Record explicit workshop acceptance and preserve subsequent synthetic work without accepting receivables. Keep 080000/070000 unapplied, real workshop grants pending and operational issuance blocks intact. Exclude local credentials and real-data preservation reports.
71 lines
7.4 KiB
PHP
71 lines
7.4 KiB
PHP
<?php
|
|
use App\Modules\Services\ServiceInvoicing;
|
|
use App\Modules\Services\ServiceReceivables;
|
|
use App\Shared\Context\CompanyContext;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Process\Process;
|
|
require __DIR__.'/../vendor/autoload.php';
|
|
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
ServiceReceivables::guard();
|
|
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
|
|
$fx=$f+$f['receivables']+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])];
|
|
$s=app(ServiceReceivables::class); $manager=$f['manager_id']; $reader=$f['reader_id']; $proof=[];
|
|
$select=fn($actor)=>app(CompanyContext::class)->selectFor($actor,$f['tenant'],$f['company'],$f['unit']);
|
|
$rev=fn($ob)=>DB::table('sr_effects')->where('obligation_id',$ob)->count();
|
|
$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit']];
|
|
$pair=function($a,$b)use($base) {
|
|
$a=new Process([PHP_BINARY,'scripts/receivables-worker.php','--env=testing'],base_path(),null,json_encode($a+$base+['pause'=>true]));
|
|
$b=new Process([PHP_BINARY,'scripts/receivables-worker.php','--env=testing'],base_path(),null,json_encode($b+$base));
|
|
$a->start(); if(!$a->waitUntil(fn($type,$buffer)=>str_contains($buffer,'LOCK_HELD'))) throw new RuntimeException('Overlap lock not established.');
|
|
$b->start(); $a->wait(); $b->wait(); $out=[];
|
|
foreach([$a,$b] as $p) { if(!$p->isSuccessful()) throw new RuntimeException('Synthetic worker failed.'); $lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($v)=>str_starts_with($v,'{'))); $out[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR); }
|
|
return $out;
|
|
};
|
|
$obligation=function()use(&$fx,$s,$select,$manager,$reader) {
|
|
$fx=\Tests\Support\InvoiceFixture::additionalDraft($fx,'862.07'); $select($manager); $si=app(ServiceInvoicing::class);
|
|
$review=$si->review($fx['draft'],$fx['series'],$fx['rule'],$manager);
|
|
$invoice=$si->issue($fx['draft'],$manager,['series_id'=>$fx['series'],'rule_id'=>$fx['rule'],'review_hash'=>$review['review_hash']],(string)Str::uuid())['id'];
|
|
$ob=$s->propose($manager,['kind'=>'obligation','invoice_id'=>$invoice,'currency'=>'MZN','source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'revision'=>0],(string)Str::uuid())['id'];
|
|
$select($reader); $s->decide($ob,$reader,['version'=>1,'revision'=>0,'decision'=>'approved','reason'=>'Open synthetic concurrency obligation.'],(string)Str::uuid());
|
|
return [$invoice,$ob];
|
|
};
|
|
$receipt=function($invoice,$ob,$amount,$confirm=true)use($s,$select,$manager,$reader,$rev) {
|
|
$select($manager); $id=$s->propose($manager,['kind'=>'receipt','invoice_id'=>$invoice,'obligation_id'=>$ob,'amount'=>$amount,'currency'=>'MZN','date'=>now(ServiceInvoicing::ZONE)->format('Y-m-d'),'source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'evidence'=>'Artificial concurrency evidence.','revision'=>$rev($ob)],(string)Str::uuid())['id'];
|
|
if($confirm) { $select($reader); $s->decide($id,$reader,['version'=>1,'revision'=>$rev($ob),'decision'=>'approved','reason'=>'Synthetic concurrency confirmation.'],(string)Str::uuid()); }
|
|
return $id;
|
|
};
|
|
$decision=fn($id,$ob)=>['method'=>'decide','id'=>$id,'actor'=>$reader,'key'=>(string)Str::uuid(),'input'=>['version'=>1,'revision'=>$rev($ob),'decision'=>'approved','reason'=>'Concurrent synthetic decision.']];
|
|
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'400.00',false); $a=$decision($r,$ob); $out=$pair($a,$a);
|
|
if($out[0]['status']!==200 || $out[1]['status']!==200 || !$out[1]['result']['replayed'] || $out[0]['result']['id']!==$out[1]['result']['id']) throw new RuntimeException('Confirmation replay failed.');
|
|
$proof['same_confirmation_one_effect_one_replay']=true;
|
|
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'400.00'); $t=$receipt($invoice,$ob,'700.00');
|
|
$allocate=fn($id,$amount,$ob)=>['method'=>'allocate','id'=>$id,'actor'=>$manager,'key'=>(string)Str::uuid(),'input'=>['version'=>1,'revision'=>$rev($ob),'amount'=>$amount]];
|
|
$out=$pair($allocate($r,'400.00',$ob),$allocate($t,'700.00',$ob));
|
|
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Document overspend race failed.');
|
|
$proof['document_400_plus_700_one_applied_one_conflict']=true;
|
|
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'700.00');
|
|
$out=$pair($allocate($r,'400.00',$ob),$allocate($r,'400.00',$ob));
|
|
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Receipt overspend race failed.');
|
|
$proof['same_receipt_available_not_double_spent']=true;
|
|
// Raw SQL writers share the same company lock and aggregate/version invariants.
|
|
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'700.00'); $t=$receipt($invoice,$ob,'400.00');
|
|
$sql=fn($id,$amount)=>['method'=>'sql_allocate','actor'=>$manager,'sql'=>['record_id'=>$id,'obligation_id'=>$ob,'receipt_id'=>$id,'kind'=>'allocate','amount'=>$amount,'revision'=>$rev($ob),'data'=>'{}','actor_id'=>$manager,'created_at'=>now()->toIso8601String()]];
|
|
$out=$pair($sql($r,'700.00'),$sql($t,'400.00'));
|
|
if(array_column($out,'status')!==[200,409] || !($out[1]['database_guard']??false)) throw new RuntimeException('SQL aggregate race failed.');
|
|
$proof['raw_sql_concurrent_aggregate_guard']=true;
|
|
[$invoice,$ob]=$obligation(); $r=$receipt($invoice,$ob,'400.00'); $select($manager); $af=$s->allocate($r,$manager,['version'=>1,'revision'=>$rev($ob),'amount'=>'400.00'],(string)Str::uuid())['id'];
|
|
$obs=$s->propose($manager,['kind'=>'observation','invoice_id'=>$invoice,'obligation_id'=>$ob,'receipt_id'=>$r,'amount'=>'400.00','currency'=>'MZN','date'=>now(ServiceInvoicing::ZONE)->format('Y-m-d'),'source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'evidence'=>'Artificial observation.','revision'=>$rev($ob)],(string)Str::uuid())['id'];
|
|
$corr=$s->propose($manager,['kind'=>'correction','action'=>'reverse_allocation','target_effect_id'=>$af,'invoice_id'=>$invoice,'obligation_id'=>$ob,'currency'=>'MZN','source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'reason'=>'Explicit reversal.','revision'=>$rev($ob)],(string)Str::uuid())['id'];
|
|
$out=$pair($decision($corr,$ob),$decision($obs,$ob));
|
|
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Reversal versus reconciliation race failed.');
|
|
$proof['reversal_reconciliation_stale_decision_refused']=true;
|
|
// Two incompatible reductions against exactly the same reviewed revision.
|
|
[$invoice,$ob]=$obligation(); $select($manager); $corrs=[];
|
|
foreach(['A','B'] as $ref) $corrs[]=$s->propose($manager,['kind'=>'correction','action'=>'reduction','amount'=>'100.00','document_ref'=>'TEST-REDUCTION-CONCURRENT-'.Str::uuid(),'line'=>'1','invoice_id'=>$invoice,'obligation_id'=>$ob,'currency'=>'MZN','source'=>'TEST-CONCURRENCY','external_id'=>(string)Str::uuid(),'reason'=>'Synthetic reduction proposal '.$ref,'revision'=>$rev($ob)],(string)Str::uuid())['id'];
|
|
$out=$pair($decision($corrs[0],$ob),$decision($corrs[1],$ob));
|
|
if(array_column($out,'status')!==[200,409]) throw new RuntimeException('Concurrent corrections failed.');
|
|
$proof['concurrent_corrections_exact_revision']=true;
|
|
file_put_contents(base_path('docs/proofs/receivables-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','overlapping_processes_per_case'=>2,'checks'=>$proof,'real_operations'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
|
echo "Six real overlapping-process scenarios passed, including raw SQL aggregate protection; sgf_tempo_test only.\n";
|