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