Files
SGF/app/Modules/Accounting/InternalStatementsController.php
2026-11-11 09:00:17 +02:00

89 lines
20 KiB
PHP

<?php
namespace App\Modules\Accounting;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
final class InternalStatementsController
{
public const POLICY='SGF-INTERNAL-STATEMENTS-QA-v1';
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
private function exact(array $v,array $rules):array{abort_if(array_diff(array_keys($v),array_keys($rules)),422);return Validator::make($v,$rules)->validate();}
private function run(Request $r,string $action,\Closure $fn):mixed
{
abort_unless(config('internal_statements.enabled')===true,404);$host=DB::connection();$hostId=(int)$r->user()->id;$identity=$host->table('users')->where('id',$hostId)->first(['id','email','name']);$identityHash=$this->hash((array)$identity);$generation=$this->hash($r->session()->get('company_context',[]));
try{return app(ComposedLedger::class)->run('view',function($engine,$actor,$binding,$origin,$scope)use($action,$fn,$host,$hostId,$identityHash,$generation){$meta=DB::table('fs_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&DB::connection()->getDatabaseName()==='compras_receipts_test_'.$meta->composition&&hash_equals($meta->source_hash,(string)config('internal_statements.schema_sha256')),503,'Schema de demonstrações não conferido.');if($action!=='view')abort_unless($hostId===($action==='prepare'?15:16)&&$host->table('permission_grants')->where($origin['sgf_host_scope']+['user_id'=>$hostId,'permission'=>'general_ledger.statements.'.$action])->lockForUpdate()->first(),403);$person=DB::table('pa_access')->where($scope+['user_id'=>$actor->id,'action'=>'general_ledger.view'])->lockForUpdate()->value('person');abort_unless(is_string($person)&&$person!=='',403);$freshIdentity=$host->table('users')->where('id',$hostId)->lockForUpdate()->first(['id','email','name']);abort_unless($freshIdentity,403);$identityHash=$this->hash((array)$freshIdentity);return$fn($engine,$actor,$scope,$hostId,$person,$identityHash,$generation,$this->hash([$origin['binding_revision'],$origin['sgf_host_scope']]),$host,$origin);});}catch(\InvalidArgumentException$e){abort(422,$e->getMessage());}catch(\DomainException$e){abort(403,$e->getMessage());}
}
private function rows(array $s){return DB::table('fs_versions')->where($s);}
private function row(?object $r):?array{if(!$r)return null;$v=(array)$r;$v['data']=json_decode($v['data'],true,64,JSON_THROW_ON_ERROR);return$v;}
private function latest(array $s,string $kind,bool $approved=false):?array{$q=$this->rows($s)->where('kind',$kind);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
private function plan(array $s):array{$v=DB::table('gl_accounts')->where($s)->selectRaw('count(*) accounts,coalesce(max(id),0) last_id')->first();return(array)$v;}
private function mapping(array $s,array $entries):array
{
$ids=array_column($entries,'account_id');$accounts=DB::table('gl_accounts')->where($s)->whereIn('id',$ids)->get(['id','code','label','currency'])->keyBy('id');abort_unless(count($ids)===$accounts->count(),403,'Conta fora do livro autorizado.');$out=[];foreach($entries as$e){$a=(array)$accounts[$e['account_id']];$out[]=['account_id'=>$e['account_id'],'category'=>$e['category'],'account'=>$a,'account_hash'=>$this->hash($a)];}usort($out,fn($a,$b)=>$a['account_id']<=>$b['account_id']);return$out;
}
private function validateMapping(array $s,array $mapping):void
{
$fresh=$this->mapping($s,array_map(fn($e)=>array_intersect_key($e,array_flip(['account_id','category'])),$mapping));abort_unless($this->hash($fresh)===$this->hash($mapping),409,'Conta ou correspondência alterada; exige revisão.');
}
private function input(array $v):array
{
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['kind'=>'required|in:mapping,result','action'=>'required|in:prepare,review','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];
if($d['action']==='review')$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);elseif($d['kind']==='mapping'){$d['data']=$this->exact($d['data'],['entries'=>'required|array|min:1|max:100','evidence'=>'required|string|min:10|max:2000']);$seen=[];foreach($d['data']['entries']as&$e){$e=$this->exact($e,['account_id'=>'required|integer|min:1','category'=>'required|in:asset,liability,income,expense,unclassified']);$e['account_id']=(int)$e['account_id'];abort_if(isset($seen[$e['account_id']]),422,'Conta repetida.');$seen[$e['account_id']]=true;}unset($e);}else$d['data']=$this->exact($d['data'],['mapping_id'=>'required|integer|min:1','from_on'=>'required|date_format:Y-m-d','to_on'=>'required|date_format:Y-m-d|after_or_equal:from_on','per_page'=>'required|integer|in:25,100','evidence'=>'required|string|min:10|max:2000']);return$d;
}
private function basis(array $s,string $kind,string $identity):array{return ['last'=>$this->latest($s,$kind),'mapping'=>$this->latest($s,'mapping',true),'mapping_latest'=>$this->latest($s,'mapping'),'plan'=>$this->plan($s),'identity'=>$identity];}
private function capture($engine,array $s,array $mapping,array $d,?string $persistKey=null):array
{
$this->validateMapping($s,$mapping['data']['entries']);$map=collect($mapping['data']['entries'])->keyBy('account_id');$totals=new InternalStatementTotals();$digest=hash_init('sha256');$first=null;$query=array_intersect_key($d,array_flip(['from_on','to_on','per_page']));$plan=$this->plan($s);$count=0;
for($page=1;;$page++){$report=$engine->trialBalance($query+['page'=>$page]);if(!$first)$first=$report;else abort_unless($this->hash($first['basis'])===$this->hash($report['basis'])&&$this->hash($first['totals'])===$this->hash($report['totals']),409,'Livro mudou entre páginas.');foreach($report['rows']as$row){$entry=$map->get($row['account_id']);$category=$entry['category']??'unclassified';$totals->add($row,$category);$line=$row+['category'=>$category];foreach(['opening_net_minor','debit_minor','credit_minor','closing_net_minor']as$key)$line[$key]=(string)$line[$key];hash_update($digest,Payables::canonical($line)."\n");if($persistKey)DB::table('fs_result_lines')->insert($s+['snapshot_key'=>$persistKey]+$line);$count++;}if($page>=$report['pagination']['last_page'])break;}
$current=DB::table('gl_journals')->where($s)->where('journal_on','<=',$d['to_on'])->selectRaw('count(*) journals,coalesce(max(id),0) last_journal_id')->first();abort_unless((int)$current->journals===$first['basis']['journals']&&(int)$current->last_journal_id===$first['basis']['last_journal_id']&&$this->hash($plan)===$this->hash($this->plan($s))&&$count===$first['totals']['accounts'],409,'Fonte mudou durante a preparação.');
$summary=['query'=>['from_on'=>$d['from_on'],'to_on'=>$d['to_on']],'mapping_id'=>$mapping['id'],'mapping_version'=>$mapping['version'],'basis'=>$first['basis'],'original_totals'=>array_map('strval',$first['totals']),'currencies'=>$totals->result(),'accounts'=>$count,'plan'=>$plan,'rows_sha256'=>hash_final($digest),'synthetic_only'=>true,'legal_model'=>false];return['summary'=>$summary,'source_basis'=>$this->hash($summary)];
}
private function csv(array $s,string $key,array $data):array
{
$stream=fopen('php://temp/maxmemory:1048576','w+');$h=hash_init('sha256');$write=function(array $row)use($stream,$h){$f=fopen('php://memory','w+');fputcsv($f,$row,';','"','',"\r\n");rewind($f);$bytes=stream_get_contents($f);fclose($f);fwrite($stream,$bytes);hash_update($h,$bytes);};
$write(['DEMONSTRACOES INTERNAS PARCIAIS QA — SEM MODELO LEGAL']);$write(['Snapshot',$key,'Versao de resultado',$data['result_version'],'Observacao',$data['frozen_at']]);$write(['Datas',$data['summary']['query']['from_on'],$data['summary']['query']['to_on'],'Correspondencias',$data['summary']['mapping_version']]);$write(['Capital proprio','INDETERMINADO','Nao presumir zero ou completar equacao patrimonial']);$write(['Rubricas/pendencias',Payables::canonical($data['summary']['currencies'])]);$write(['Fundamento',"'".$data['evidence']]);$write(['Conta','Descricao','Moeda','Rubrica','Abertura liquida minor','Debito minor','Credito minor','Fecho liquido minor']);
DB::table('fs_result_lines')->where($s+['snapshot_key'=>$key])->orderBy('code')->orderBy('account_id')->chunk(100,function($rows)use($write){foreach($rows as$r)$write(["'".$r->code,"'".$r->label,$r->currency,$r->category,$r->opening_net_minor,$r->debit_minor,$r->credit_minor,$r->closing_net_minor]);});rewind($stream);return['stream'=>$stream,'sha256'=>hash_final($h)];
}
private function execute($engine,$actor,array $s,int $hostId,string $person,string $identity,array $d,array $b,string $key,string $stamp,$host,array $origin):array
{
$last=$b['last'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada; confira novamente.');$version=$d['version']+1;$prepared=$hostId;$native=$actor->id;$preparedPerson=$person;$preparedIdentity=$identity;
if($d['action']==='prepare'){
if($d['kind']==='mapping'){$entries=$this->mapping($s,$d['data']['entries']);$data=['entries'=>$entries,'evidence'=>$d['data']['evidence']];$source=$this->hash(['entries'=>$entries,'plan'=>$b['plan']]);}
else{$m=$b['mapping'];abort_unless($m&&$m['id']===(int)$d['data']['mapping_id'],409,'Escolha explicitamente as correspondências revistas actuais.');$captured=$this->capture($engine,$s,$m,$d['data'],$key);$data=['snapshot_key'=>$key,'result_version'=>$version,'frozen_at'=>$stamp,'evidence'=>$d['data']['evidence'],'summary'=>$captured['summary']];$csv=$this->csv($s,$key,$data);$data['csv_sha256']=$csv['sha256'];fclose($csv['stream']);$source=$captured['source_basis'];DB::table('fs_results')->insert($s+['snapshot_key'=>$key,'data'=>Payables::canonical($data),'csv_sha256'=>$data['csv_sha256'],'source_basis'=>$source,'created_at'=>$stamp]);}
$state='prepared';
}else{
abort_unless($last&&$last['state']==='prepared'&&$last['prepared_host_id']!==$hostId&&$last['prepared_native_id']!==$actor->id&&$last['prepared_person']!==$person,403,'Preparação actual e pessoa distinta obrigatórias.');$prepared=$last['prepared_host_id'];$native=$last['prepared_native_id'];$preparedPerson=$last['prepared_person'];$preparedIdentity=$last['preparer_identity'];$u=$host->table('users')->where('id',$prepared)->lockForUpdate()->first(['id','email','name']);abort_unless($u&&hash_equals($preparedIdentity,$this->hash((array)$u))&&$host->table('company_memberships')->where(['tenant_id'=>$origin['sgf_host_scope']['tenant_id'],'company_id'=>$origin['sgf_host_scope']['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),409,'Identidade/vínculo do preparador mudou.');abort_unless($host->table('permission_grants')->where($origin['sgf_host_scope']+['user_id'=>$prepared,'permission'=>'general_ledger.statements.prepare'])->lockForUpdate()->first(),403);$freshPerson=DB::table('pa_access')->where($s+['user_id'=>$native,'action'=>'general_ledger.view'])->lockForUpdate()->value('person');abort_unless($freshPerson===$preparedPerson,409,'Pessoa do preparador mudou.');$data=$last['data'];
if($d['kind']==='mapping'){$this->validateMapping($s,$data['entries']);$source=$this->hash(['entries'=>$data['entries'],'plan'=>$b['plan']]);}else{$m=$b['mapping'];abort_unless($m&&$m['id']===$data['summary']['mapping_id'],409,'Correspondências mudaram desde a preparação.');$source=$this->capture($engine,$s,$m,$data['summary']['query']+['per_page'=>100])['source_basis'];$csv=$this->csv($s,$data['snapshot_key'],$data);abort_unless(hash_equals($data['csv_sha256'],$csv['sha256']),409,'Snapshot congelado incompatível.');fclose($csv['stream']);}abort_unless(hash_equals($last['source_basis'],$source),409,'Contas, livro ou dependências mudaram desde a preparação.');$state=$d['data']['decision'];
}
$impact=['before'=>['version'=>$d['version'],'state'=>$last['state']??null],'after'=>['version'=>$version,'state'=>$state],'kind'=>$d['kind'],'data'=>$data,'new_journals'=>0,'accounts_changed'=>false,'legal_model'=>false];$id=DB::table('fs_versions')->insertGetId($s+['kind'=>$d['kind'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_host_id'=>$prepared,'prepared_native_id'=>$native,'prepared_person'=>$preparedPerson,'preparer_identity'=>$preparedIdentity,'host_actor_id'=>$hostId,'actor_id'=>$actor->id,'data'=>Payables::canonical($data),'source_basis'=>$source,'reason'=>$d['reason'],'created_at'=>now()->utc()]);return['id'=>$id,'kind'=>$d['kind'],'version'=>$version,'state'=>$state,'impact'=>$impact];
}
public function preview(Request $r)
{
$d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$d['action'],function($e,$a,$s,$h,$p,$identity,$g,$binding,$host,$origin)use($d,$key){$b=$this->basis($s,$d['kind'],$identity);$stamp=now()->utc()->toIso8601String();$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($e,$a,$s,$h,$p,$identity,$d,$b,$key,$stamp,$host,$origin);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['input'=>$d,'actor'=>$h,'person'=>$p,'generation'=>$g,'binding'=>$binding,'key'=>$key,'stamp'=>$stamp,'observed'=>time(),'basis'=>$this->hash($b),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(Payables::canonical($cut)),'impact'=>$out['impact']])->header('Cache-Control','no-store');});
}
public function confirm(Request $r)
{
$v=$this->exact($r->all(),['preview'=>'required|string|max:131072']);try{$c=json_decode(Crypt::decryptString($v['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);return$this->run($r,$d['action'],function($e,$a,$s,$h,$p,$identity,$g,$binding,$host,$origin)use($c,$d){abort_unless($c['actor']===$h&&$c['person']===$p&&$c['generation']===$g&&$c['binding']===$binding&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$command=$s+['host_actor_id'=>$h,'command_key'=>$c['key']];$old=DB::table('fs_commands')->where($command)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d))&&hash_equals($old->binding_hash,$binding),409,'Chave usada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true])->header('Cache-Control','no-store');}$b=$this->basis($s,$d['kind'],$identity);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Versão, identidade ou correspondências mudaram.');$out=$this->execute($e,$a,$s,$h,$p,$identity,$d,$b,$c['key'],$c['stamp'],$host,$origin);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');app(RequisitionWorkflow::class)->audit($a,'sgf.statements.command',null,['version'=>$d['version']],['host_actor_id'=>$h,'operation_key'=>$c['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('fs_commands')->insert($command+['actor_id'=>$a->id,'request_hash'=>$this->hash($d),'binding_hash'=>$binding,'input'=>Payables::canonical($d),'result'=>Payables::canonical($out),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');});
}
public function receipt(Request $r,string $key)
{
abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,'view',function($e,$a,$s,$h,$p,$identity,$g,$binding)use($key){$old=DB::table('fs_commands')->where($s+['host_actor_id'=>$h,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);return response()->json(['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');});
}
public function index(Request $r)
{
$d=$this->exact($r->query(),['view'=>'sometimes|in:history,accounts,result','kind'=>'sometimes|in:mapping,result','version_id'=>'required_if:view,result|prohibited_unless:view,result|integer|min:1','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);return$this->run($r,'view',function($e,$a,$s,$h,$p,$identity,$g,$binding,$host,$origin)use($d){$view=$d['view']??'history';$size=(int)($d['per_page']??25);$page=(int)($d['page']??1);$lastMapping=$this->latest($s,'mapping');$mapping=$this->latest($s,'mapping',true);$lastResult=$this->latest($s,'result');$approvedResult=$this->latest($s,'result',true);$snapshot=null;$matches=null;
if($view==='accounts'){$q=DB::table('gl_accounts')->where($s);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($q)=>$q->whereRaw("code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("label ILIKE ? ESCAPE '!'",[$needle]));}$rows=$q->orderBy('code')->orderBy('id')->paginate($size,['id','code','label','currency'],'page',$page);}
elseif($view==='result'){$v=$this->row($this->rows($s)->where(['id'=>$d['version_id'],'kind'=>'result','state'=>'approved'])->first());abort_unless($v,403,'Resultado revisto fora do contexto.');$snapshot=$v['data'];$q=DB::table('fs_result_lines')->where($s+['snapshot_key'=>$snapshot['snapshot_key']]);$rows=$q->orderBy('code')->orderBy('account_id')->paginate($size,['*'],'page',$page);$current=$mapping&&$mapping['id']===$snapshot['summary']['mapping_id'];if($current){try{$now=$this->capture($e,$s,$mapping,$snapshot['summary']['query']+['per_page'=>100]);$current=hash_equals($v['source_basis'],$now['source_basis']);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$ex){if($ex->getStatusCode()!==409)throw$ex;$current=false;}}$matches=$current;}
else{$q=$this->rows($s);if(isset($d['kind']))$q->where('kind',$d['kind']);$rows=$q->orderByDesc('id')->paginate($size,['*'],'page',$page);$rows->setCollection($rows->getCollection()->map(fn($v)=>$this->row($v)));}
$can=fn($action,$expected)=>$h===$expected&&$host->table('permission_grants')->where($origin['sgf_host_scope']+['user_id'=>$h,'permission'=>'general_ledger.statements.'.$action])->exists();return response()->json(['policy'=>self::POLICY,'view'=>$view,'records'=>$rows,'mapping_latest'=>$lastMapping,'mapping_approved'=>$mapping,'result_latest'=>$lastResult,'result_approved'=>$approvedResult,'snapshot'=>$snapshot,'current_source_matches'=>$matches,'capabilities'=>['prepare'=>$can('prepare',15),'review'=>$can('review',16)],'notice'=>'Demonstrações internas parciais QA; classificação pendente visível, capital não presumido. Não é modelo legal.'])->header('Cache-Control','no-store');});
}
public function export(Request $r,int $id)
{
abort_if($r->query()!==[],422);return$this->run($r,'view',function($e,$a,$s)use($id){$v=$this->row($this->rows($s)->where(['id'=>$id,'kind'=>'result','state'=>'approved'])->first());abort_unless($v,403,'Resultado não revisto ou fora do âmbito.');$data=$v['data'];$saved=DB::table('fs_results')->where($s+['snapshot_key'=>$data['snapshot_key']])->first();abort_unless($saved&&hash_equals($saved->csv_sha256,$data['csv_sha256']),409);$csv=$this->csv($s,$data['snapshot_key'],$data);if(!hash_equals($data['csv_sha256'],$csv['sha256'])){fclose($csv['stream']);abort(409,'Artefacto alterado.');}$stream=$csv['stream'];return response()->stream(function()use($stream){try{fpassthru($stream);}finally{fclose($stream);}},200,['Content-Type'=>'text/csv; charset=UTF-8','Content-Disposition'=>'attachment; filename="sgf-demonstracoes-internas-QA.csv"','X-Content-SHA256'=>$csv['sha256'],'Cache-Control'=>'no-store, private']);});
}
}