Files
SGF/app/Modules/Services/ServiceReceivables.php
T
2026-11-11 15:04:01 +02:00

278 lines
25 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/** Isolated operational subledger. Never invokes fiscal, banking or accounting consumers. */
final class ServiceReceivables
{
public const POLICY='SGF-RECEIVABLES-SIM-v1';
public const NOTICE='TESTE — SEM VALIDADE FISCAL';
public const SIMULATION='SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO';
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
public static function guard(): void
{
abort_unless(ServiceCalculation::isolated(),403,'Recebimentos e reconciliação disponíveis exclusivamente em testes isolados.');
TestingDatabaseGuard::assertSafe();
}
private function scope(int $actor,string $right): array
{
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
abort_unless($actor===$this->context->userId() && $s['unit_id']!==null,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'],'service_receivables.'.$r,$s['unit_id']);
return $s;
}
public static function money(mixed $value,bool $positive=true): string
{
$v=DraftValues::decimal($value,'amount');
abort_unless($v!==null,422,'Montante explícito necessário.');
$n=BigDecimal::of($v);
abort_if(($positive && $n->isLessThanOrEqualTo(0)) || $n->getScale()>2,422,'Use um montante positivo exactamente representável a duas casas; sem truncagem.');
return (string)$n->toScale(2,RoundingMode::UNNECESSARY);
}
private function validate(array $d,array $rules): array
{
abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo protegido ou não suportado.');
return Validator::make($d,$rules)->validate();
}
private function json(mixed $d): string { return json_encode($d,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE); }
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
private function row(int $id,array $s,?string $kind=null): object
{
$q=DB::table('sr_records')->where($s)->where('id',$id);
if($kind) $q->where('kind',$kind);
return $q->first() ?? abort(403,'Identidade fora do âmbito ou tipo incorrecto.');
}
private function revision(int $ob): int { return DB::table('sr_effects')->where('obligation_id',$ob)->count(); }
private function command(int $actor,string $right,string $key,array $input,callable $write): array
{
self::guard(); $s=$this->scope($actor,$right);
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência obrigatória.');
ksort($input); $hash=hash('sha256',$this->json($input));
return DB::transaction(function() use($actor,$right,$key,$input,$hash,$s,$write) {
// Same global order as the database guards: company, then immutable records/effects.
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$this->scope($actor,$right);
$old=DB::table('sr_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$right,'command_key'=>$key])->first();
if($old) {
abort_unless(hash_equals($old->request_hash,$hash),409,'Chave já usada com conteúdo diferente.');
return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true];
}
$result=$write($s);
$cmd=DB::table('sr_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>$right,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($result),'created_at'=>now()]);
Audit::record('service_receivables.'.$right,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['command_id'=>$cmd,'record_id'=>$result['id'],'simulation_only'=>true]);
$event=DB::table('sr_events')->insertGetId(['command_id'=>$cmd,'type'=>$result['event'],'schema_version'=>1,'payload'=>$this->json($s+['policy'=>self::POLICY,'command_id'=>$cmd,'actor_id'=>$actor,'result'=>$result]),'created_at'=>now()]);
DB::table('sr_outbox')->insert(['event_id'=>$event,'state'=>'captured_simulation_only']);
return $result+['replayed'=>false];
},3);
}
private function record(array $s,int $actor,string $kind,object $inv,int $client,?int $ob,?int $receipt,string $amount,string $source,string $external,array $data): array
{
abort_if(DB::table('sr_records')->where($s)->where(['kind'=>$kind,'source'=>$source,'external_id'=>$external])->exists(),409,'Acontecimento já registado; consulte a identidade original.');
if($kind==='obligation') abort_if(DB::table('sr_records')->where('invoice_id',$inv->id)->where('kind','obligation')->exists(),409,'Documento já tem obrigação.');
$origin=['invoice_id'=>$inv->id,'snapshot_sha256'=>hash('sha256',$inv->snapshot),'series'=>$inv->series_code,'number'=>$inv->number,'issued_at'=>$inv->issued_at];
$id=DB::table('sr_records')->insertGetId($s+['invoice_id'=>$inv->id,'client_id'=>$client,'obligation_id'=>$ob,'receipt_id'=>$receipt,'kind'=>$kind,'currency'=>'MZN','amount'=>$amount,'source'=>$source,'external_id'=>$external,'data'=>$this->json(array_replace($data,['policy'=>self::POLICY,'origin'=>$origin])),'version'=>1,'actor_id'=>$actor,'created_at'=>now()]);
return ['id'=>$id,'version'=>1,'event'=>$kind.'.proposed'];
}
public function propose(int $actor,array $input,string $key): array
{
self::guard();
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,reverse_reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
$kind=$d['kind'];
$right=$kind==='correction'?'correction_request':'prepare';
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
return $this->command($actor,$right,$key,$d,function($s)use($actor,$d,$kind) {
$inv=QualifiedInvoiceSource::query()->where($s)->where('id',$d['invoice_id'])->first();
abort_unless($inv,403,'Documento de origem não autorizado.');
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['simulation_only']??false)===true && ($snapshot['notice']??'')===self::NOTICE && ($snapshot['document']['data']['currency']??'')==='MZN',422,'Origem sintética compatível necessária.');
$history=DB::table('a_history')->where($s)->where(['area'=>'billing_drafts','record_id'=>$inv->draft_id,'version'=>$snapshot['document']['draft_version']])->value('snapshot');
abort_unless($history,409,'Versão histórica da origem necessária.');
$client=(int)json_decode($history,true,32,JSON_THROW_ON_ERROR)['client_id'];
$ob=null; $receipt=null; $amount='0.00';
if($kind==='obligation') {
abort_if(!empty($d['obligation_id']) || !empty($d['receipt_id']) || $d['revision']!==0,422);
$amount=self::money($snapshot['calculation']['total_document_proposed']);
abort_unless(!isset($d['amount']) || $d['amount']===$amount,422,'Montante deve coincidir com a fotografia.');
} else {
$ob=$this->row((int)($d['obligation_id']??0),$s,'obligation');
abort_unless($ob->invoice_id===$inv->id,403);
abort_unless($d['revision']===$this->revision($ob->id),409,'Revisão desactualizada.');
abort_unless(DB::table('sr_effects')->where(['record_id'=>$ob->id,'kind'=>'open'])->exists(),409,'Obrigação ainda não aberta.');
if(!empty($d['receipt_id'])) { $receipt=$this->row($d['receipt_id'],$s,'receipt'); abort_unless($receipt->obligation_id===$ob->id,403); }
}
if(in_array($kind,['receipt','observation'])) {
abort_unless(isset($d['amount'],$d['date'],$d['evidence']),422,'Montante, data e evidência sintética são obrigatórios.');
abort_if($d['date']>now(ServiceInvoicing::ZONE)->format('Y-m-d'),422,'Data futura não comprova recebimento ou observação.');
$amount=$d['amount'];
abort_if(($kind==='receipt' && $receipt) || ($kind==='observation' && !$receipt),422);
}
if($kind==='correction') {
abort_unless(isset($d['action'],$d['reason']),422,'Acção e motivo necessários.');
if($d['action']==='reduction') {
abort_unless(isset($d['amount'],$d['document_ref'],$d['line']) && str_starts_with($d['document_ref'],'TEST-REDUCTION-'),422,'Documento redutor sintético e linha necessários.');
$amount=$d['amount'];
}
if(in_array($d['action'],['reverse_allocation','reverse_reconcile','reverse_reduction'])) {
$effect=DB::table('sr_effects')->where('obligation_id',$ob->id)->where('id',$d['target_effect_id']??0)->first();
abort_unless($effect && $effect->kind===(match($d['action']){'reverse_allocation'=>'allocate','reverse_reduction'=>'reduction',default=>'reconcile'}),422,'Alvo de reversão incompatível.');
if($d['action']==='reverse_reduction'){abort_unless(config('service_documents.enabled')===true&&DB::table('sd_outputs')->where($s)->where(['kind'=>'credit_note','effect_id'=>$effect->id])->exists(),403);DocumentPeriod::open($s);}else $receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
}
if($d['action']==='reverse_receipt') { abort_unless($receipt,422); $amount=$receipt->amount; }
}
return $this->record($s,$actor,$kind,$inv,$client,$ob?->id,$receipt?->id,$amount,$d['source'],$d['external_id'],$d);
});
}
public function decide(int $id,int $actor,array $input,string $key): array
{
self::guard(); $s=$this->scope($actor,'view'); $r=$this->row($id,$s);
$right=$r->kind==='correction'?'correction_approve':($r->kind==='observation'?'reconcile':'confirm');
$d=$this->validate($input,['version'=>'required|integer|in:1','revision'=>'required|integer|min:0','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
return $this->command($actor,$right,$key,['record_id'=>$id]+$d,function($s)use($id,$actor,$d) {
$r=$this->row($id,$s); $rd=$this->data($r); $ob=$r->kind==='obligation'?$r->id:$r->obligation_id;
abort_if($r->actor_id===$actor,403,'Confirmação/aprovação exige identidade distinta.');
abort_unless($d['revision']===$this->revision($ob),409,'Estado alterado; reveja a decisão.');
abort_if(DB::table('sr_effects')->where('record_id',$id)->where('kind','<>','allocate')->exists(),409,'Versão já tem decisão.');
$kind='refused'; $amount='0.00'; $receipt=null; $target=null;
if($d['decision']==='approved') {
if($r->kind==='obligation') { $kind='open'; $amount=$r->amount; }
if($r->kind==='receipt') { $kind='receipt_confirm'; $amount=$r->amount; }
if($r->kind==='observation') {
$receipt=$r->receipt_id; $rc=$this->row($receipt,$s,'receipt');
$diff=BigDecimal::of($rc->amount)->minus($r->amount)->abs();
$kind=$diff->isZero()?'reconcile':'divergence'; $amount=$diff->isZero()?$rc->amount:(string)$diff->toScale(2);
}
if($r->kind==='correction') {
abort_unless(($rd['revision']??-1)===$d['revision'],409,'Proposta ligada a revisão anterior; prepare nova proposta.');
$kind=$rd['action']; $amount=$r->amount; $receipt=$r->receipt_id; $target=$rd['target_effect_id']??null;
if($kind==='reduction' || $kind==='due' || $kind==='reverse_reduction') $receipt=null;if($kind==='reverse_reduction'){DocumentPeriod::open($s);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);}
}
} elseif($r->kind==='observation') abort(422,'A conferência regista correspondência ou divergência; não é aprovação de recibo.');
return $this->effect($id,$ob,$receipt,$kind,$amount,$target,$d['revision'],$actor,$d+['due_date'=>$rd['due_date']??null]);
});
}
private function effect(int $record,int $ob,?int $receipt,string $kind,string $amount,?int $target,int $revision,int $actor,array $data): array
{
$state=$this->state($ob);
if($kind==='allocate') {
$r=$state['receipts'][$receipt]??abort(409,'Recebimento indisponível.');
abort_unless($r['state']==='confirmed' && BigDecimal::of($amount)->isLessThanOrEqualTo($r['available']) && BigDecimal::of($amount)->isLessThanOrEqualTo($state['balance']),409,'Sobreafectação recusada integralmente.');
}
if($kind==='reduction') abort_if(BigDecimal::of($amount)->isGreaterThan($state['balance']),409,'Liberte afectação antes da redução.');
if($kind==='reverse_receipt') {
$r=$state['receipts'][$receipt]??abort(409);
abort_unless($r['state']==='confirmed' && $r['allocated']==='0.00' && !$r['reconciled'],409,'Reverta afectações e reconciliação primeiro.');
}
if(in_array($kind,['reconcile','divergence'])) {
$r=$state['receipts'][$receipt]??abort(409);
abort_unless($r['state']==='confirmed' && !$r['reconciled'],409,'Recebimento indisponível ou já conferido.');
}
if($target) abort_if(DB::table('sr_effects')->where('target_effect_id',$target)->exists(),409,'Efeito já revertido.');
$id=DB::table('sr_effects')->insertGetId(['record_id'=>$record,'obligation_id'=>$ob,'receipt_id'=>$receipt,'kind'=>$kind,'amount'=>$amount,'target_effect_id'=>$target,'revision'=>$revision,'data'=>$this->json($data),'actor_id'=>$actor,'created_at'=>now()]);
return ['id'=>$id,'record_id'=>$record,'obligation_id'=>$ob,'event'=>$kind.'.simulation','revision'=>$revision+1];
}
public function allocate(int $id,int $actor,array $input,string $key): array
{
self::guard(); $d=$this->validate($input,['amount'=>'required|string','revision'=>'required|integer|min:0','version'=>'required|integer|in:1']);
$d['amount']=self::money($d['amount']);
return $this->command($actor,'allocate',$key,['receipt_id'=>$id]+$d,function($s)use($id,$actor,$d) {
$r=$this->row($id,$s,'receipt'); abort_unless($d['revision']===$this->revision($r->obligation_id),409,'Revisão desactualizada.');
return $this->effect($id,$r->obligation_id,$id,'allocate',$d['amount'],null,$d['revision'],$actor,[]);
});
}
/**
* One sealed QA invoice-receipt, using the original record/effect writers.
* prepared_by is the actual prior declarant; actor_id on effects is the real executor.
* No guard/context impersonation and no general delegation of prepare/allocate rights.
*/
public function completeDocument(int $versionId,int $invoiceId,int $actor,string $key):array
{
self::guard();abort_unless(config('service_documents.enabled')===true&&config('service_documents.invoice_receipt_enabled')===true&&DB::transactionLevel()>0,403);
$s=$this->scope($actor,'confirm');foreach(['view','issue'] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$r,$s['unit_id']);
$v=DB::table('sd_versions')->where($s)->where(['id'=>$versionId,'kind'=>'invoice_receipt','state'=>'approved'])->first();abort_unless($v&&$v->prepared_by!==$actor,403,'Intenção financeira distinta e revista necessária.');
$latest=DB::table('sd_versions')->where($s)->where('document_key',$v->document_key)->max('version');abort_unless($latest===$v->version,409);
$intent=json_decode($v->data,true,32,JSON_THROW_ON_ERROR);$preparer=(int)$v->prepared_by;
abort_unless(DB::table('users')->where('id',$preparer)->lockForUpdate()->exists()&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$preparer])->lockForUpdate()->exists(),403);
DB::table('permission_grants')->where($s)->where('user_id',$preparer)->orderBy('id')->lockForUpdate()->get();
foreach(['service_documents.prepare','service_receivables.view','service_receivables.prepare','service_receivables.allocate'] as $right)$this->permissions->require($preparer,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']);
$inv=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where(['id'=>$invoiceId,'draft_id'=>$intent['draft_id'],'series_id'=>$intent['series_id'],'rule_id'=>$intent['rule_id'],'actor_id'=>$actor])->first();abort_unless($inv,403,'A emissão deve pertencer à origem selada e ao executor actual.');
$snap=json_decode($inv->snapshot,true,64,JSON_THROW_ON_ERROR);abort_unless(($snap['simulation_only']??null)===true&&($snap['notice']??null)===self::NOTICE&&($snap['calculation']['total_document_proposed']??null)==='1000.00'&&($snap['calculation']['currency']??null)==='MZN',422);
abort_if(DB::table('sr_records')->where('invoice_id',$invoiceId)->exists()||DB::table('sd_outputs')->where('invoice_id',$invoiceId)->exists(),409,'Origem já utilizada.');
$history=DB::table('a_history')->where($s)->where(['area'=>'billing_drafts','record_id'=>$inv->draft_id,'version'=>$snap['document']['draft_version']])->value('snapshot');abort_unless($history,409);$client=(int)json_decode($history,true)['client_id'];
$source='SIM-FACTURA-RECIBO';$external='SD-'.$v->document_key;$reason='Conclusão atómica da intenção financeira QA revista: '.$v->document_key;
$ob=$this->record($s,$preparer,'obligation',$inv,$client,null,null,'1000.00',$source,$external.'-OB',['due_date'=>null,'document_intent_id'=>$v->id]);$obId=$ob['id'];
$this->effect($obId,$obId,null,'open','1000.00',null,0,$actor,['reason'=>$reason,'document_intent_id'=>$v->id]);
$rc=$this->record($s,$preparer,'receipt',$inv,$client,$obId,null,'1000.00',$source,$external.'-RC',['date'=>$intent['payment_date'],'evidence'=>$intent['evidence'],'document_intent_id'=>$v->id]);
$this->effect($rc['id'],$obId,null,'receipt_confirm','1000.00',null,1,$actor,['reason'=>$reason,'document_intent_id'=>$v->id]);
$allocation=$this->effect($rc['id'],$obId,$rc['id'],'allocate','1000.00',null,2,$actor,['reason'=>$reason,'document_intent_id'=>$v->id,'aggregate_authority'=>'service_documents.issue']);
$result=['id'=>$obId,'obligation_id'=>$obId,'receipt_id'=>$rc['id'],'allocation_id'=>$allocation['id'],'event'=>'invoice_receipt.simulation','balance'=>'0.00'];
$command=DB::table('sr_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>'document_issue','command_key'=>$key,'request_hash'=>hash('sha256',$this->json([$v->id,$invoiceId,$intent])),'result'=>$this->json($result),'created_at'=>now()]);
$event=DB::table('sr_events')->insertGetId(['command_id'=>$command,'type'=>'invoice_receipt.simulation','schema_version'=>1,'payload'=>$this->json($s+['result'=>$result,'prepared_by'=>$preparer,'executed_by'=>$actor,'document_intent_id'=>$v->id]),'created_at'=>now()]);DB::table('sr_outbox')->insert(['event_id'=>$event,'state'=>'captured_simulation_only']);
Audit::record('service_receivables.document_issue',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['command_id'=>$command,'prepared_by'=>$preparer,'document_intent_id'=>$v->id,'simulation_only'=>true]);return $result;
}
private function state(int $ob,?string $cut=null): array
{
$r=DB::table('sr_records')->find($ob); $rd=$this->data($r);
$effects=DB::table('sr_effects')->where('obligation_id',$ob)->orderBy('id')->get();
$receipts=[]; $balance=BigDecimal::of($r->amount); $reduced=BigDecimal::zero(); $allocated=BigDecimal::zero(); $due=$rd['due_date']??null; $opened=false; $refused=false;
foreach(DB::table('sr_records')->where('obligation_id',$ob)->where('kind','receipt')->get() as $rc) $receipts[$rc->id]=['id'=>$rc->id,'amount'=>$rc->amount,'state'=>'proposed','allocated'=>'0.00','available'=>'0.00','reconciled'=>false];
foreach($effects as $e) {
if($e->kind==='open') $opened=true;
if($e->kind==='refused' && $e->record_id===$ob) $refused=true;
if($e->kind==='due') $due=$this->data($e)['due_date'];
if($e->kind==='receipt_confirm') { $receipts[$e->record_id]['state']='confirmed'; $receipts[$e->record_id]['available']=$e->amount; }
if($e->kind==='refused' && isset($receipts[$e->record_id])) $receipts[$e->record_id]['state']='refused';
if(in_array($e->kind,['allocate','reverse_allocation'])) {
$x=BigDecimal::of($e->amount)->multipliedBy($e->kind==='allocate'?1:-1); $allocated=$allocated->plus($x);
$rc=&$receipts[$e->receipt_id]; $rc['allocated']=(string)BigDecimal::of($rc['allocated'])->plus($x)->toScale(2); $rc['available']=(string)BigDecimal::of($rc['available'])->minus($x)->toScale(2); unset($rc);
}
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);if($e->kind==='reverse_reduction') $reduced=$reduced->minus($e->amount);
if($e->kind==='reconcile') $receipts[$e->receipt_id]['reconciled']=true;
if($e->kind==='reverse_reconcile') $receipts[$e->receipt_id]['reconciled']=false;
if($e->kind==='reverse_receipt') { $receipts[$e->receipt_id]['state']='reversed'; $receipts[$e->receipt_id]['available']='0.00'; }
}
$balance=$balance->minus($reduced)->minus($allocated); $received=BigDecimal::zero(); $available=BigDecimal::zero();
foreach($receipts as $rc) if($rc['state']==='confirmed') { $received=$received->plus($rc['amount']); $available=$available->plus($rc['available']); }
$cut??=now(ServiceInvoicing::ZONE)->format('Y-m-d');
return ['id'=>$ob,'original'=>$r->amount,'reduced'=>(string)$reduced->toScale(2),'received'=>(string)$received->toScale(2),'allocated'=>(string)$allocated->toScale(2),'available'=>(string)$available->toScale(2),'balance'=>(string)$balance->toScale(2),'opened'=>$opened,'state'=>!$opened?($refused?'refused':'proposed'):($balance->isZero()?'settled':($allocated->isPositive()?'partial':'open')),'due_date'=>$due,'due_state'=>!$opened?'not_open':($balance->isZero()?'settled':($due===null?'unknown':($cut>$due?'overdue':'not_due'))),'cut_date'=>$cut,'revision'=>$effects->count(),'receipts'=>$receipts,'effects'=>$effects->all()];
}
public function listing(int $actor): array
{
$s=$this->scope($actor,'view');
if(!ServiceCalculation::isolated()) return ['blocked'=>true,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'obligations'=>[],'records'=>[],'documents'=>[]];
self::guard();
return DB::transaction(function()use($s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$records=DB::table('sr_records')->where($s)->orderBy('id')->get();
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>QualifiedInvoiceSource::query()->where($s)->get(['id','series_code','number'])->all()];
});
}
/** Scoped single-position read reuses the authoritative state calculation without writer locks. */
public function position(int $obligation,int $actor):array
{
self::guard();$scope=$this->scope($actor,'view');$this->row($obligation,$scope,'obligation');return $this->state($obligation);
}
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
public function deliver(int $event): bool
{
self::guard();
return DB::transaction(function()use($event) {
$e=DB::table('sr_events')->where('id',$event)->lockForUpdate()->firstOrFail();
if(DB::table('sr_deliveries')->where('event_id',$event)->exists()) return false;
$cmd=DB::table('sr_commands')->find($e->command_id); $result=json_decode($cmd->result,true,32,JSON_THROW_ON_ERROR);
$ob=$result['obligation_id']??null;
if($ob) { $state=$this->state($ob); abort_if(BigDecimal::of($state['balance'])->isNegative(),409,'Integridade do saldo falhou.'); }
DB::table('sr_deliveries')->insert(['event_id'=>$event,'consumer'=>'simulation_integrity_v1','created_at'=>now()]);
return true;
});
}
}