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
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Modules\Finance;
use App\Shared\Installation\CompositionRuntime;use App\Modules\Accounting\SourceBook;use Illuminate\Support\Facades\DB;
/** Strict read adapter over two existing native QA lines. No fixture fallback or bank classification. */
class BankLedgerSource
{
public function snapshot(array $scope,array $binding):array
{
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/binding.json';abort_unless(realpath((string)config('receipt_qa.profile'))===realpath($file),403);$profile=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);$c=$profile['connection'];abort_unless($c['database']==='compras_receipts_test_'.$p['composition']&&$c['username']==='payable_history_native_'.$p['composition']&&$c['host']==='127.0.0.1'&&(int)$c['port']===55439,403);config(['database.connections.receipt_native'=>$c]);
return $this->read($scope,$binding);
}
private function read(array $s,array $b):array
{
$rows=SourceBook::table('gl_lines')->where($s)->whereIn('id',$b['ledger_line_ids'])->orderBy('id')->get();abort_unless($rows->count()===2,409,'As duas fontes contabilísticas do exercício não estão disponíveis.');$out=[];
foreach($rows as$r){$j=SourceBook::table('gl_journals')->where($s)->where('id',$r->journal_id)->first();$a=SourceBook::table('gl_accounts')->where($s)->where('id',$r->account_id)->first();abort_unless($j&&$a&&$a->code==='QA-LIQUIDACAO'&&$j->currency==='MZN'&&$j->journal_on==='2026-11-10'&&max($r->debit_minor,$r->credit_minor)===2000,409,'Linha contabilística incompatível com o exercício autorizado.');$out[]=['id'=>$r->id,'journal_id'=>$r->journal_id,'account_code'=>$a->code,'date'=>$j->journal_on,'currency'=>'MZN','debit_minor'=>$r->debit_minor,'credit_minor'=>$r->credit_minor,'journal_sha256'=>hash('sha256',$j->snapshot)];}
return ['kind'=>'native_qa_ledger','lines'=>$out,'hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($out))];
}
}
@@ -0,0 +1,86 @@
<?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 Tempo\Finance\Payables\Payables;
/** A bounded internal statement exercise. Never posts, pays or changes account classification. */
final class BankReconciliation
{
public function __construct(private CompanyContext $context,private Permissions $permissions,private BankLedgerSource $source){}
private function json(array $a):string{return Payables::canonical($a);}private function hash(array $a):string{return hash('sha256',$this->json($a));}
public function scope(int $actor,string $right='view'):array
{
abort_unless(config('bank_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'],'bank_reconciliation.'.$r,$s['unit_id']);abort_unless($this->binding()['scope']===$s,403);return $s;
}
public function binding():array
{
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/bank-binding.json';$pin=config('bank_qa.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503,'Vínculo do exercício bancário não conferido.');$b=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($b['composition']===$p['composition']&&$b['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&$b['ledger_line_ids']===[4,5],403);return $b;
}
private function statements(array $s,array $b){return DB::table('br_statements')->where($s)->where('statement_key',$b['statement_key']);}
private function events(array $s,int $id){return DB::table('br_events')->where($s)->where('statement_id',$id);}
private function row(object $r):array{$a=(array)$r;foreach(['entries','data']as$k)if(isset($a[$k]))$a[$k]=json_decode($a[$k],true,32,JSON_THROW_ON_ERROR);return $a;}
private function basis(array $s,array $b):array
{
$source=$this->source->snapshot($s,$b);$header=$this->statements($s,$b)->first();$last=$header?$this->events($s,$header->id)->orderByDesc('version')->first():null;
abort_if($header&&$header->source_hash!==$source['hash'],409,'A origem contabilística mudou. Confira o exercício; não há ajuste automático.');return ['binding'=>$this->hash($b),'source'=>$source,'header'=>$header?$this->row($header):null,'last'=>$last?$this->row($last):null];
}
private function exact(array $d,array $rules):array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora do exercício autorizado.');return Validator::make($d,$rules)->validate();}
private function right(string $a):string{return match($a){'import'=>'import','prepare_match'=>'prepare',default=>'review'};}
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();
}
public function preview(int $actor,array $input,string $key):array
{
abort_if(strlen(json_encode($input))>32768,422);$d=$this->exact($input,['action'=>'required|in:import,review_statement,prepare_match,review_match,finalize','version'=>'required|integer|min:0','proposal_id'=>'nullable|required_if:action,review_match|integer|min:1','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']));$b=$this->binding();return DB::transaction(function()use($actor,$s,$b,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));$basis=$this->basis($s,$b);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$b,$d,$basis);}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($basis),'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']));$b=$this->binding();return DB::transaction(function()use($actor,$s,$b,$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('br_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];}$basis=$this->basis($s,$b);abort_unless($this->hash($basis)===$c['basis'],409,'Extracto, origem ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$b,$d,$basis);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('br_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('bank_qa.'.$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'=>$basis['source']['hash']]);return $out+['replayed'=>false];});
}
private function parse(array $d,array $b):array
{
$d=$this->exact($d,['account_ref'=>'required|in:QA-BANCO-LOCAL','currency'=>'required|in:MZN','period'=>'required|in:2026-11','opening'=>'required|in:0.00','closing'=>'required|in:0.00','csv'=>'required|string|max:8192']);$fp=fopen('php://temp','w+');fwrite($fp,$d['csv']);rewind($fp);abort_unless(fgetcsv($fp,0,',','"','')===['date','reference','debit','credit','balance'],422,'Cabeçalho CSV inválido.');$rows=[];$balance=0;while(($row=fgetcsv($fp,0,',','"',''))!==false){if($row===[null])continue;abort_unless(count($row)===5&&count($rows)<2,422,'Este exercício exige exactamente duas linhas.');[$date,$ref,$debit,$credit,$closing]=$row;$expected=count($rows)===0?['2026-11-10','QA-OUT-20','20.00','0.00','-20.00']:['2026-11-10','QA-IN-20','0.00','20.00','0.00'];abort_unless($row===$expected,422,'Data, referência, sinal, valor ou saldo fora do exercício artificial autorizado.');$out=$debit==='20.00'?2000:0;$in=$credit==='20.00'?2000:0;$balance+=$in-$out;abort_unless(($closing==='-20.00'?-2000:0)===$balance,422);$rows[]=['date'=>$date,'reference'=>$ref,'debit_minor'=>$out,'credit_minor'=>$in,'balance_minor'=>$balance];}fclose($fp);abort_unless(count($rows)===2&&$balance===0,422,'Fecho/linhas incompletos.');return $d+['entries'=>$rows,'csv_sha256'=>hash('sha256',$d['csv'])];
}
private function reviewed(array $s,int $id,string $state):bool{return $this->events($s,$id)->where('state',$state)->exists();}
private function checkPreparer(int $actor,int $prepared,array $s,string $permission):void
{
abort_unless($actor!==$prepared,403,'Revisão por pessoa distinta obrigatória.');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'=>$permission])->lockForUpdate()->first(),403,'Preparador sem autoridade actual.');
}
private function execute(int $actor,array $s,array $b,array $d,array $basis):array
{
$h=$basis['header'];$last=$basis['last'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada.');$prepared=$actor;$proposal=null;$data=[];$state='';$id=$h['id']??null;
if($d['action']==='import'){
abort_if(isset($d['proposal_id']),422);$parsed=$this->parse($d['data'],$b);if($h){abort_unless($parsed['csv_sha256']===$h['csv_sha256'],409,'Conserve o extracto importado; não pode substituir a fotografia.');return ['id'=>$last['id'],'statement_id'=>$id,'version'=>$last['version'],'state'=>$last['state'],'already_imported'=>true,'impact'=>['action'=>'import','before_version'=>$d['version'],'after_version'=>$d['version'],'state'=>$last['state'],'existing_statement'=>true,'new_journals'=>0,'bank_operations'=>false]];}
$id=DB::table('br_statements')->insertGetId($s+['statement_key'=>$b['statement_key'],'account_ref'=>$parsed['account_ref'],'currency'=>$parsed['currency'],'period'=>$parsed['period'],'csv_sha256'=>$parsed['csv_sha256'],'csv_bytes'=>$parsed['csv'],'opening_minor'=>0,'closing_minor'=>0,'entries'=>$this->json($parsed['entries']),'source_hash'=>$basis['source']['hash'],'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()]);$state='imported';$data=['csv_sha256'=>$parsed['csv_sha256'],'opening_minor'=>0,'closing_minor'=>0,'rows'=>2];
}else{
abort_unless($h,409,'Importe e confira o extracto primeiro.');abort_if($this->reviewed($s,$id,'finalized'),409,'Conferência finalizada, sem reabertura.');
if($d['action']==='review_statement'){$decision=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);abort_if($this->events($s,$id)->where('action','review_statement')->exists(),409,'O extracto já recebeu decisão.');$prepared=$h['actor_id'];$this->checkPreparer($actor,$prepared,$s,'bank_reconciliation.import');$state=$decision['decision']==='reviewed'?'statement_reviewed':'statement_refused';$data=['csv_sha256'=>$h['csv_sha256'],'opening_minor'=>$h['opening_minor'],'closing_minor'=>$h['closing_minor']];}
else{
abort_unless($this->reviewed($s,$id,'statement_reviewed'),409,'Extracto sem revisão favorável.');
if($d['action']==='prepare_match'){
abort_if(isset($d['proposal_id']),422);$data=$this->exact($d['data'],['row_reference'=>'required|in:QA-OUT-20,QA-IN-20','ledger_line_id'=>'required|integer|min:1']);$row=null;$line=null;foreach($h['entries']as$r)if($r['reference']===$data['row_reference'])$row=$r;foreach($basis['source']['lines']as$l)if($l['id']===(int)$data['ledger_line_id'])$line=$l;abort_unless($row&&$line,403,'Escolha individual da linha de extracto e origem autorizadas.');abort_unless($row['debit_minor']===$line['credit_minor']&&$row['credit_minor']===$line['debit_minor']&&$row['date']===$line['date'],422,'Sinal/data/valor não correspondem.');
$used=$this->events($s,$id)->where('state','match_reviewed')->where(fn($q)=>$q->where('data->row_reference',$data['row_reference'])->orWhere('data->ledger_line_id',$data['ledger_line_id']))->exists();abort_if($used,409,'Linha já conferida; não pode ser usada novamente.');$pending=$this->events($s,$id)->where('state','match_prepared')->where('data->row_reference',$data['row_reference'])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('br_events as r')->whereColumn('r.proposal_id','br_events.id'))->exists();abort_if($pending,409,'Já existe uma proposta por rever nesta linha.');$state='match_prepared';$data=['row_reference'=>$row['reference'],'ledger_line_id'=>$line['id'],'journal_id'=>$line['journal_id'],'debit_minor'=>$row['debit_minor'],'credit_minor'=>$row['credit_minor'],'source_hash'=>$basis['source']['hash']];
}elseif($d['action']==='review_match'){
$decision=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);$p=$this->events($s,$id)->where(['id'=>$d['proposal_id'],'state'=>'match_prepared'])->first();abort_unless($p,403);abort_if($this->events($s,$id)->where('proposal_id',$p->id)->exists(),409,'Proposta já revista.');$prepared=$p->prepared_by;$this->checkPreparer($actor,$prepared,$s,'bank_reconciliation.prepare');$proposal=$p->id;$data=json_decode($p->data,true);abort_unless($data['source_hash']===$basis['source']['hash'],409);$state=$decision['decision']==='reviewed'?'match_reviewed':'match_refused';
}else{
$this->exact($d['data'],[]);abort_if(isset($d['proposal_id']),422);$prepared=$h['actor_id'];$this->checkPreparer($actor,$prepared,$s,'bank_reconciliation.import');abort_unless($this->events($s,$id)->where('state','match_reviewed')->count()===2,409,'Há linhas por conferir.');abort_if($this->events($s,$id)->where('state','match_prepared')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('br_events as r')->whereColumn('r.proposal_id','br_events.id'))->exists(),409,'Há propostas por rever.');$state='finalized';$data=['opening_minor'=>0,'closing_minor'=>0,'difference_minor'=>0,'matched_rows'=>2,'source_hash'=>$basis['source']['hash']];
}
}
}
$version=$d['version']+1;$event=DB::table('br_events')->insertGetId($s+['statement_id'=>$id,'version'=>$version,'previous_id'=>$last['id']??null,'proposal_id'=>$proposal,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'data'=>$this->json($data),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$event,'statement_id'=>$id,'version'=>$version,'state'=>$state,'impact'=>['action'=>$d['action'],'before_version'=>$d['version'],'after_version'=>$version,'before_state'=>$last['state']??null,'state'=>$state,'data'=>$data,'new_journals'=>0,'bank_operations'=>false]];
}
public function listing(int $actor,array $input):array
{
$s=$this->scope($actor);$d=$this->exact($input,['view'=>'sometimes|in:rows,history','status'=>'sometimes|in:unmatched,pending,matched','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$b=$this->binding();return DB::transaction(function()use($actor,$s,$d,$b){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$basis=null;$blocked=null;try{$basis=$this->basis($s,$b);}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==409)throw $e;$blocked=$e->getMessage();}$h=$basis['header']??($this->statements($s,$b)->first()?->id? $this->row($this->statements($s,$b)->first()):null);$rows=[];$pending=[];$matches=[];$history=null;$statementReviewed=false;$final=false;
if($h){$matches=$this->events($s,$h['id'])->where('state','match_reviewed')->get()->map(fn($r)=>$this->row($r))->all();$pending=$this->events($s,$h['id'])->where('state','match_prepared')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('br_events as r')->whereColumn('r.proposal_id','br_events.id'))->get()->map(fn($r)=>$this->row($r))->all();foreach($h['entries']as$r){$r['status']='unmatched';foreach($matches as$m)if($m['data']['row_reference']===$r['reference']){$r['status']='matched';$r['match']=$m;}foreach($pending as$p)if($p['data']['row_reference']===$r['reference']){$r['status']='pending';$r['proposal']=$p;}if(!isset($d['status'])||$d['status']===$r['status'])$rows[]=$r;}$q=$this->events($s,$h['id']);$history=$q->orderByDesc('version')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$history->setCollection($history->getCollection()->map(fn($r)=>$this->row($r)));$statementReviewed=$this->reviewed($s,$h['id'],'statement_reviewed');$final=$this->reviewed($s,$h['id'],'finalized');unset($h['csv_bytes']);}
return ['synthetic_only'=>true,'account_is_real_bank'=>false,'blocked'=>$blocked,'source_kind'=>$basis['source']['kind']??null,'source_lines'=>$basis['source']['lines']??[],'statement'=>$h,'version'=>$basis['last']['version']??0,'statement_reviewed'=>$statementReviewed,'finalized'=>$final,'rows'=>$rows,'pending_proposals'=>$pending,'history'=>$history?->toArray(),'report_available'=>$final&&!$blocked,'capabilities'=>array_combine(['import','prepare','review'],array_map(fn($r)=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'bank_reconciliation.'.$r,$s['unit_id']),['import','prepare','review'])),'template_csv'=>file_get_contents(base_path('resources/data/bank-statement-qa.csv')),'new_journals'=>0];});
}
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('br_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'operation_executed'=>false,'historical_receipt_only'=>true,'created_at'=>$r->created_at];
}
public function report(int $actor):array
{
$s=$this->scope($actor);$b=$this->binding();return DB::transaction(function()use($actor,$s,$b){$this->lock($actor,$s);$basis=$this->basis($s,$b);$h=$basis['header'];abort_unless($h&&$this->reviewed($s,$h['id'],'finalized'),409,'Conclua a conferência antes do relatório.');$matches=$this->events($s,$h['id'])->where('state','match_reviewed')->orderBy('id')->get();$fp=fopen('php://temp','w+');$rows=[['CONFERENCIA INTERNA QA — NAO E EXTRACTO BANCARIO REAL'],['Conta de exercicio',$h['account_ref']],['Abertura','0.00'],['Fecho','0.00'],['Diferenca','0.00'],['Linha extracto','Linha contabilistica','Diario','Debito extracto MZN','Credito extracto MZN']];foreach($matches as$m){$d=json_decode($m->data,true);$rows[]=[$d['row_reference'],(string)$d['ledger_line_id'],(string)$d['journal_id'],(string)\Brick\Math\BigDecimal::of($d['debit_minor'])->dividedBy('100',2),(string)\Brick\Math\BigDecimal::of($d['credit_minor'])->dividedBy('100',2)];}foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);Audit::record('bank_qa.report_downloaded',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$h['id'],'version'=>$basis['last']['version'],'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];});
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Modules\Finance;
use App\Modules\Identity\Services\{Permissions,Audit};use App\Modules\Services\{ServiceReceivables,DocumentPeriod};use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use App\Modules\Accounting\SourceBook;use Illuminate\Support\Facades\{DB,Crypt,Validator};use Tempo\Finance\Payables\Payables;
/** One explicit QA custody book; never issues invoices/receipts or moves real money. */
final class CashWorkspace
{
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function json(array $v):string{return Payables::canonical($v);}private function hash(array $v):string{return hash('sha256',$this->json($v));}
public function scope(int $actor,string $right='view',bool $ledger=false):array
{
abort_unless(config('cash_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&in_array(ModuleRecords::active(),$ledger?['finance','accounting']:['finance'],true),403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['view',$right] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'cash.'.$r,$s['unit_id']);abort_unless($this->binding()['scope']==$s,403);return $s;
}
public function binding():array
{
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/cash-binding.json';$pin=config('cash_qa.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);$b=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($b['composition']===$p['composition']&&$b['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);return $b;
}
private function events(array $s,array $b){return DB::table('cash_events')->where($s)->where('session_key',$b['session_key']);}
public function receipt(array $s,array $b):array
{
$r=DB::table('sr_records')->where($s)->where(['id'=>$b['receipt_id'],'invoice_id'=>$b['invoice_id'],'kind'=>'receipt'])->first();$confirm=$r?DB::table('sr_effects')->where(['record_id'=>$r->id,'kind'=>'receipt_confirm'])->first():null;$doc=DB::table('sd_outputs')->where($s)->where(['id'=>$b['source_output_id'],'kind'=>'invoice_receipt','invoice_id'=>$b['invoice_id']])->first();$allocation=$doc?DB::table('sr_effects')->where(['id'=>$doc->effect_id,'receipt_id'=>$b['receipt_id'],'kind'=>'allocate'])->first():null;
abort_unless($r&&$confirm&&$doc&&$allocation&&$r->actor_id!==$confirm->actor_id&&ServiceReceivables::money($r->amount)==='1000.00'&&ServiceReceivables::money($allocation->amount)==='1000.00'&&!DB::table('sr_effects')->where('receipt_id',$r->id)->where('kind','reverse_receipt')->exists()&&!DB::table('sr_effects')->where('target_effect_id',$allocation->id)->exists(),409,'A origem foi alterada/revertida ou não está confirmada. Confira a custódia; não há reembolso automático.');
abort_unless(hash_equals($doc->pdf_sha256,hash('sha256',base64_decode($doc->pdf_base64,true))),409);return ['receipt'=>(array)$r,'confirmation'=>(array)$confirm,'allocation'=>(array)$allocation,'document'=>['id'=>$doc->id,'invoice_id'=>$doc->invoice_id,'sha256'=>$doc->pdf_sha256]];
}
public function basis(array $s,array $b):array
{
$q=$this->events($s,$b);$balance=(int)(clone$q)->where(['action'=>'review','state'=>'approved'])->selectRaw("coalesce(sum(CASE kind WHEN 'receipt' THEN (data->>'amount_minor')::bigint WHEN 'expense' THEN -(data->>'amount_minor')::bigint ELSE 0 END),0) balance")->value('balance');$fingerprint=(clone$q)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->orderBy('id')->pluck('id')->all();
return ['binding'=>$this->hash($b),'revision'=>(int)(clone$q)->max('version'),'open'=>(clone$q)->where(['kind'=>'open','action'=>'review','state'=>'approved'])->exists(),'closed'=>(clone$q)->where('state','closed')->exists(),'received'=>(clone$q)->where(['kind'=>'receipt','action'=>'review','state'=>'approved'])->exists(),'expense_recorded'=>(clone$q)->where(['kind'=>'expense','action'=>'review','state'=>'approved'])->exists(),'balance_minor'=>$balance,'movements_hash'=>$this->hash($fingerprint),'source'=>$this->hash($this->receipt($s,$b))];
}
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(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->where(fn($q)=>$q->whereNull('unit_id')->orWhere('unit_id',$s['unit_id']))->orderBy('id')->lockForUpdate()->get();
}
public function preview(int $actor,array $input,string $key):array
{
abort_if(strlen(json_encode($input,JSON_THROW_ON_ERROR))>65536,422);$rules=['action'=>'required|in:prepare,review,close','kind'=>'required|in:open,receipt,expense,count','proposal_id'=>'nullable|integer|min:1','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:4000'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();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,$d['action']);$b=$this->binding();
return DB::transaction(function()use($actor,$s,$d,$key,$b){$this->lock($actor,$s);$this->scope($actor,$d['action']);$basis=$this->basis($s,$b);abort_unless($basis['revision']===$d['version'],409,'Versão da Caixa alterada.');$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$b,$d,$basis);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['input'=>$d,'key'=>$key,'actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'basis'=>$this->hash($basis),'impact'=>$out['impact'],'observed'=>time()];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,$d['action']);$b=$this->binding();return DB::transaction(function()use($actor,$s,$d,$c,$b){$this->lock($actor,$s);$this->scope($actor,$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('cash_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];}$basis=$this->basis($s,$b);abort_unless($this->hash($basis)===$c['basis'],409,'Caixa, origem, estado ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$b,$d,$basis);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('cash_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('cash.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'reason'=>$d['reason']]);return $out+['replayed'=>false];});
}
private function exact(array $data,array $rules):array{abort_if(array_diff(array_keys($data),array_keys($rules)),422);return Validator::make($data,$rules)->validate();}
private function execute(int $actor,array $s,array $b,array $d,array $basis):array
{
abort_if($basis['closed'],409,'Caixa fechada; não há reabertura.');$period=DocumentPeriod::open($s);$q=$this->events($s,$b);$data=[];$source=null;$proposal=null;$state='proposed';$kind=$d['kind'];$change=0;
if($d['action']==='prepare'){
abort_if($d['proposal_id']??null,422);abort_unless($kind==='open'?!$basis['open']:$basis['open'],409,'Confirme a abertura primeiro.');$common=['date'=>'required|date_format:Y-m-d|before_or_equal:today','evidence'=>'required|string|min:10|max:1000'];
$data=$this->exact($d['data'],match($kind){'open'=>$common+['label'=>'required|string|min:3|max:100','opening'=>'required|string'],'receipt'=>$common+['receipt_id'=>'required|integer|min:1','medium'=>'required|in:cash'],'expense'=>$common+['amount'=>'required|string','beneficiary'=>'required|string|min:3|max:150','description'=>'required|string|min:10|max:1000'],'count'=>$common+['counted'=>'required|string']});
if($kind==='open'){abort_unless(ServiceReceivables::money($data['opening'],false)==='0.00',422,'Abertura QA explicitamente zero.');$data['amount_minor']=0;}
if($kind==='receipt'){abort_unless($data['receipt_id']===$b['receipt_id'],403,'Escolha a origem QA autorizada.');abort_if((clone$q)->where(['kind'=>'receipt','action'=>'review','state'=>'approved'])->exists(),409,'Recebimento já classificado.');$source=$b['receipt_id'];$data['amount_minor']=$b['amount_minor'];}
if($kind==='expense'){abort_unless(ServiceReceivables::money($data['amount'])==='50.00'&&$basis['balance_minor']>=5000,422,'Despesa QA50 exige saldo suficiente.');abort_if((clone$q)->where(['kind'=>'expense','action'=>'review','state'=>'approved'])->exists(),409,'Despesa QA já confirmada.');$data['amount_minor']=5000;}
if($kind==='count'){$data['counted_minor']=(int)\Brick\Math\BigDecimal::of(ServiceReceivables::money($data['counted'],false))->multipliedBy(100)->toInt();abort_if($data['counted_minor']<0,422);}
if($kind!=='open'){$opened=(clone$q)->where(['kind'=>'open','action'=>'review','state'=>'approved'])->firstOrFail();abort_unless($data['date']>=json_decode($opened->data,true)['date'],422,'O facto não pode anteceder a abertura.');}
if($kind==='receipt'){$original=$this->receipt($s,$b);$on=json_decode($original['receipt']['data'],true)['date']??null;abort_unless(is_string($on)&&$data['date']>=$on,422,'A classificação não pode anteceder o recebimento.');}
if(in_array($kind,['expense','count'],true)){$lastDate=(clone$q)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->max('data->date');abort_unless($lastDate===null||$data['date']>=$lastDate,422,'O facto não pode anteceder os movimentos confirmados.');}
}elseif($d['action']==='review'){
$choice=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);$proposal=(clone$q)->where(['id'=>$d['proposal_id']??0,'action'=>'prepare','kind'=>$kind])->firstOrFail();abort_if($proposal->actor_id===$actor,403,'Revisão exige pessoa distinta.');abort_if((clone$q)->where('proposal_id',$proposal->id)->exists(),409,'Proposta já decidida.');$data=json_decode($proposal->data,true);$deps=json_decode($proposal->dependencies,true);$state=$choice['decision'];$source=$proposal->source_receipt_id;
if($state==='approved'){
DB::table('users')->where('id',$proposal->actor_id)->lockForUpdate()->firstOrFail();$this->permissions->require($proposal->actor_id,$s['tenant_id'],$s['company_id'],'cash.prepare',$s['unit_id']);abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$proposal->actor_id])->lockForUpdate()->first(),403);
abort_unless($deps['source']===$basis['source'],409,'Origem alterada desde a preparação.');
if($kind==='open')abort_if($basis['open'],409);else abort_unless($basis['open'],409);
if($kind==='receipt'){abort_if((clone$q)->where(['kind'=>'receipt','action'=>'review','state'=>'approved'])->exists(),409);$change=100000;}
if($kind==='expense'){abort_unless($basis['balance_minor']>=5000,409,'Saldo insuficiente.');abort_if((clone$q)->where(['kind'=>'expense','action'=>'review','state'=>'approved'])->exists(),409);$change=-5000;}
if($kind==='count')abort_unless($deps['movements_hash']===$basis['movements_hash']&&$data['counted_minor']===$basis['balance_minor'],409,'Contagem divergente ou movimentos alterados; sem ajuste automático.');
}
}else{
abort_unless($basis['received']&&$basis['expense_recorded']&&$basis['balance_minor']===95000,409,'Complete a classificação1000, despesa50 e contagem950 deste recorte QA.');
abort_unless($kind==='count'&&$d['data']===[]&&$basis['open'],422);$proposal=(clone$q)->where(['id'=>$d['proposal_id']??0,'kind'=>'count','action'=>'review','state'=>'approved'])->firstOrFail();$count=(clone$q)->where('id',$proposal->proposal_id)->firstOrFail();abort_if($count->actor_id===$actor,403,'Quem contou não pode fechar.');$data=json_decode($proposal->data,true);$deps=json_decode($proposal->dependencies,true);abort_unless($data['counted_minor']===$basis['balance_minor']&&$deps['movements_hash']===$basis['movements_hash'],409,'Movimentos mudaram após a contagem.');
abort_if((clone$q)->where('action','prepare')->whereNotIn('id',(clone$q)->where('action','review')->whereNotNull('proposal_id')->select('proposal_id'))->exists(),409,'Decisões pendentes impedem o fecho.');
foreach((clone$q)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->get(['id','kind']) as $fact){$type=$fact->kind==='receipt'?'cash.receipt_classified':'cash.expense_confirmed';abort_unless(SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type',$type)->where('snapshot->source->source_id',$fact->id)->exists(),409,'Valorizações de Caixa pendentes no diário.');}$state='closed';
}
$id=DB::table('cash_events')->insertGetId($s+['session_key'=>$b['session_key'],'version'=>$basis['revision']+1,'action'=>$d['action'],'kind'=>$kind,'state'=>$state,'proposal_id'=>$proposal?->id,'source_receipt_id'=>$source,'actor_id'=>$actor,'data'=>$this->json($data),'dependencies'=>$this->json($basis+['period'=>$period]),'reason'=>$d['reason'],'created_at'=>now()]);
return ['id'=>$id,'version'=>$basis['revision']+1,'state'=>$state,'impact'=>['action'=>$d['action'],'kind'=>$kind,'state'=>$state,'before_minor'=>$basis['balance_minor'],'after_minor'=>$basis['balance_minor']+$change,'change_minor'=>$change,'counted_minor'=>$data['counted_minor']??null,'money_real'=>false,'new_invoice_or_receipt'=>false,'journal_posted'=>false]];
}
public function listing(int $actor,array $input):array
{
$s=$this->scope($actor);return DB::transaction(function()use($actor,$input,$s){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$d=$this->exact($input,['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','kind'=>'sometimes|in:open,receipt,expense,count','from'=>'sometimes|date_format:Y-m-d','to'=>'sometimes|date_format:Y-m-d']);abort_if(isset($d['from'],$d['to'])&&$d['from']>$d['to'],422);$b=$this->binding();$q=$this->events($s,$b);foreach(['kind'=>'kind'] as $k=>$column)if(isset($d[$k]))$q->where($column,$d[$k]);if(isset($d['from']))$q->where('data->date','>=',$d['from']);if(isset($d['to']))$q->where('data->date','<=',$d['to']);
$basis=null;$blocked=null;try{$basis=$this->basis($s,$b);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){if($e->getStatusCode()!==409)throw $e;$blocked=$e->getMessage();}
$p=$q->orderByDesc('version')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$decisions=$this->events($s,$b)->whereIn('proposal_id',$p->pluck('id')->all())->where('action','review')->get(['id','proposal_id','state'])->keyBy('proposal_id');$commandReferences=DB::table('cash_commands')->where($s)->where('actor_id',$actor)->whereIn('result->id',$p->pluck('id')->all())->get(['command_key','result'])->keyBy(fn($r)=>json_decode($r->result,true)['id']);foreach($p->items() as $row){$row->command_reference=$commandReferences->get($row->id)?->command_key;$row->data=json_decode($row->data,true);$row->dependencies=json_decode($row->dependencies,true);$row->decision=$decisions->get($row->id);}$caps=[];foreach(['prepare','review','close'] as $r)$caps[$r]=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'cash.'.$r,$s['unit_id']);
$period=null;if($basis!==null){$from=$d['from']??'0001-01-01';$to=$d['to']??'9999-12-31';$period=(array)$this->events($s,$b)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->selectRaw("coalesce(sum(CASE WHEN data->>'date' < ? THEN CASE kind WHEN 'receipt' THEN (data->>'amount_minor')::bigint ELSE -(data->>'amount_minor')::bigint END ELSE 0 END),0) opening_minor,coalesce(sum((data->>'amount_minor')::bigint) FILTER(WHERE kind='receipt' AND data->>'date'>=? AND data->>'date'<=?),0) receipts_minor,coalesce(sum((data->>'amount_minor')::bigint) FILTER(WHERE kind='expense' AND data->>'date'>=? AND data->>'date'<=?),0) expenses_minor",[$from,$from,$to,$from,$to])->first();foreach($period as $k=>$v)$period[$k]=(int)$v;$period['closing_minor']=$period['opening_minor']+$period['receipts_minor']-$period['expenses_minor'];}
return ['basis'=>$basis,'blocked'=>$blocked,'events'=>$p,'period'=>$period,'binding'=>array_intersect_key($b,array_flip(['session_key','receipt_id','invoice_id','amount_minor','expense_minor'])),'capabilities'=>$caps,'actor_id'=>$actor,'simulation_only'=>true];
});
}
public function commandReceipt(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);$row=DB::table('cash_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($row,404);$result=json_decode($row->result,true,32,JSON_THROW_ON_ERROR);abort_unless($this->events($s,$this->binding())->where('id',$result['id'])->exists(),403);return ['result'=>$result,'created_at'=>$row->created_at,'command_key'=>$key,'historical_receipt_only'=>true,'operation_executed'=>false];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Finance;
use App\Modules\Accounting\SourceBook;use App\Modules\Services\DocumentPeriod;
/** Bounded QA projection, with original Cash authority and native composition checks. */
class FinancialIndicatorSource
{
public function snapshot(int $actor,array $scope):array
{
$cash=app(CashWorkspace::class)->listing($actor,['from'=>'2026-11-10','to'=>'2026-11-10','per_page'=>25]);abort_unless(!$cash['blocked']&&$cash['basis']&&$cash['basis']['closed']&&$cash['period'],409,'Caixa não conciliada/fechada ou origem alterada.');$period=DocumentPeriod::open($scope);$balances=[];
foreach(['QA-CAIXA','QA-FORNECEDOR']as$code){$a=SourceBook::table('gl_accounts')->where($scope)->where('code',$code)->first();abort_unless($a&&$a->currency==='MZN',409,'Conta/moeda da fonte QA não conferida.');$q=SourceBook::table('gl_lines')->join('gl_journals as j',function($join){$join->on('j.id','=','gl_lines.journal_id');foreach(['tenant_id','company_id','unit_id']as$k)$join->on('j.'.$k,'=','gl_lines.'.$k);})->where(array_combine(array_map(fn($k)=>'gl_lines.'.$k,array_keys($scope)),array_values($scope)))->where('gl_lines.account_id',$a->id)->where('j.journal_on','<=','2026-11-10');$r=$q->selectRaw('coalesce(sum(gl_lines.debit_minor-gl_lines.credit_minor),0)::text net_debit_minor,count(*)::int line_count,max(gl_lines.id) last_line_id')->first();$balances[$code]=['account_id'=>$a->id,'code'=>$a->code,'currency'=>$a->currency]+(array)$r;}
abort_unless($balances['QA-CAIXA']['net_debit_minor']===(string)$cash['period']['closing_minor'],409,'Caixa e diário não conciliam.');$v=['available'=>true,'kind'=>'composed_qa_sources','scope'=>$scope,'currency'=>'MZN','unit'=>'money','as_of'=>'2026-11-10','cash_balance_minor'=>(string)$cash['period']['closing_minor'],'cash_expenses_minor'=>(string)$cash['period']['expenses_minor'],'cash_receipts_minor'=>(string)$cash['period']['receipts_minor'],'supplier_credit_minor'=>(string)\Brick\Math\BigInteger::of($balances['QA-FORNECEDOR']['net_debit_minor'])->negated(),'cash_revision'=>$cash['basis']['revision'],'cash_source_hash'=>$cash['basis']['source'],'cash_session_key'=>$cash['binding']['session_key'],'ledger_accounts'=>$balances,'period'=>$period];$v['hash']=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($v));return $v;
}
}
@@ -0,0 +1,71 @@
<?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)];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Finance\Http;
use App\Modules\Finance\BankReconciliation;use Illuminate\Http\Request;
final class BankReconciliationController
{
private function response(\Closure $fn){try{$out=$fn();return $out instanceof \Symfony\Component\HttpFoundation\Response?$out:response()->json($out)->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){$state=$e->errorInfo[0]??null;if($state==='23505')return response()->json(['message'=>'Linha/referência já utilizada; confira a versão actual.'],409);if(!in_array($state,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Conferência ocupada. Recupere o mesmo pedido; versão alterada exige nova conferência.'],503)->header('Retry-After','5');}}
public function index(Request $r,BankReconciliation $b){return $this->response(fn()=>$b->listing($r->user()->id,$r->query()));}
public function preview(Request $r,BankReconciliation $b){return $this->response(fn()=>$b->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,BankReconciliation $b){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$b->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,BankReconciliation $b,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$b->receipt($r->user()->id,strtolower($key)));}
public function screen(Request $r,BankReconciliation $b){abort_if($r->query()!==[],422);$b->scope($r->user()->id);return response()->view('finance.bank',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
public function report(Request $r,BankReconciliation $b){abort_if($r->query()!==[],422);return $this->response(function()use($r,$b){$data=$b->report($r->user()->id);return response($data['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="conferencia-extracto-QA.csv"')->header('X-Content-SHA256',$data['sha256'])->header('Cache-Control','no-store, private');});}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Modules\Finance\Http;
use Illuminate\Http\Request;use App\Modules\Finance\CashWorkspace;
final class CashController
{
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'=>'Caixa ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}}
public function index(Request $r,CashWorkspace $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
public function receipt(Request $r,CashWorkspace $s,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$s->commandReceipt($r->user()->id,strtolower($key)));}
public function screen(Request $r,CashWorkspace $s){abort_if($r->query()!==[],422);$s->scope($r->user()->id);return response()->view('finance.cash',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
public function preview(Request $r,CashWorkspace $s){return $this->response(fn()=>$s->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,CashWorkspace $s){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$s->confirm($r->user()->id,$d['preview']));}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Modules\Finance\Http;
use App\Modules\Finance\FinancialIndicators;use Illuminate\Http\Request;
final class FinancialIndicatorsController
{
private function response(\Closure $fn){try{$out=$fn();return $out instanceof \Symfony\Component\HttpFoundation\Response?$out:response()->json($out)->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'=>'Critérios/indicadores ocupados. Recupere o mesmo pedido; versão alterada exige nova conferência.'],503)->header('Retry-After','5');}}
public function screen(Request $r,FinancialIndicators $a){abort_if($r->query()!==[],422);$a->scope($r->user()->id);return response()->view('finance.indicators',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
public function index(Request $r,FinancialIndicators $a){return $this->response(fn()=>$a->listing($r->user()->id,$r->query()));}
public function detail(Request $r,FinancialIndicators $a,int $id){$d=$r->validate(['type'=>'sometimes|in:criterion,result']);abort_if(array_diff(array_keys($r->query()),['type']),422);return $this->response(fn()=>$a->detail($r->user()->id,$id,$d['type']??'criterion'));}
public function preview(Request $r,FinancialIndicators $a){return $this->response(fn()=>$a->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,FinancialIndicators $a){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$a->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,FinancialIndicators $a,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$a->receipt($r->user()->id,strtolower($key)));}
public function report(Request $r,FinancialIndicators $a,int $id){abort_if($r->query()!==[],422);return $this->response(function()use($r,$a,$id){$data=$a->report($r->user()->id,$id);return response($data['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="indicador-QA-'.$id.'.csv"')->header('X-Content-SHA256',$data['sha256'])->header('Cache-Control','no-store, private');});}
}