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