Files
SGF/app/Modules/Finance/FinancialIndicators.php
T
2026-11-11 09:00:17 +02:00

72 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Modules\Finance;
use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use App\Modules\Identity\Services\{Permissions,Audit};use Illuminate\Support\Facades\{DB,Crypt,Validator};use Brick\Math\{BigRational,RoundingMode};use Tempo\Finance\Payables\Payables;
final class FinancialIndicators
{
public const METRICS=['cash_coverage_single_payable','cash_expense_share'];
public function __construct(private CompanyContext $context,private Permissions $permissions,private FinancialIndicatorSource $source){}
private function json(array $v):string{return Payables::canonical($v);}private function hash(array $v):string{return hash('sha256',$this->json($v));}
public static function templates():array{return ['cash_coverage_single_payable'=>['label'=>'Cobertura QA de uma obrigação por Caixa','numerator'=>'cash_balance','denominator'=>'supplier_credit_balance','denominator_direction'=>'credit','currency'=>'MZN','representation'=>'times_2','as_of'=>'2026-11-10'],'cash_expense_share'=>['label'=>'Despesa/entradas da Caixa QA','numerator'=>'cash_expenses','denominator'=>'cash_receipts','denominator_direction'=>'inflow','currency'=>'MZN','representation'=>'percent_2','as_of'=>'2026-11-10']];}
public function scope(int $actor,string $right='view'):array
{
abort_unless(config('analysis_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&ModuleRecords::active()==='finance',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(array_unique(['view',$right])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'financial_analysis.'.$r,$s['unit_id']);return $s;
}
private function criteria(array $s){return DB::table('an_criteria')->where($s);}
private function row(object $r):array{$a=(array)$r;foreach(['definition','input','result','source_snapshot']as$k)if(isset($a[$k]))$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return $a;}
private function currentCriteria(array $s){$q=$this->criteria($s);return $q->whereNotExists(function($sub){$sub->selectRaw('1')->from('an_criteria as newer');foreach(['tenant_id','company_id','unit_id','metric']as$k)$sub->whereColumn('newer.'.$k,'an_criteria.'.$k);$sub->whereColumn('newer.version','>','an_criteria.version');});}
private function snapshot(int $actor,array $s):array
{
try{return $this->source->snapshot($actor,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==409)throw $e;$v=['available'=>false,'kind'=>'unavailable','pending'=>[$e->getMessage()],'scope'=>$s];$v['hash']=$this->hash($v);return $v;}
}
private function basis(int $actor,array $s,array $d):array{$source=$this->snapshot($actor,$s);$q=$this->criteria($s);$latest=isset($d['metric'])?(clone$q)->where('metric',$d['metric'])->orderByDesc('version')->first():null;$selected=isset($d['data']['criterion_id'])?(clone$q)->where('id',$d['data']['criterion_id'])->first():null;return ['source'=>$source,'latest'=>$latest?$this->row($latest):null,'selected'=>$selected?$this->row($selected):null,'criteria_count'=>(clone$q)->count(),'criteria_last_id'=>(int)(clone$q)->max('id')];}
private function exact(array $d,array $rules):array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora dos indicadores aprovados.');return Validator::make($d,$rules)->validate();}
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();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
}
private function right(string $action):string{return $action==='review'?'review':'prepare';}
public function preview(int $actor,array $input,string $key):array
{
abort_if(strlen(json_encode($input))>16384,422);$d=$this->exact($input,['action'=>'required|in:prepare,review,calculate','metric'=>'nullable|required_unless:action,calculate|in:'.implode(',',self::METRICS),'version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:4000']);$d['version']=(int)$d['version'];abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$this->right($d['action']));return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));$b=$this->basis($actor,$s,$d);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'observed'=>time(),'key'=>$key,'input'=>$d,'basis'=>$this->hash($b),'impact'=>$out['impact']];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
}
public function confirm(int $actor,string $preview):array
{
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$c['input'];$s=$this->scope($actor,$this->right($d['action']));return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('an_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$b=$this->basis($actor,$s,$d);abort_unless($this->hash($b)===$c['basis'],409,'Critérios, fontes ou versão mudaram. Exige nova conferência.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('an_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('financial_analysis.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'before_revision'=>$d['version'],'reason'=>$d['reason'],'review_hash'=>$b['source']['hash']]);return $out+['replayed'=>false];});
}
private function execute(int $actor,array $s,array $d,array $b):array
{
if($d['action']==='calculate'){
abort_if(!empty($d['metric'])||$d['version']!==0,422);$in=$this->exact($d['data'],['criterion_id'=>'nullable|integer|min:1','as_of'=>'required|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31']);abort_if(isset($in['criterion_id'])&&!$b['selected'],403,'Critério fora do contexto.');$result=$this->evaluate($s,$b['source'],$b['selected'],$in);$id=DB::table('an_results')->insertGetId($s+['actor_id'=>$actor,'criterion_id'=>$b['selected']['id']??null,'input'=>$this->json($in),'result'=>$this->json($result),'source_snapshot'=>$this->json($b['source']),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'result','version'=>0,'state'=>$result['state'],'impact'=>['action'=>'calculate','result'=>$result,'new_financial_effects'=>0,'real_company_criterion'=>false]];
}
$last=$b['latest'];abort_unless(($last['version']??0)===$d['version'],409,'Versão do critério alterada.');$prepared=$actor;
if($d['action']==='prepare'){
$template=self::templates()[$d['metric']];$def=$this->exact($d['data'],['numerator'=>'required|string','denominator'=>'required|string','denominator_direction'=>'required|string','currency'=>'required|in:MZN','representation'=>'required|string','as_of'=>'required|in:2026-11-10','scope_description'=>'required|string|min:10|max:2000','evidence'=>'required|string|min:10|max:2000']);foreach($template as$k=>$value)if($k!=='label')abort_unless(($def[$k]??null)===$value,422,'Fontes/direcção/fórmula fora dos dois indicadores QA aprovados.');$def['label']=$template['label'];$state='prepared';
}else{
$review=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação actual e revisor distinto obrigatórios.');$prepared=$last['prepared_by'];DB::table('users')->where('id',$prepared)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),403);abort_unless(DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>'financial_analysis.prepare'])->lockForUpdate()->first(),403,'Preparador sem autoridade actual.');abort_unless($last['source_hash']===$b['source']['hash'],409,'Fontes mudaram depois da preparação.');abort_unless($b['source']['available'],409,'Fontes não conciliadas; não podem ser aprovadas por inferência.');$def=$last['definition'];$state=$review['decision'];
}
$version=$d['version']+1;$id=DB::table('an_criteria')->insertGetId($s+['metric'=>$d['metric'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'definition'=>$this->json($def),'source_hash'=>$b['source']['hash'],'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'criterion','version'=>$version,'state'=>$state,'impact'=>['action'=>$d['action'],'before_version'=>$d['version'],'before_state'=>$last['state']??null,'after_version'=>$version,'state'=>$state,'definition'=>$def,'sources'=>$b['source'],'new_financial_effects'=>0,'real_company_criterion'=>false]];
}
private function evaluate(array $s,array $source,?array $criterion,array $in):array
{
$pending=[];$def=$criterion['definition']??null;if(!$criterion)$pending[]='Escolha explícita de critério em falta.';else{if($criterion['state']!=='reviewed'||$this->criteria($s)->where('metric',$criterion['metric'])->orderByDesc('version')->value('id')!==$criterion['id'])$pending[]='Critério não revisto ou versão antiga.';if($criterion['source_hash']!==$source['hash'])$pending[]='Fontes mudaram; exige nova conferência do critério.';}
if(!$source['available'])$pending[]='Fonte ausente/não conciliada; não é zero.';if(($source['currency']??null)!=='MZN')$pending[]='Moeda incompatível ou desconhecida.';if(($source['unit']??null)!=='money')$pending[]='Dimensão incompatível.';if($in['as_of']!=='2026-11-10'||($source['as_of']??null)!==$in['as_of'])$pending[]='Retrato temporal não disponível para essa data.';
$num=null;$den=null;if($criterion&&$source['available']){if($criterion['metric']==='cash_coverage_single_payable'){$num=$source['cash_balance_minor']??null;$den=$source['supplier_credit_minor']??null;}else{$num=$source['cash_expenses_minor']??null;$den=$source['cash_receipts_minor']??null;}if($num===null||$den===null)$pending[]='Numerador/denominador sem fonte conhecida.';elseif(BigRational::of($den)->isLessThanOrEqualTo(0))$pending[]='Denominador nulo ou sem direcção positiva/credora comprovada.';elseif(BigRational::of($num)->isLessThan(0))$pending[]='Numerador com sinal fora do critério aprovado.';}
if($pending)return ['state'=>'pending','pending'=>$pending,'value'=>null,'exact_fraction'=>null,'numerator_minor'=>null,'denominator_minor'=>null,'synthetic_only'=>true,'real_company_criterion'=>false,'previous_period'=>null,'trend'=>null];
$ratio=BigRational::of($num)->dividedBy($den)->simplified();$percent=$criterion['metric']==='cash_expense_share';$represented=$ratio->multipliedBy($percent?100:1);$display=$represented->toScale(2,RoundingMode::Down);return ['state'=>'determined_qa','label'=>$def['label'],'metric'=>$criterion['metric'],'criterion_id'=>$criterion['id'],'criterion_version'=>$criterion['version'],'as_of'=>$in['as_of'],'numerator_minor'=>(string)$num,'denominator_minor'=>(string)$den,'currency'=>'MZN','exact_fraction'=>(string)$ratio,'value'=>(string)$display,'unit'=>$percent?'percent':'times','representation_difference_fraction'=>(string)$represented->minus(BigRational::of((string)$display))->simplified(),'formula'=>$percent?'despesa / entradas ×100':'Caixa / saldo credor QA-FORNECEDOR','source_hash'=>$source['hash'],'previous_period'=>null,'trend'=>null,'notes'=>['Indicador limitado ao exercício QA; não rácio geral de liquidez/margem ou recomendação de pagamento.','Sem série anterior validada; não se apresenta variação zero.'],'pending'=>[],'synthetic_only'=>true,'real_company_criterion'=>false];
}
public function listing(int $actor,array $input):array
{
$s=$this->scope($actor);$d=$this->exact($input,['view'=>'sometimes|in:criteria,history,results','metric'=>'sometimes|in:'.implode(',',self::METRICS),'state'=>'sometimes|in:prepared,reviewed,refused','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$view=$d['view']??'criteria';abort_if($view==='history'&&!isset($d['metric']),422);abort_if($view==='results'&&array_intersect(array_keys($d),['metric','state','search']),422);
return DB::transaction(function()use($actor,$s,$d,$view){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$view==='results'?DB::table('an_results')->where($s):($view==='history'?$this->criteria($s):$this->currentCriteria($s));if(isset($d['metric']))$q->where('metric',$d['metric']);if(isset($d['state']))$q->where('state',$d['state']);if(!empty($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($w)=>$w->whereRaw("definition->>'label' ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("definition->>'evidence' ILIKE ? ESCAPE '!'",[$needle]));}$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$page->setCollection($page->getCollection()->map(fn($r)=>$this->row($r)));return ['templates'=>self::templates(),'view'=>$view,'records'=>$page->toArray(),'metric_version'=>isset($d['metric'])?(int)$this->criteria($s)->where('metric',$d['metric'])->max('version'):null,'source'=>$this->snapshot($actor,$s),'capabilities'=>array_combine(['prepare','review'],array_map(fn($r)=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'financial_analysis.'.$r,$s['unit_id']),['prepare','review'])),'real_company_criterion'=>false];});
}
public function detail(int $actor,int $id,string $type='criterion'):array
{
$s=$this->scope($actor);$row=DB::table($type==='result'?'an_results':'an_criteria')->where($s)->where('id',$id)->first();abort_unless($row,404);$a=$this->row($row);if($type==='criterion')$a['current_version']=(int)$this->criteria($s)->where('metric',$a['metric'])->max('version');else{$source=$this->snapshot($actor,$s);$criterion=$a['criterion_id']?$this->criteria($s)->where('id',$a['criterion_id'])->first():null;$a['current_evaluation']=$this->evaluate($s,$source,$criterion?$this->row($criterion):null,$a['input']);$a['historical_snapshot_unchanged']=true;}return $a;
}
public function receipt(int $actor,string $key):array{$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$r=DB::table('an_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'created_at'=>$r->created_at,'historical_receipt_only'=>true,'operation_executed'=>false];}
public function report(int $actor,int $id):array
{
$r=$this->detail($actor,$id,'result');$v=$r['result'];$fp=fopen('php://temp','w+');$rows=[['INDICADOR INTERNO QA — NAO E RACIO GERAL OU RECOMENDACAO'],['Estado historico',$v['state']],['Estado actual',$r['current_evaluation']['state']],['Data do retrato',$r['input']['as_of']],['Indicador',$v['label']??'Indeterminado'],['Numerador MZN',isset($v['numerator_minor'])?(string)\Brick\Math\BigDecimal::of($v['numerator_minor'])->dividedBy('100',2):'INDETERMINADO'],['Denominador MZN',isset($v['denominator_minor'])?(string)\Brick\Math\BigDecimal::of($v['denominator_minor'])->dividedBy('100',2):'INDETERMINADO'],['Fraccao exacta',$v['exact_fraction']??'INDETERMINADO'],['Representacao',$v['value']??'INDETERMINADO'],['Unidade',$v['unit']??'INDETERMINADO'],['Versao do criterio',(string)($v['criterion_version']??0)],['Serie anterior','INDETERMINADA'],['Tendencia','INDETERMINADA']];foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);$s=$this->scope($actor);Audit::record('financial_analysis.report_downloaded',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>0,'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];
}
}