90 lines
18 KiB
PHP
90 lines
18 KiB
PHP
<?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];
|
|
}
|
|
}
|