Files
SGF/scripts/sgf-service-documents-proof.php
T
2026-11-11 09:00:17 +02:00

40 lines
11 KiB
PHP

<?php
use Illuminate\Support\Facades\Auth;use Illuminate\Support\Str;use App\Shared\Context\CompanyContext;use App\Modules\Services\{ServiceDocuments,ServiceInvoicing,ServiceReceivables};
require __DIR__.'/sgf-ledger-proof-bootstrap.php';$inputs=json_decode(file_get_contents($s['root'].'/single-document-inputs.json'),true);config(['service_documents.enabled'=>true,'service_documents.invoice_receipt_enabled'=>true,'service_documents.inputs_sha256'=>hash_file('sha256',$s['root'].'/single-document-inputs.json')]);
set_exception_handler(function($e){echo json_encode(['passed'=>false,'exception'=>get_class($e),'message'=>$e->getMessage(),'errors'=>$e instanceof Illuminate\Validation\ValidationException?$e->errors():null,'frames'=>array_map(fn($x)=>[basename($x['file']??''),$x['line']??0,$x['function']??''],array_slice($e->getTrace(),0,9))]).PHP_EOL;exit(1);});
$select=function($role)use($profiles,$app){Auth::forgetGuards();Auth::shouldUse('web');$u=App\Models\User::findOrFail($profiles[$role]['user_id']);Auth::guard('web')->setUser($u);$r=Illuminate\Http\Request::create('/api/service-documents','POST');$r->attributes->set('sgf_module','commercial');$r->setUserResolver(fn()=>$u);$session=app('session')->driver();$session->start();$session->put('company_context',['tenant_id'=>1,'company_id'=>2,'unit_id'=>2,'module_id'=>'commercial','token'=>'documents-'.$role]);$r->setLaravelSession($session);$app->instance('request',$r);app(CompanyContext::class)->selectFor($u->id,1,2,2);return $u->id;};
$command=function($role,$kind,$action,$key,$version,$data=[])use($select){$actor=$select($role);$w=app(ServiceDocuments::class);$p=$w->preview($actor,['kind'=>$kind,'action'=>$action,'document_key'=>$key,'version'=>$version,'data'=>$data,'reason'=>'Decisão documental sintética individual com origem e evidência.'],(string)Str::uuid());return [$w->confirm($actor,$p['preview']),$p];};
$reject=function(Closure $fn,int $status,string $label)use($ok){$bad=false;try{$fn();}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$bad=$e->getStatusCode()===$status;}catch(Illuminate\Validation\ValidationException){$bad=$status===422;}$ok($bad,$label);};
$host->beginTransaction();$before=$host->table('si_invoices')->orderBy('id')->get()->toJson();$oldGrants=$host->table('permission_grants')->orderBy('id')->get()->toJson();
$native->beginTransaction();config(['sales_ledger.synthetic_enabled'=>true]);
try{
$actor=$select('commercial_prepare');$input=$inputs['credit_invoice'];$w=app(ServiceInvoicing::class);$r=$w->review($input['draft_id'],$input['series_id'],$input['rule_id'],$actor);$invoice=$w->issue($input['draft_id'],$actor,['series_id'=>$input['series_id'],'rule_id'=>$input['rule_id'],'review_hash'=>$r['review_hash']],(string)Str::uuid())['id'];
$sr=app(ServiceReceivables::class);$actor=$select('finance_prepare');$ob=$sr->propose($actor,['kind'=>'obligation','invoice_id'=>$invoice,'currency'=>'MZN','source'=>'SIM-DOC-PROOF','external_id'=>'OB-'.$invoice,'revision'=>0],(string)Str::uuid())['id'];$actor=$select('finance_review');$sr->decide($ob,$actor,['version'=>1,'revision'=>0,'decision'=>'approved','reason'=>'Abertura da nova factura QA por conferente distinto.'],(string)Str::uuid());
$actor=$select('finance_prepare');$receipt=$sr->propose($actor,['kind'=>'receipt','invoice_id'=>$invoice,'obligation_id'=>$ob,'currency'=>'MZN','source'=>'SIM-DOC-PROOF','external_id'=>'RC-'.$invoice,'amount'=>'400.00','revision'=>1,'date'=>'2026-11-10','evidence'=>'Recibo de teste com pagamento fictício400MZN.'],(string)Str::uuid())['id'];$actor=$select('finance_review');$sr->decide($receipt,$actor,['version'=>1,'revision'=>1,'decision'=>'approved','reason'=>'Confirmação distinta do recebimento QA400.'],(string)Str::uuid());$actor=$select('finance_prepare');$allocation=$sr->allocate($receipt,$actor,['amount'=>'400.00','version'=>1,'revision'=>2],(string)Str::uuid())['id'];
$key=(string)Str::uuid();[$prepared]=$command('commercial_prepare','receipt','prepare',$key,0,['invoice_id'=>$invoice,'allocation_id'=>$allocation]);$ok($prepared['state']==='draft','receipt preparation binds an actual allocation');
$reject(fn()=>$command('commercial_prepare','receipt','review',$key,1,['decision'=>'approved']),403,'receipt preparer cannot self-review');
$command('commercial_review','receipt','review',$key,1,['decision'=>'approved']);[$issued,$p]=$command('commercial_prepare','receipt','issue',$key,2);$actor=$select('commercial_prepare');$again=app(ServiceDocuments::class)->confirm($actor,$p['preview']);$ok($again['replayed']&&$again['output_id']===$issued['output_id'],'receipt uncertain replay has one PDF and output');$pdf=app(ServiceDocuments::class)->pdf($actor,$issued['output_id']);$ok(hash('sha256',base64_decode($pdf->pdf_base64,true))===$pdf->pdf_sha256,'frozen receipt PDF integrity');
$reject(fn()=>$command('read_only','receipt','prepare',(string)Str::uuid(),0,['invoice_id'=>$invoice,'allocation_id'=>$allocation]),403,'read-only profile cannot prepare a document');
$reject(fn()=>$command('commercial_prepare','receipt','prepare',(string)Str::uuid(),0,['invoice_id'=>$invoice,'allocation_id'=>$allocation]),409,'same allocation cannot acquire another receipt');
$reject(fn()=>$command('commercial_prepare','receipt','prepare',(string)Str::uuid(),0,['invoice_id'=>999999,'allocation_id'=>$allocation]),403,'foreign invoice source refused');
$key=(string)Str::uuid();$command('finance_prepare','credit_note','prepare',$key,0,['invoice_id'=>$invoice,'amount'=>'100.00','explanation'=>'Redução nominal QA100 da nova factura, sem devolução de dinheiro.']);$command('finance_review','credit_note','review',$key,1,['decision'=>'approved']);
$actor=$select('finance_review');$w=app(ServiceDocuments::class);$issue=['kind'=>'credit_note','action'=>'issue','document_key'=>$key,'version'=>2,'data'=>[],'reason'=>'Emissão sintética individual da redução nominal100MZN.'];$pending=$w->preview($actor,$issue,(string)Str::uuid());$stale=$w->preview($actor,$issue,(string)Str::uuid());$n=$host->table('sr_effects')->count();$outputs=$host->table('sd_outputs')->count();
$host->beginTransaction();try{$host->table('permission_grants')->where(['user_id'=>$actor,'permission'=>'service_documents.credit_apply'])->delete();$reject(fn()=>$w->confirm($actor,$pending['preview']),403,'revoked credit authority refuses prepared issue');}finally{$host->rollBack();}
$host->unprepared("CREATE FUNCTION sd_test_fail() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''SD_TEST_FAILURE''; END'");$host->unprepared('REVOKE ALL ON FUNCTION sd_test_fail() FROM PUBLIC');$host->unprepared('CREATE TRIGGER sd_test_fail BEFORE INSERT ON sd_commands FOR EACH ROW EXECUTE FUNCTION sd_test_fail()');$failed=false;try{$w->confirm($actor,$pending['preview']);}catch(Illuminate\Database\QueryException){$failed=true;}$ok($failed&&$host->table('sr_effects')->count()===$n&&$host->table('sd_outputs')->count()===$outputs,'failure after financial writer rolls back credit, PDF and command together');$host->unprepared('DROP TRIGGER sd_test_fail ON sd_commands');$host->unprepared('DROP FUNCTION sd_test_fail()');
$credit=$w->confirm($actor,$pending['preview']);$again=$w->confirm($actor,$pending['preview']);$ok($again['replayed']&&$again['effect_id']===$credit['effect_id'],'credit recovery creates exactly one financial effect');$reject(fn()=>$w->confirm($actor,$stale['preview']),409,'competing credit conference refuses old document version');$position=$sr->position($ob,$actor);$ok($position['balance']==='500.00'&&$position['reduced']==='100.00','native reduction yields1000 minus400 minus100 equals500');
$key=(string)Str::uuid();$data=$inputs['invoice_receipt'];$data=array_intersect_key($data,array_flip(['draft_id','series_id','rule_id']))+['payment_date'=>'2026-11-10','evidence'=>'Intenção financeira QA1000 preparada por pessoa distinta.'];$invoiceCount=$host->table('si_invoices')->count();$effectCount=$host->table('sr_effects')->count();
$command('finance_prepare','invoice_receipt','prepare',$key,0,$data);$command('finance_review','invoice_receipt','review',$key,1,['decision'=>'approved']);[$fr,$cut]=$command('finance_review','invoice_receipt','issue',$key,2);$actor=$select('finance_review');$again=app(ServiceDocuments::class)->confirm($actor,$cut['preview']);$ok($again['replayed']&&$again['output_id']===$fr['output_id'],'invoice-receipt recovery returns one aggregate result');
$output=$host->table('sd_outputs')->where('id',$fr['output_id'])->first();$newOb=$host->table('sr_records')->where(['invoice_id'=>$output->invoice_id,'kind'=>'obligation'])->value('id');$position=$sr->position($newOb,$actor);$ok($position['balance']==='0.00'&&$position['allocated']==='1000.00','invoice-receipt atomically issues one invoice and allocates1000');
$ok($host->table('si_invoices')->count()===$invoiceCount+1&&$host->table('sr_effects')->count()===$effectCount+3,'one invoice, one opening, one receipt confirmation and one allocation');
$ok($host->table('sr_records')->where('invoice_id',$output->invoice_id)->pluck('actor_id')->unique()->all()===[$profiles['finance_prepare']['user_id']]&&$host->table('sr_effects')->where('obligation_id',$newOb)->pluck('actor_id')->unique()->all()===[$profiles['finance_review']['user_id']],'original declarant and actual executor retained without changing identity');
$posted=[];foreach([['sales.invoice',$invoice],['receivable.allocated',$allocation],['receivable.reduced',$credit['effect_id']],['sales.invoice',$output->invoice_id],['receivable.allocated',$fr['effect_id']]] as [$kind,$source]){
$p=$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>$kind,'source_id'=>$source,'journal_on'=>'2026-11-10','period_key'=>'QA-2026-INTEGRADO','rule_key'=>$kind,'reason'=>'Origem documental QA individual para o mesmo diário integrado.'],(string)Str::uuid()));
$call('accounting_review','validate',fn($e)=>$e->command('validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Revisão distinta da factura, afectação ou crédito com PDF e origem.'],(string)Str::uuid()));
$j=$call('accounting_prepare','post',fn($e)=>$e->command('post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Aplicação única da origem documental QA conferida.'],(string)Str::uuid()));$posted[]=(int)$native->table('gl_journals')->where('id',$j['journal_id'])->value('debit_minor');
}
$ok($posted===[100000,40000,10000,100000,100000],'same ledger records both invoices,400 receipt,100 nominal credit and1000 receipt');
$native->statement('SET CONSTRAINTS ALL IMMEDIATE');$ok(true,'native balanced journal constraints validated');
$host->statement('SET CONSTRAINTS ALL IMMEDIATE');$ok(true,'original invoice and financial SQL invariants remain valid');
}finally{while($native->transactionLevel()>0)$native->rollBack();while($host->transactionLevel()>0)$host->rollBack();}
$ok($before===$host->table('si_invoices')->orderBy('id')->get()->toJson()&&$oldGrants===$host->table('permission_grants')->orderBy('id')->get()->toJson(),'proof rolls back new documents and leaves original invoices and rights unchanged');file_put_contents($work.'/service-documents-result.json',json_encode(['passed'=>true,'checks'=>$checks,'rolled_back'=>true,'visual_review'=>false],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/service-documents-result.json']).PHP_EOL;