commit de compras
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sgfProject = 'C:\laragon\www\sgf-tempo'
|
||||
Push-Location -LiteralPath $sgfProject
|
||||
try {
|
||||
& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' 'scripts/purchasing-single-installation.mjs' 'start'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Não foi possível abrir a instalação de Compras. Nenhuma base nova foi criada.' }
|
||||
} finally { Pop-Location }
|
||||
@@ -0,0 +1,3 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),hash=b=>crypto.createHash('sha256').update(b).digest('hex'),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),file='docs/GENERAL_LEDGER_DIRECT_COST_CORRECTION_SCOPE.md',expected=read('docs/proofs/common-ledger-direct-cost-20261103.json').next_scope.sha256,archive=path.join(state.root,'private/ledger-cost-correction-approved-scope.md');if(state.port!==55439)throw Error('Owned scope archive required');const current=fs.readFileSync(file,'utf8');
|
||||
if(fs.existsSync(archive)){if(hash(fs.readFileSync(archive))!==expected)throw Error('Approved archive changed');}else{if(hash(current)!==expected)throw Error('Scope differs from presented proposal');fs.writeFileSync(archive,current);}const original=fs.readFileSync(archive,'utf8'),updated=original.replace(/^03\/11\/2026\..+$/m,'03/11/2026. **Autorização expressa recebida: «Autorizar a correcção sintética proposta». Implementação/provas concluídas; ver [entrega e limites](GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md).** O texto seguinte conserva o recorte apresentado. A decisão pedida no final é histórica; não constitui aceitação, reabertura ou autorização operacional.');if(current!==original&¤t!==updated)throw Error('Unexpected scope edits; preserve and inspect');if(current!==updated)fs.writeFileSync(file,updated);console.log(JSON.stringify({scope_archive_matches_presented_proposal:true,approved_scope_sha256:expected,authorization_recorded:true}));
|
||||
@@ -0,0 +1,4 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),hash=b=>crypto.createHash('sha256').update(b).digest('hex'),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),file='docs/GENERAL_LEDGER_DIRECT_COST_SCOPE.md',expected=read('docs/proofs/common-ledger-reports-20261103.json').next_scope.sha256,archive=path.join(state.root,'private/ledger-cost-approved-scope.md');if(state.port!==55439)throw Error('Owned archive root required');const current=fs.readFileSync(file,'utf8');
|
||||
if(fs.existsSync(archive)){if(hash(fs.readFileSync(archive))!==expected)throw Error('Approved archive changed');}else{if(hash(current)!==expected)throw Error('Current scope differs from presented proposal');fs.writeFileSync(archive,current);}
|
||||
const original=fs.readFileSync(archive,'utf8'),authorized='03/11/2026. **Autorização expressa recebida: «Autorizar o ciclo sintético proposto». Implementação/provas concluídas no recorte sintético; ver [entrega e limites](GENERAL_LEDGER_DIRECT_COST_DELIVERY.md).** O texto seguinte conserva o recorte apresentado; a decisão pedida no final é histórica. Não equivale a aceitação ou autorização de correcção de custo já lançado.';const updated=original.replace(/^03\/11\/2026\..+$/m,authorized);if(current!==original&¤t!==updated)throw Error('Unexpected scope edits; preserve and inspect');if(current!==updated)fs.writeFileSync(file,updated);console.log(JSON.stringify({archive_matches_presented_scope:true,approved_scope_sha256:expected,authorization_recorded:true}));
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {fileURLToPath} from 'node:url';
|
||||
export function validateEvidence(spec,read,loadXml){
|
||||
const value=read(spec.file);let checks=0;
|
||||
for(const c of spec.checks??[]){let v=value;for(const key of c.pointer.split('/').slice(1))v=v?.[key];if(v!==c.equals)throw Error('Failed evidence selector '+spec.file+' '+c.pointer);checks++;}
|
||||
if(spec.selected_junit){const seen=new Set();if(!value.selected_cases?.length||value.selected_cases.length!==value.totals.distinct_tests)throw Error('Incomplete selected case coverage');for(const c of value.selected_cases){if(seen.has(c.name))throw Error('Duplicate selected case');seen.add(c.name);const p=value.proofs.find(p=>p.file===c.proof);if(!p)throw Error('Missing JUnit reference');const xml=loadXml(p.file,p.sha256),cases=[...xml.matchAll(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g)].map(m=>m[0]),test=cases.find(t=>t.match(/\bname="([^"]+)"/)?.[1]===c.name);if(!test||/<(?:failure|error|skipped)\b/.test(test))throw Error('Selected case did not pass '+c.name);checks++;}}
|
||||
if(!checks)throw Error('Evidence has no success criterion');return {verified_checks:checks,recorded_proof_verified:true};
|
||||
}
|
||||
export function validateOrigins(plan){const ids=new Set(plan.stages.map(s=>s.id));for(const s of plan.stages)for(const branch of s.origin_paths??[]){if(!branch.id||!Array.isArray(branch.requires)||branch.requires.some(x=>!ids.has(x)||x===s.id))throw Error('Invalid alternative origin path');}return true;}
|
||||
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
|
||||
const root=path.resolve('.'),read=file=>JSON.parse(fs.readFileSync(path.join(root,file),'utf8').replace(/^\uFEFF/,'')),hash=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),plan=read('docs/integration/sgf-flow-plan.json'),state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8')),roots={sgf:root,compras:path.resolve('../compras')};validateOrigins(plan);
|
||||
const stages=plan.stages.map(stage=>{if(!stage.evidence?.length)throw Error('Unmapped evidence '+stage.id);const code=stage.source_ids.map(id=>{const s=plan.sources.find(s=>s.id===id);if(!s)throw Error('Unknown source');const base=fs.realpathSync(roots[s.repository]),file=fs.realpathSync(path.join(base,s.path));if(!file.startsWith(base+path.sep))throw Error('Source escape');return {id,file:s.path,repository:s.repository,sha256:hash(file),source_present_only:true};});const evidence=stage.evidence.map(e=>{if(!/^docs\/proofs\/[a-z0-9-]+\.json$/.test(e.file))throw Error('Unsafe evidence path');return {...e,sha256:hash(path.join(root,e.file)),...validateEvidence(e,read,(name,pin)=>{if(!/^workshop-stock-\d+\.xml$/.test(name))throw Error('Unsafe XML reference');const file=path.join(state.root,name);if(hash(file)!==pin)throw Error('JUnit changed');return fs.readFileSync(file,'utf8');})};});for(const source of code){const pins=[];for(const spec of stage.evidence){const p=read(spec.file);for(const field of ['source_files','sources','source_hashes','source_sha256']){const entries=p[field];if(Array.isArray(entries)){for(const e of entries)if((e.file??e.path)?.replaceAll('\\','/').endsWith(source.file)&&e.sha256)pins.push({proof:spec.file,sha256:e.sha256});}else if(entries&&typeof entries==='object'){for(const [file,value]of Object.entries(entries)){const pin=typeof value==='string'?value:value?.sha256;if(file.replaceAll('\\','/').endsWith(source.file)&&/^[a-f0-9]{64}$/.test(pin??''))pins.push({proof:spec.file,sha256:pin});}}}}source.recorded_source_pins=pins;source.current_hash_has_matching_recorded_evidence=pins.length?pins.some(p=>p.sha256===source.sha256):null;}
|
||||
return {id:stage.id,owner:stage.owner,state:stage.state,code,evidence,remaining_gap:stage.gap,operational_validated:false,current_entire_stage_code_regression_repeated:false};});
|
||||
const output=process.argv[2];if(!output||!/^docs\/proofs\/[a-z0-9-]+\.json$/.test(output)||fs.existsSync(output))throw Error('Fresh proof output required');fs.writeFileSync(output,JSON.stringify({date:new Date().toISOString(),kind:'matrix_code_recorded_evidence_audit',plan_sha256:hash(path.join(root,'docs/integration/sgf-flow-plan.json')),stages,stages_checked:stages.length,visual_review_pending:true,deployment_capacity_validated:false,limitations:['Historical selected proofs verified; source presence/current hashes are not proof that all later edits were regressed.','No runtime modules, real data, rights, cache or databases altered by this audit.'],database_connections:0,financial_effects:0},null,2));console.log(JSON.stringify({output,stages:stages.length,selected_criteria:stages.reduce((n,s)=>n+s.evidence.reduce((m,e)=>m+e.verified_checks,0),0)}));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
// Package transport only: rewrite PHP namespace tokens, never rule expressions or strings.
|
||||
[$script,$source,$target]=$argv;
|
||||
if(realpath($source)!==realpath(dirname(__DIR__).'/../compras')||!str_starts_with(str_replace('\\','/',$target),'C:/laragon/data/sgf-tempo/run/modular-regression-')||file_exists($target))throw new LogicException('Fresh private bundle required');
|
||||
mkdir($target,0777,true);$files=[];$originals=[];
|
||||
foreach(['app'=>$source.'/app','fortify'=>$source.'/vendor/laravel/fortify/src']as$prefix=>$directory){
|
||||
foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory,FilesystemIterator::SKIP_DOTS))as$file){
|
||||
if(!$file->isFile()||$file->getExtension()!=='php')continue;if($file->isLink())throw new LogicException('Linked source refused');$relative=$prefix.'/'.str_replace('\\','/',substr($file->getPathname(),strlen($directory)+1));$raw=file_get_contents($file->getPathname());$text='';
|
||||
foreach(token_get_all($raw)as$token){if(!is_array($token)){$text.=$token;continue;}[$type,$value]=$token;
|
||||
if($prefix==='app'&&in_array($type,[T_NAME_QUALIFIED,T_NAME_FULLY_QUALIFIED],true))$value=preg_replace('/^(\\\\?)App\\\\/','$1Tempo\\Purchasing\\Embedded\\',$value);
|
||||
$text.=$value;
|
||||
}
|
||||
$destination=$target.'/'.$relative;if(!is_dir(dirname($destination)))mkdir(dirname($destination),0777,true);file_put_contents($destination,$text);$files[$relative]=hash('sha256',$text);$originals[$relative]=hash('sha256',$raw);
|
||||
}
|
||||
}
|
||||
ksort($files);ksort($originals);file_put_contents($target.'/manifest.json',json_encode(['format'=>'sgf.receipt.native-namespace.v1','files'=>$files,'originals'=>$originals,'transformation'=>'Only App-qualified PHP name tokens become Tempo\\Purchasing\\Embedded; strings and rule expressions unchanged.'],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($files)." original PHP files packaged with namespace-only adaptation.\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
require dirname(__DIR__).'/app/Shared/Geography/Cartography.php';
|
||||
use App\Shared\Geography\Cartography;
|
||||
$run=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true,32,JSON_THROW_ON_ERROR);$private=$run['root'].'/private';$source=$private.'/cartography-source-20261103.json';$prepared=$private.'/cartography-prepared-20261103.json';$out=dirname(__DIR__).'/docs/proofs/cartography-preparation-20261103.json';if(is_file($prepared)||is_file($out))throw new LogicException('Completed proof preserved');$s=json_decode(file_get_contents($source),true,64,JSON_THROW_ON_ERROR);$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};$c=Cartography::prepare($s);
|
||||
$ok($c['total']===20407,'all20407 source records are preserved');$ok(array_values($c['levels'])===[11,148,437,1334,1870,16607],'source levels reconcile to the full record count');$ok($c['nodes'][0]['codes']['province']==='01','leading zero preserved as text');$ok(count(array_unique(array_column($c['nodes'],'key')))===20407,'source-version plus source-ID uniquely identifies each record');$ok($c['diagnostics']['duplicate_path_groups']===48&&$c['diagnostics']['duplicate_extra_rows']===48,'duplicate code paths are retained and signalled');$ok($c['diagnostics']['conflicting_designation_groups']===39,'conflicting duplicate designations are exposed');$ok($c['diagnostics']['missing_designations']===9,'nine absent designations remain absent');$ok($c['diagnostics']['missing_parent_records']===8610,'missing immediate parent rows are reported without invented labels');$alpha=array_values(array_filter($c['nodes'],fn($n)=>in_array($n['codes']['village_quarter'],['7A','7B'],true)));$ok(count($alpha)===2,'alphanumeric source codes survive unchanged');$province=Cartography::selection($c,$s['sha256'],$c['nodes'][0]['key']);$ok($province['designation']==='Niassa'&&$province['codes']['district']==='','explicit selection permits a province without imposing deeper precision');$refuse=function($fn,$message)use($ok){try{$fn();throw new RuntimeException('Selection accepted unexpectedly');}catch(DomainException $e){$ok($e->getMessage()===$message,$message);}};
|
||||
$refuse(fn()=>Cartography::selection($c,str_repeat('0',64),$c['nodes'][0]['key']),'CARTOGRAPHY_VERSION_CHANGED');$refuse(fn()=>Cartography::selection($c,$s['sha256'],'Niassa'),'CARTOGRAPHY_TERRITORY_UNKNOWN');$ambiguous=array_values(array_filter($c['nodes'],fn($n)=>in_array('duplicate_path',$n['issues'],true)))[0];$refuse(fn()=>Cartography::selection($c,$s['sha256'],$ambiguous['key']),'CARTOGRAPHY_REVIEW_REQUIRED');$ok(Cartography::prepare($s)===$c,'preparation is deterministic without source mutation');$bad=$s;$bad['rows'][0]['cells']['B']='Other';try{Cartography::prepare($bad);throw new RuntimeException('Wrong schema accepted');}catch(InvalidArgumentException $e){$ok($e->getMessage()==='CARTOGRAPHY_SOURCE_REFUSED','unexpected workbook schema is refused');}$bad=$s;$bad['rows'][2]['cells']['A']=$bad['rows'][1]['cells']['A'];try{Cartography::prepare($bad);throw new RuntimeException('Duplicate ID accepted');}catch(InvalidArgumentException $e){$ok($e->getMessage()==='CARTOGRAPHY_SOURCE_ID_REFUSED','duplicate source IDs are refused');}
|
||||
file_put_contents($prepared,json_encode($c,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));file_put_contents($out,json_encode(['passed'=>true,'checks'=>count($checks),'names'=>$checks,'source'=>$c['source'],'total'=>$c['total'],'levels'=>$c['levels'],'diagnostics'=>$c['diagnostics'],'prepared_sha256'=>hash_file('sha256',$prepared),'implementation_sha256'=>hash_file('sha256',dirname(__DIR__).'/app/Shared/Geography/Cartography.php'),'original_workbook_modified'=>false,'database_connections'=>0,'business_records_assigned'=>0,'catalogue_activated'=>false,'authority_independently_verified'=>false,'public_data_payload_committed'=>false],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));echo count($checks)." cartography preparation checks passed.\n";
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
if(!($s['stockComposition']??false))throw new LogicException('Stock composition fixture required');
|
||||
$origin=$s['root'].'/composition-stock-origin.json';
|
||||
if(is_file($origin)){$order=json_decode(file_get_contents($origin),true,32,JSON_THROW_ON_ERROR);}
|
||||
elseif($db->table('purchase_orders')->where('tenant_id',$n['tenant'])->count()===1&&is_file($s['root'].'/composition-stock-progress.json')){
|
||||
// Recover the completed source setup after an assertion failure; do not issue another order.
|
||||
$id=$db->table('purchase_orders')->where('tenant_id',$n['tenant'])->value('id');
|
||||
$order=['id'=>$id,'items'=>[['id'=>$db->table('purchase_order_items')->where('purchase_order_id',$id)->value('id')]]];
|
||||
file_put_contents($origin,json_encode($order,JSON_THROW_ON_ERROR));
|
||||
}else{require __DIR__.'/receipt-workspace-source.php';file_put_contents($origin,json_encode($order,JSON_THROW_ON_ERROR));}
|
||||
$enter('validator',0,['module'=>'stock']);$stock='/api/stock-read';
|
||||
$read=fn($query=[])=>$call('validator',$stock.'?'.http_build_query($query));
|
||||
$r=$read();$ok($r['status']===200&&$r['data']['rows']===[]&&$r['data']['status']==='available','composed stock starts with a genuine empty physical book');$before=$r['data']['snapshot'];
|
||||
$ok(in_array($call('actor',$stock)['status'],[403,409],true),'purchasing authority does not grant stock access');
|
||||
$enter('validator',1);$ok(in_array($call('validator',$stock,null,1)['status'],[403,409],true),'stock endpoint refuses Purchasing module context');
|
||||
$enter('validator',1,['module'=>'stock','foreign'=>1]);$ok($call('validator',$stock,null,1)['status']===403,'another unit cannot inherit the source binding');
|
||||
$ok($read(['warehouse_id'=>$n['foreign_warehouse']])['status']===403,'foreign warehouse explicitly refused');
|
||||
$ok($read(['per_page'=>101])['status']===422&&$read(['per_page'=>1])['status']===422&&$read(['unknown'=>1])['status']===422,'page bounds and unknown filters rejected');
|
||||
$enter('validator',1,['module'=>'stock']);
|
||||
$r=$call('validator',$stock,null,1,['X-SGF-Context'=>'stale']);$ok($r['status']===409,'stale HTTP context refused before source observation');
|
||||
// Catalogue volume is fixture metadata only, not artificial physical movements.
|
||||
$base=(array)$db->table('stock_items')->where('id',$n['stock'])->first();unset($base['id']);
|
||||
$db->transaction(function()use($db,$base){for($i=1;$i<=102;$i++){$row=$base;$row['code']='QA-COMPOSED-'.str_pad((string)$i,3,'0',STR_PAD_LEFT);$row['name']=$i===102?'Literal%_marker':'Composed QA article '.$i;$db->table('stock_items')->insert($row);}});
|
||||
$r=$read(['view'=>'items','per_page'=>100]);$r2=$read(['view'=>'items','per_page'=>100,'page'=>2,'snapshot'=>$r['data']['snapshot']]);
|
||||
$total=$db->table('stock_items')->where('tenant_id',$n['tenant'])->count();
|
||||
$ok($r['status']===200&&$r2['status']===200&&count($r['data']['rows'])===100&&count($r2['data']['rows'])===$total-100,'catalogue uses bounded pages100 and remainder');
|
||||
$ids=[];for($page=1;$page<=ceil($total/25);$page++){$r=$read(['view'=>'items','page'=>$page]);$ids=array_merge($ids,array_column($r['data']['rows'],'id'));}
|
||||
$ok(count($ids)===$total&&count(array_unique($ids))===$total&&!in_array($n['foreign_stock'],$ids,true),'pages25 cover the same tenant catalogue once without foreign articles');
|
||||
$r=$read(['view'=>'items','search'=>'%_']);$ok($r['status']===200&&count($r['data']['rows'])===1&&$r['data']['rows'][0]['name']==='Literal%_marker','search preserves literal wildcard semantics');
|
||||
$r=$read(['view'=>'warehouses']);$ok($r['status']===200&&array_column($r['data']['rows'],'id')===[$n['warehouse']],'only explicitly mapped warehouse is visible');
|
||||
$r=$read();$before=$r['data']['snapshot'];
|
||||
$id=$order['id'];$line=$order['items'][0]['id'];$url='/api/purchasing-receipts/orders/'.$id;
|
||||
$enter('validator',1);$r=$call('validator',$url,null,1);
|
||||
$payload=['revision'=>$r['data']['revision'],'idempotency_key'=>(string)Str::uuid(),'delivery_note'=>'QA-COMPOSED-STOCK','received_on'=>date('Y-m-d'),'warehouse_id'=>$n['warehouse'],
|
||||
'items'=>[['purchase_order_item_id'=>$line,'received_quantity'=>'1.000','accepted_quantity'=>'1.000','quality_status'=>'compliant','stock_item_id'=>$n['stock'],'notes'=>null]]];
|
||||
$p=$call('validator',$url.'/preview',$payload,1);$ok($p['status']===200&&$db->table('stock_movements')->count()===0,'original receipt preview has no physical effect');
|
||||
$r=$call('validator',$url.'/confirm',['preview'=>$p['data']['preview']],1);$ok($r['status']===201&&count($r['data']['movements'])===1,'original receipt confirmation creates exactly one native movement');$movement=$r['data']['movements'][0]['id'];
|
||||
$r=$call('validator',$url.'/confirm',['preview'=>$p['data']['preview']],1);$ok($r['status']===200&&$r['data']['replayed']&&$db->table('stock_movements')->count()===1,'receipt replay remains idempotent across the integration');
|
||||
$ok($read(['snapshot'=>$before])['status']===409,'new native movement invalidates an earlier stock snapshot');
|
||||
$r=$read(['view'=>'movements']);$ok($r['status']===200&&count($r['data']['rows'])===1&&$r['data']['rows'][0]['id']===$movement&&$r['data']['rows'][0]['quantity_milli']==='1000','Stock reads the exact canonical receipt movement and rational quantity');
|
||||
$ok(!array_intersect(['snapshot','cost','unit_price','supplier_id'],array_keys($r['data']['rows'][0])),'stock projection exposes no raw document or financial snapshot');
|
||||
$r=$read();$ok($r['data']['rows'][0]['physical_milli']==='1000'&&$r['data']['rows'][0]['available_milli']===null&&!$r['data']['capabilities']['write'],'Stock derives physical balance without inventing availability or another writer');$token=$r['data']['snapshot'];
|
||||
$enter('validator',0,['module'=>'stock']);$ok($read(['snapshot'=>$token])['status']===409,'renewed session context invalidates prior pagination token');
|
||||
$grant=DB::table('permission_grants')->where($f['scope']+['user_id'=>$f['validator'],'permission'=>'stock.view'])->first();
|
||||
DB::table('permission_grants')->where('id',$grant->id)->delete();try{$ok($read()['status']===403,'revoked stock grant is rechecked on every page');}finally{DB::table('permission_grants')->insert((array)$grant);}
|
||||
$profile=$s['root'].'/stock-binding.json';$original=file_get_contents($profile);$binding=json_decode($original,true);$key=array_key_first($binding['bindings']);
|
||||
$binding['bindings'][$key]['warehouse_ids']=[$n['foreign_warehouse']];file_put_contents($profile,json_encode($binding));
|
||||
try{$r=$read();$ok($r['status']===503,'incompatible tenant warehouse mapping returns unavailable rather than a zero balance');}finally{file_put_contents($profile,$original);}
|
||||
$ok($read()['status']===200,'valid private mapping recovers without changing the book');
|
||||
$binding=json_decode($original,true);$binding['connection']['password']='invalid-qa-only';file_put_contents($profile,json_encode($binding));
|
||||
try{$r=$read();$ok($r['status']===503&&!str_contains($r['body'],'stock_receipt_read_')&&!str_contains($r['body'],'SQLSTATE'),'source authentication failure is generic and does not expose credentials');}finally{file_put_contents($profile,$original);}
|
||||
$ok($read()['status']===200,'source authentication recovers with original profile');
|
||||
config(['database.connections.composition_security'=>json_decode($original,true)['connection']]);$reader=DB::connection('composition_security');
|
||||
foreach(['DELETE FROM stock_movements WHERE false','SELECT * FROM goods_receipts LIMIT 0','SELECT * FROM audit_logs LIMIT 0','SELECT * FROM pa_obligations LIMIT 0']as$sql){$refused=false;try{$reader->statement($sql);}catch(\PDOException $e){$refused=in_array($e->errorInfo[0]??'',['42501','25006'],true);}$ok($refused,'technical reader refuses '.strtok($sql,' ').' outside its capability: '.explode(' ',$sql)[2]);}
|
||||
$refused=false;try{$db->table('stock_movements')->where('id',$movement)->update(['quantity_milli'=>1001]);}catch(\PDOException $e){$refused=($e->errorInfo[0]??'')==='P0001';}
|
||||
$ok($refused&&(int)$db->table('stock_movements')->sum('quantity_milli')===1000,'new QA append-only trigger preserves revision assumptions');
|
||||
// An outstanding native writer mutex must not block the observational connection.
|
||||
$db->beginTransaction();try{$db->table('tenants')->where('id',$n['tenant'])->lockForUpdate()->first();$ok($read()['status']===200,'Stock read does not acquire the native tenant writer mutex');}finally{$db->rollBack();}
|
||||
$ok($db->table('goods_receipts')->count()===1&&$db->table('stock_movements')->count()===1&&$db->table('invoices')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('pa_obligations')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('external_payments')->where('tenant_id',$n['tenant'])->count()===0,'integration leaves one receipt/movement and creates no invoice debt or payment');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'same_native_book'=>true,'receipts'=>1,'movements'=>1,'physical_milli'=>1000,'catalogue_rows'=>$total,'page_sizes'=>[25,100],'new_permission_names'=>[],'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." composed stock checks passed.\n";
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/composition-stock-demo.json';
|
||||
if(!($s['stockComposition']??false)||is_file($out))throw new LogicException('Fresh stock demonstration required');
|
||||
require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.stock_demo'=>$s['nativeConnection']]);$db=DB::connection('stock_demo');
|
||||
$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};
|
||||
$fingerprint=fn()=>hash('sha256',json_encode([$db->table('stock_items')->orderBy('id')->get(),$db->table('warehouses')->orderBy('id')->get(),$db->table('stock_movements')->orderBy('id')->get(),$db->table('audit_logs')->orderBy('id')->get(),DB::table('permission_grants')->orderBy('id')->get()]));$before=$fingerprint();
|
||||
$ok($db->table('requisitions')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('goods_receipts')->count()===0&&$db->table('stock_movements')->count()===0,'new demonstration begins without main operations or physical movements');
|
||||
$client=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:8083','http_errors'=>false,'timeout'=>25]);$jar=new GuzzleHttp\Cookie\CookieJar;
|
||||
$r=$client->get('/qa/enter/validator',['cookies'=>$jar,'query'=>['module'=>'stock']]);$v=json_decode((string)$r->getBody(),true);$options=['cookies'=>$jar,'headers'=>['Accept'=>'application/json','X-SGF-Context'=>$v['context']]];
|
||||
$r=$client->get('/',$options);$ok($r->getStatusCode()===200&&str_contains((string)$r->getBody(),'id="app"'),'existing SGF React shell is served in explicit Stock context');
|
||||
$manifest=json_decode(file_get_contents($s['app'].'/public/build/manifest.json'),true);$entry=$manifest['resources/js/app.jsx']['file'];$r=$client->get('/build/'.$entry);
|
||||
$ok($r->getStatusCode()===200&&hash('sha256',(string)$r->getBody())===hash_file('sha256',$s['app'].'/public/build/'.$entry),'served existing React bundle matches the local manifest');
|
||||
$r=$client->get('/api/stock-read',$options);$d=json_decode((string)$r->getBody(),true);
|
||||
$ok($r->getStatusCode()===200&&$d['rows']===[]&&$d['status']==='available'&&!$d['capabilities']['write'],'same existing Stock reader observes the new empty source without inventing balances');
|
||||
$r=$client->get('/api/session',$options);$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&str_contains(json_encode($d),'stock.view'),'existing session API exposes only the explicit QA stock grant');
|
||||
// Hold a real host authority lock in a separate connection, then release and recover.
|
||||
$c=DB::connection()->getConfig();config(['database.connections.stock_lock'=>$c]);$hold=DB::connection('stock_lock');$hold->beginTransaction();
|
||||
try{$hold->table('permission_grants')->where($f['scope']+['user_id'=>$f['validator'],'permission'=>'stock.view'])->lockForUpdate()->first();$r=$client->get('/api/stock-read',$options);$ok($r->getStatusCode()===503,'host authority lock contention fails boundedly with unavailable');}finally{$hold->rollBack();}
|
||||
$r=$client->get('/api/stock-read',$options);$ok($r->getStatusCode()===200,'read recovers after host authority lock release');
|
||||
$controller=new App\Modules\Inventory\CompositionStockRead;$request=Illuminate\Http\Request::create('/api/stock-read');$ctx=app(App\Shared\Context\CompanyContext::class);$permissions=app(App\Modules\Identity\Services\Permissions::class);
|
||||
foreach(['disabled','production']as$mode){$old=app()->environment();$flag=config('receipt_qa.stock_read_enabled');if($mode==='disabled')config(['receipt_qa.stock_read_enabled'=>false]);else app()->instance('env','production');$status=0;try{$controller->index($request,$ctx,$permissions);}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$status=$e->getStatusCode();}finally{config(['receipt_qa.stock_read_enabled'=>$flag]);app()->instance('env',$old);}$ok($status===($mode==='disabled'?404:403),'direct adapter refuses '.$mode.' before accessing a source');}
|
||||
$ok($fingerprint()===$before,'demo reads preserve source metadata movements audit and grants');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'before'=>$before,'after'=>$fingerprint(),'direct_guard_probes'=>2,'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." composed stock demo checks passed.\n";
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/composition-stock-result.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/composition-stock-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/composition-stock-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator','reviewer','auditor','requester','approver']as$role)$enter($role);$enter('issuer',1);$enter('auditor',1);$base='/api/purchasing-invoices';
|
||||
require __DIR__.'/composition-stock-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/composition-stock-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);
|
||||
if(!($s['stockComposition']??false)||is_file($s['root'].'/stock-binding.json'))throw new LogicException('Fresh stock composition required');
|
||||
require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
App\Modules\Purchasing\BudgetAdapter::guard();
|
||||
$tag=$s['tag'];$run=$s['env']['SGF_ISOLATED_REGRESSION_RUN'];
|
||||
if(!preg_match('/^[a-f0-9]{12}$/D',$tag)||!preg_match('/^[a-f0-9]{12}$/D',$run)||str_replace('\\','/',$s['root'])!=='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$tag)throw new LogicException('Owned fixture required');
|
||||
$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);
|
||||
$admin=new PDO('pgsql:host=127.0.0.1;port=55439;dbname=compras_receipts_test_'.$tag,'sgf_regression_owner',trim(file_get_contents(dirname($s['root']).'/admin-password.txt')),[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
|
||||
if($admin->query("SELECT current_setting('cluster_name')")->fetchColumn()!=='sgf_modular_regression_'.$run)throw new LogicException('Wrong physical cluster');
|
||||
$reader='stock_receipt_read_'.$tag;$password=bin2hex(random_bytes(32));
|
||||
$admin->exec("CREATE ROLE $reader LOGIN PASSWORD '$password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; ALTER ROLE $reader SET default_transaction_read_only=on; GRANT CONNECT ON DATABASE compras_receipts_test_$tag TO $reader; GRANT USAGE ON SCHEMA public TO $reader; GRANT SELECT ON stock_items,warehouses,stock_movements TO $reader;");
|
||||
// New isolated book only: make the reader revision's append-only assumption enforceable.
|
||||
$admin->exec("CREATE FUNCTION composition_stock_append_only() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve stock movement evidence'; END; \$\$;
|
||||
CREATE TRIGGER composition_stock_append_only BEFORE UPDATE OR DELETE ON stock_movements FOR EACH ROW EXECUTE FUNCTION composition_stock_append_only();");
|
||||
DB::transaction(function()use($f){
|
||||
$def=DB::selectOne("SELECT pg_get_constraintdef(oid) definition FROM pg_constraint WHERE conname='permission_grants_scope_check' AND conrelid='permission_grants'::regclass")->definition;
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ('.substr($def,6)." OR (unit_id IS NOT NULL AND permission='stock.view'))");
|
||||
DB::table('permission_grants')->insert($f['scope']+['user_id'=>$f['validator'],'permission'=>'stock.view','created_at'=>now(),'updated_at'=>now()]);
|
||||
});
|
||||
$binding=['schema'=>'public','source_tenant_id'=>$n['tenant'],'warehouse_ids'=>[$n['warehouse']],'append_only'=>true];
|
||||
$connection=$s['nativeConnection'];$connection['username']=$reader;$connection['password']=$password;
|
||||
file_put_contents($s['root'].'/stock-binding.json',json_encode(['connection'=>$connection,'bindings'=>[implode(':',array_values($f['scope'])).':'.$f['validator']=>$binding]],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
file_put_contents($s['root'].'/composition-stock-baseline.json',json_encode(['passed'=>true,'existing_qa_right'=>'stock.view','granted_only_to_fixture_validator'=>true,'source_select_tables'=>['stock_items','warehouses','stock_movements'],'native_writer_changed'=>false,'append_only_trigger_new_qa_book_only'=>true,'new_permission_names'=>[],'real_grants'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
echo "Stock read composition prepared.\n";
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
$requestCommand=function($role,$id,$action,$data)use($call){$base='/api/purchasing-requests';$r=$call($role,$id?$base.'/'.$id:$base);$p=$call($role,$base.'/'.$id.'/preview',['operation_key'=>(string)Str::uuid(),'revision'=>$id?$r['data']['revision']:$r['data']['new_revision'],'action'=>$action,'data'=>$data]);if($p['status']!==200)throw new RuntimeException('Source request preview');$r=$call($role,$base.'/'.$id.'/confirm',['preview'=>$p['data']['preview']]);if($r['status']!==201)throw new RuntimeException('Source request confirmation');return $r['data'];};
|
||||
$r=$requestCommand('requester',0,'save',['cost_center_id'=>$n['centre'],'priority'=>'normal','needed_by'=>date('Y-m-d',strtotime('+30 days')),'justification'=>'Fixture aberta para o selector inicial de fornecedores.','items'=>[['description'=>'Consulta inicial QA','category'=>'Outro','purchase_type'=>'consumable','quantity'=>'1.000','unit'=>'UN','unit_price'=>'1.00','tax_rate'=>'0']]]);$id=$r['request_id'];$requestCommand('requester',$id,'submit',[]);$requestCommand('approver',$id,'decide',['decision'=>'approved','comment'=>'Conferencia distinta da fixture aberta de1MZN.']);$url='/api/purchasing-sourcing/requests/'.$id.'/suppliers';$a=$call('actor',$url.'?page=1');$b=$call('actor',$url.'?page=1&search=');$ok($a['status']===200&&$b['status']===200&&$a['data']['data']===$b['data']['data']&&$a['data']['total']===$b['data']['total']&&$b['data']['total']===3,'supplier empty initial search equals omitted search and returns native paginated eligible directory');$r=$call('actor',$url.'?page=1&search=%20%20');$ok($r['status']===200&&$r['data']['data']===$a['data']['data'],'trimmed blank supplier search keeps unfiltered initial-page semantics');$ok($call('actor',$url.'?search[]=x')['status']===422&&$call('actor',$url.'?per_page=30')['status']===422,'nullable empty filter still rejects array search and unsupported page size');$ok($call('validator',$url.'?search=')['status']===403,'empty search does not bypass sourcing preparation right');$source=json_decode(file_get_contents($s['root'].'/receipt-ui-source.json'),true);$ok($call('actor','/api/purchasing-sourcing/requests/'.$source['request_id'].'/suppliers?search=')['status']===403,'empty search does not reopen proposals closed by actual award');$catalogue='/api/purchasing-receipts/orders/'.$source['order']['id'].'/lines/'.$source['order']['items'][0]['id'].'/catalogue';$a=$call('validator',$catalogue.'?page=1');$b=$call('validator',$catalogue.'?search=&page=1');$ok($a['status']===200&&$b['status']===200&&$a['data']['data']===$b['data']['data'],'empty article filter equals omitted filter in actual receipt catalogue');$ok($call('validator',$catalogue.'?search[]=x')['status']===422&&$call('validator',$catalogue.'?unknown=1')['status']===422,'article catalogue keeps strict parameter types and allowlist');$ok($db->table('goods_receipts')->count()===1&&$db->table('stock_movements')->count()===1&&(int)$db->table('stock_movements')->sum('quantity_milli')===1000&&$db->table('invoices')->count()===0&&$db->table('pa_obligations')->count()===0&&$db->table('external_payments')->count()===0,'catalogue checks preserve one UI receipt and physical movement without invoice debt or payment');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'open_request_fixture_minor'=>100,'supplier_rows'=>3,'new_right_names'=>[],'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." continuity boundary checks passed.\n";
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/continuity-boundaries.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/continuity-boundaries-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/receipt-documents-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator','reviewer','auditor','requester','approver']as$role)$enter($role);$enter('issuer',1);$enter('auditor',1);$base='/api/purchasing-invoices';
|
||||
require dirname(__DIR__).'/scripts/receipt-workspace-source.php';
|
||||
require dirname(__DIR__).'/scripts/continuity-boundaries-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/receipt-documents-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import {appendProcessLinks} from '../public/purchasing-focus.mjs';
|
||||
const tag=process.argv[2];assert(/^[a-f0-9]{12}$/.test(tag??''));const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),root=path.join(run.root,'private/receipt-composition-'+tag),out=path.join(root,'continuity-ui.json');assert(!fs.existsSync(out));const checks=[],ok=(v,name)=>{assert(v,name);checks.push(name);};let enabled=true,allowed=new Set(['receipts','invoices','obligations','dossiers']);
|
||||
class Element{constructor(tag,text){this.tag=tag;this.textContent=text??'';this.children=[];this.dataset={focusEnabled:'true',continuityEnabled:enabled?'true':'false',context:'qa-context',csrf:'qa-csrf'};this.isConnected=true;}append(...nodes){this.children.push(...nodes);}prepend(...nodes){this.children.unshift(...nodes);}replaceChildren(...nodes){this.children=[...nodes];}setAttribute(k,v){this[k]=v;}dispatchEvent(){} }
|
||||
globalThis.Option=class extends Element{constructor(text,value){super('option',text);this.value=value;}};globalThis.FormData=class{*[Symbol.iterator](){} };const walk=e=>[e,...e.children.flatMap(walk)],paginator=data=>({data,current_page:1,last_page:1,per_page:25,total:data.length}),rev='a'.repeat(64),calls=[];let elements=new Map,mismatchReceipt=false;
|
||||
globalThis.document={createElement:tag=>new Element(tag),querySelector:selector=>{if(selector.startsWith('nav[')){const stage=selector.match(/a\[href="\/purchasing\/([a-z]+)"\]/)?.[1];return allowed.has(stage)?new Element('a'):null;}if(!elements.has(selector))elements.set(selector,new Element(selector));return elements.get(selector);}};
|
||||
const request={id:3,number:'REQ-EXACT',status:'approved',items:[],steps:[],total_minor:10000,process_version:3};const invoice={id:77,number:'INV-EXACT',status:'validated',issued_on:'2026-11-03',total_minor:10000,created_by:1,validated_by:2,items:[],purchase_order_id:41};
|
||||
const responses={
|
||||
'/api/purchasing-requests':{new_revision:rev,centres:[],can_prepare:false,requests:paginator([])},'/api/purchasing-requests/3':{request,capabilities:{},history:[],revision:rev},
|
||||
'/api/purchasing-sourcing/requests/3':{request,capabilities:{},comparison:{policy:{minimum_proposals:3,minimum_evaluators:2},ready:true,rows:[],top_ids:[]},quotes:[],award:{id:2,status:'approved',total_minor:10000,approvals:[]},history:[]},
|
||||
'/api/purchasing-orders/awards/2':{basis:{award:{id:2,status:'approved',approvals:[],total_minor:10000},quote:{supplier:{name:'QA'},items:[],currency:'MZN'}},existing:{order:{id:41,number:'OC-EXACT',items:[]}},can_issue:false},
|
||||
'/api/purchasing-receipts/orders/41':{order:{id:41,number:'OC-EXACT'},progress:{state:'complete'},history:{...paginator([]),last_page:2},can_confirm:false},
|
||||
'/api/purchasing-receipts/history/91':{receipt:{id:91,purchase_order_id:41,number:'REC-EXACT'},documents_enabled:false},
|
||||
'/api/purchasing-invoices/orders/41':{order:{id:41,number:'OC-EXACT',currency:'MZN',items:[]},invoices:[invoice],can_prepare:false,revision:rev,receipt_progress:{},physical_corrections:[]},
|
||||
'/api/purchasing-obligations/77':{documents:[{local_id:77,number:'INV-EXACT',supplier_name:'QA',total_minor:10000,status:'validated',order_id:41,missing:[],lines:[]}],obligations:[],permissions:{},proposals:[],events:[],revision:rev,pagination:{proposals:{page:1,last_page:1},events:{page:1,last_page:1}}},
|
||||
'/api/purchasing-dossiers/3':{request,view_revision:rev,_links:[{href:'/purchasing/requests?request=3',label:'Consultar esta requisição'}],stages:Object.fromEntries(['quotes','awards','orders','receipts','invoices','obligations'].map(k=>[k,{accessible:false}])),events:{accessible:true,data:[],pagination:{page:1,last_page:1}}}
|
||||
};
|
||||
globalThis.fetch=async(url,options={})=>{calls.push({url,method:options.method??'GET'});const pathname=url.split('?')[0];let data=responses[pathname];if(!data&&(/\/history$/.test(pathname)||/^\/api\/purchasing-(sourcing|orders|receipts|invoices|obligations|dossiers)$/.test(pathname)))data=paginator([]);if(!data)throw Error('Unexpected mock read '+url);if(mismatchReceipt&&pathname==='/api/purchasing-receipts/history/91')data={receipt:{id:91,purchase_order_id:42,number:'REC-MISMATCH'},documents_enabled:false};return {ok:true,json:async()=>structuredClone(data)};};
|
||||
const settle=()=>new Promise(resolve=>setTimeout(resolve,10));let sequence=0;async function mount(file,query){elements=new Map;calls.length=0;globalThis.location={search:query};await import('../public/'+file+'.mjs?case='+tag+'-'+sequence++);await settle();await settle();return {calls:[...calls],detail:document.querySelector(file==='order-workspace'?'#award-detail':file==='sourcing-workspace'?'#process-detail':'#detail'),status:document.querySelector(file==='receipt-workspace'?'#message':'#status')};}
|
||||
|
||||
responses['/api/purchasing-invoices/orders/41'].order.requisition_id=3;const observed=[],links=d=>walk(d.detail).filter(v=>v.href).map(v=>v.href);let d=await mount('receipt-workspace','?order=41');observed.push(...d.calls);ok(links(d).includes('/purchasing/invoices?order=41'),'receipt carries actual order into invoice consultation');ok(d.calls.every(v=>v.url.startsWith('/api/purchasing-receipts')),'receipt continuation adds no hidden cross-stage request');d=await mount('supplier-invoice-workspace','?order=41&invoice=77');observed.push(...d.calls);ok(['/purchasing/receipts?order=41','/purchasing/dossiers?request=3','/purchasing/obligations?invoice=77'].every(v=>links(d).includes(v)),'invoice links retain exact receipt order dossier request and document responsibility');d=await mount('supplier-obligation-workspace','?invoice=77');observed.push(...d.calls);ok(['/purchasing/invoices?order=41&invoice=77','/purchasing/receipts?order=41'].every(v=>links(d).includes(v)),'obligation returns to exact original invoice and physical order');allowed=new Set(['receipts']);d=await mount('receipt-workspace','?order=41');observed.push(...d.calls);ok(!links(d).length,'physical-only menu does not advertise invoice destination');d=await mount('supplier-invoice-workspace','?order=41&invoice=77');observed.push(...d.calls);ok(links(d).length===1&&links(d)[0]==='/purchasing/receipts?order=41','missing obligation and dossier menu entries suppress only those contextual links');enabled=false;allowed=new Set(['receipts','invoices','obligations','dossiers']);d=await mount('supplier-invoice-workspace','?order=41&invoice=77');observed.push(...d.calls);ok(!links(d).length,'disabled continuity feature leaves previous focused screen without inline links');const node=new Element('div');appendProcessLinks(node,[{href:'/purchasing/orders?award=2',label:'original dossier hint'}]);ok(node.children.length===1,'default dossier link rendering remains independent of optional menu filter');ok(observed.every(v=>v.method==='GET'),'all inline-link startup probes perform reads only');fs.writeFileSync(out,JSON.stringify({passed:true,checks,dom_simulation:true,business_commands:0,visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length}));
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const tag=process.argv[2];assert(/^[a-f0-9]{12}$/.test(tag??''));const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),root=path.join(run.root,'private/receipt-composition-'+tag),s=JSON.parse(fs.readFileSync(path.join(root,'state.json'))),out=path.join(root,'dossier-assets.json');assert(s.dossiers&&!fs.existsSync(out));if(!fs.existsSync(path.join(root,'dossier-build.log'))){const result=spawnSync(process.execPath,['node_modules/vite/bin/vite.js','build','--outDir',path.join(s.app,'public/build')],{cwd:process.cwd(),encoding:'utf8',windowsHide:true,timeout:180000});fs.writeFileSync(path.join(root,'dossier-build.log'),result.stdout+'\n'+result.stderr);assert.equal(result.status,0,'private build');}else assert(fs.readFileSync(path.join(root,'dossier-build.log'),'utf8').includes('built in'),'prior build succeeded');const manifest=JSON.parse(fs.readFileSync(path.join(s.app,'public/build/manifest.json'))),files=Object.values(manifest).flatMap(v=>[v.file,...(v.css??[])]),checks=['private frontend build succeeds'];for(const file of files){const bytes=fs.readFileSync(path.join(s.app,'public/build',file)),r=await fetch('http://127.0.0.1:8074/build/'+file);assert.equal(r.status,200);assert.equal(crypto.createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex'),crypto.createHash('sha256').update(bytes).digest('hex'));}checks.push('served built assets match private build');const r=await fetch('http://127.0.0.1:8074/purchasing-dossier.mjs');assert.equal(r.status,200);checks.push('dossier module is served through private static whitelist');fs.writeFileSync(out,JSON.stringify({passed:true,checks,assets:files,visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length}));
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/dossier-demo-result.json';if(!($s['dossiers']??false)||is_file($out))throw new LogicException('New dossier demonstration required');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.dossier_demo'=>$s['nativeConnection']]);$db=DB::connection('dossier_demo');$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};
|
||||
$fingerprint=function()use($db){$rows=[];foreach(['requisitions','quotations','purchase_orders','goods_receipts','invoices','pa_obligations','audit_logs']as$table)$rows[$table]=$db->table($table)->orderBy('id')->get();$rows['grants']=DB::table('permission_grants')->orderBy('id')->get();$rows['locations']=DB::table('geo_locations')->get();return hash('sha256',json_encode($rows));};$before=$fingerprint();$ok($db->table('requisitions')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('pa_obligations')->count()===0,'live dossier starts without invented process or responsibility');$client=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:8074','http_errors'=>false,'timeout'=>25]);
|
||||
foreach(['requester','approver','actor','issuer','validator','reviewer']as$role){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$client->get('/qa/enter/'.$role,['cookies'=>$jar]);$v=json_decode((string)$r->getBody(),true);$o=['cookies'=>$jar,'headers'=>['Accept'=>'application/json','X-SGF-Context'=>$v['context']]];$r=$client->get('/api/purchasing-dossiers',$o);$d=json_decode((string)$r->getBody(),true);$allowed=in_array($role,['requester','approver','actor','issuer'],true);$ok($allowed?($r->getStatusCode()===200&&$d['data']===[]):$r->getStatusCode()===403,'live dossier list respects '.$role);
|
||||
if($role==='actor'){$r=$client->get('/purchasing/dossiers',$o);$ok($r->getStatusCode()===200&&str_contains((string)$r->getBody(),'purchasing-dossier.mjs'),'live dossier page served');$grant=DB::table('permission_grants')->where($f['scope'])->where('user_id',$f['actor'])->where('permission','purchasing_requests.view')->first();DB::table('permission_grants')->where('id',$grant->id)->delete();try{$r=$client->get('/api/purchasing-dossiers',$o);$ok($r->getStatusCode()===403,'revoked existing request right immediately removes dossier access');}finally{DB::table('permission_grants')->insert((array)$grant);}$r=$client->get('/api/purchasing-dossiers',$o);$ok($r->getStatusCode()===200,'restored fixture grant recovers consultation');}
|
||||
}
|
||||
$r=$client->get('/purchasing-dossier.mjs');$ok($r->getStatusCode()===200&&hash('sha256',(string)$r->getBody())===hash_file('sha256',$s['app'].'/public/purchasing-dossier.mjs'),'served dossier script matches delivery');$ok($fingerprint()===$before,'live checks preserve process grants and geographical evidence');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'before'=>$before,'after'=>$fingerprint(),'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." dossier demonstration checks passed.\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/dossier-guard-result.json';if(!($s['dossiers']??false)||is_file($out))throw new LogicException('New dossier fixture required');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$queries=0;$checks=[];DB::listen(function()use(&$queries){$queries++;});$controller=new App\Modules\Purchasing\DossierController;$runtime=new App\Modules\Purchasing\ReceiptRuntime;
|
||||
$check=function($fn,$status,$name)use(&$checks){try{$fn();throw new RuntimeException('Unexpected access');}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==$status)throw $e;}$checks[]=$name;};
|
||||
config(['receipt_qa.dossiers_enabled'=>false]);$check(fn()=>$controller->index(new Illuminate\Http\Request,$runtime),404,'disabled dossier list refuses before SQL');$check(fn()=>$controller->show(1,new Illuminate\Http\Request,$runtime),404,'disabled dossier detail refuses before SQL');
|
||||
config(['receipt_qa.dossiers_enabled'=>true]);$app->instance('env','production');$check(fn()=>$controller->index(new Illuminate\Http\Request,$runtime),403,'production refuses synthetic dossier before SQL');
|
||||
if($queries!==0||App\Shared\Context\ModuleNavigation::owner('api/purchasing-dossiers/1')!=='purchasing')throw new RuntimeException('Guard boundary');$checks[]='dossier route belongs to purchasing and guard probes issue zero SQL';file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'database_queries'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." dossier guard checks passed.\n";
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
if(!($s['read_overlay']??false)||!is_file($s['original_root'].'/integrated-result.json'))throw new LogicException('Completed regression book, new read application only');
|
||||
$fingerprint=function($connection){$result=[];foreach($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename")as$table){if(!preg_match('/^[a-z0-9_]+$/D',$table->tablename))throw new LogicException('Unexpected fixture table');$name=$table->tablename;$row=$connection->selectOne('SELECT count(*) n,md5(coalesce(string_agg(row_to_json(t)::text,\'\' ORDER BY row_to_json(t)::text),\'\')) h FROM "'.$name.'" t');$result[$name]=[(int)$row->n,$row->h];}$result['sequences']=$connection->select("SELECT sequencename,last_value FROM pg_sequences WHERE schemaname='public' ORDER BY sequencename");return $result;};
|
||||
$before=['host'=>$fingerprint(DB::connection()),'native'=>$fingerprint($db)];$saved=json_decode(file_get_contents($s['original_root'].'/integrated-result.json'),true);$id=$saved['source_ids']['request'];$base='/api/purchasing-dossiers';$url=$base.'/'.$id;
|
||||
$ok($db->table('pa_obligations')->count()===1&&(int)$db->table('pa_obligations')->sum('principal_minor')===10000,'read proof reuses already-proven canonical responsibility without replaying commands');
|
||||
$r=$call('issuer',$base.'?status=approved');$ok($r['status']===200&&$r['data']['total']===1&&$r['data']['per_page']===25&&$r['data']['data'][0]['id']===$id,'dossier list applies current origin and server status filter');
|
||||
$r=$call('issuer',$base.'?search='.rawurlencode($db->table('requisitions')->where('id',$id)->value('number')));$ok($r['status']===200&&$r['data']['total']===1,'literal process number search returns existing source');
|
||||
$r=$call('issuer',$base.'?page=2');$ok($r['status']===200&&$r['data']['data']===[],'second dossier list page contains no repeated source');
|
||||
$r=$call('issuer',$url);$full=$r['data'];$ok($r['status']===200&&$full['read_only']===true&&$full['request']['id']===$id&&strlen($full['view_revision'])===64,'finance dossier identifies exact source and visible-page revision');
|
||||
foreach(['quotes'=>3,'awards'=>1,'orders'=>1,'receipts'=>1,'invoices'=>1]as$stage=>$count)$ok($full['stages'][$stage]['accessible']===true&&$full['stages'][$stage]['pagination']['total']===$count&&count($full['stages'][$stage]['data'])===$count,'canonical '.$stage.' evidence is present with independent page');
|
||||
$ok($full['stages']['orders']['data'][0]['id']===$saved['source_ids']['order']&&$full['stages']['invoices']['data'][0]['id']===$saved['source_ids']['invoice'],'dossier correlations use actual original order and invoice ids');
|
||||
$position=$full['stages']['obligations']['data'][0];$ok($position['id']===$saved['source_ids']['obligation']&&$position['principal_minor']===10000&&$position['balance_minor']===10000&&$position['due_on']===null&&$position['source_conflict']===false,'canonical kernel supplies current balance with unknown due date');
|
||||
$ok($full['stages']['obligations']['source_window']===true&&$full['stages']['obligations']['pagination']['total_sources']===1,'financial pagination describes bounded source window rather than invented global total');
|
||||
$ok($full['events']['pagination']['per_page']===25&&count($full['events']['data'])===25,'authorised event timeline uses server pagination');$e2=$call('issuer',$url.'?events_page=2');$ok($e2['status']===200&&count($e2['data']['events']['data'])>0&&array_intersect(array_column($full['events']['data'],'id'),array_column($e2['data']['events']['data'],'id'))===[],'older event page preserves distinct existing history');
|
||||
$r=$call('issuer',$url.'?quotes_page=2&awards_page=2&orders_page=2&receipts_page=2&invoices_page=2&obligations_page=2');$ok($r['status']===200&&$r['data']['stages']['quotes']['data']===[]&&$r['data']['stages']['invoices']['data']===[]&&$r['data']['stages']['obligations']['data']===[],'independent later evidence pages do not reload earlier records');
|
||||
$r=$call('actor',$url);$ok($r['status']===200&&$r['data']['stages']['quotes']['accessible']===true&&$r['data']['stages']['invoices']===['accessible'=>false]&&$r['data']['stages']['obligations']===['accessible'=>false],'buyer sees procurement evidence without invoice or payable economics');
|
||||
$r=$call('requester',$url);$owner=$r['data'];$ok($r['status']===200&&$owner['request']['id']===$id&&count(array_filter($owner['stages'],fn($v)=>$v!==['accessible'=>false]))===0,'request-only profile sees no downstream rows counts or amounts');
|
||||
$ok(!array_filter($owner['events']['data'],fn($v)=>!str_starts_with($v['action'],'requisition.')&&$v['action']!=='sgf.request.command'),'request-only history excludes hidden-stage activity');
|
||||
$ok(!array_filter($full['events']['data'],fn($v)=>array_diff(array_keys($v),['id','user_id','action','created_at'])),'event projection never embeds unrestricted before-after payloads');
|
||||
$ok($call('validator',$url)['status']===403&&$call('reviewer',$url)['status']===403&&$call('auditor',$base)['status']===403,'physical-only and invoice-only profiles do not inherit request dossier rights');
|
||||
$ok($call('issuer',$base.'?unknown=1')['status']===422&&$call('issuer',$url.'?events_page=0')['status']===422&&$call('issuer',$url.'?page=1')['status']===422,'unsupported filters and evidence pages refused');
|
||||
$ok($call('issuer',$base.'/'.$n['foreign_requisition'])['status']===403&&$call('issuer',$base.'/999999')['status']===403,'foreign or unknown request exposes no dossier');
|
||||
$enter('issuer',1,['module'=>'commercial']);$ok($call('issuer',$url,null,1)['status']===409,'different available module cannot inspect dossier');$enter('issuer',1,['foreign'=>1]);$ok($call('issuer',$url,null,1)['status']===403,'unmapped unit cannot inspect known dossier');
|
||||
$ok($call('issuer',$url,null,0,['X-SGF-Context'=>'old'])['status']===409,'stale context generation cannot mix dossier reads');
|
||||
$ok($call('issuer','/purchasing/dossiers')['status']===200&&str_contains($call('issuer','/purchasing/workspace')['body'],'href="/purchasing/dossiers"'),'authorised dossier screen is connected to common navigation');
|
||||
$ok(!str_contains($call('validator','/purchasing/workspace')['body'],'href="/purchasing/dossiers"'),'physical-only navigation does not advertise request dossier');
|
||||
$ok($call('issuer',$url,[])['status']===405,'dossier has no mutation endpoint');
|
||||
$after=['host'=>$fingerprint(DB::connection()),'native'=>$fingerprint($db)];$ok(json_encode($before)===json_encode($after),'all original regression tables and sequences remain unchanged after read proof');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'reused_proof'=>'purchasing-integrated-composition-20261103.json','financial_commands_repeated'=>0,'before_sha256'=>hash('sha256',json_encode($before)),'after_sha256'=>hash('sha256',json_encode($after)),'tables_checked'=>count($before['host'])+count($before['native'])-2,'new_right_names'=>[],'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." dossier read checks passed.\n";
|
||||
@@ -0,0 +1,8 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),tag=crypto.randomBytes(6).toString('hex'),sourceRoot=path.join(run.root,'private/receipt-composition-b4093c6f6707'),source=JSON.parse(fs.readFileSync(path.join(sourceRoot,'state.json'))),root=path.join(run.root,'private/dossier-read-'+tag),app=path.join(root,'app');
|
||||
if(!JSON.parse(fs.readFileSync(path.join(sourceRoot,'integrated-result.json'))).passed||source.env.DB_DATABASE!=='budget_core_sgf_b4093c6f6707'||source.env.DB_PORT!=='55439'||source.env.SGF_ISOLATED_REGRESSION_RUN!==run.id)throw Error('Owned completed regression book required');
|
||||
fs.mkdirSync(root);for(const dir of ['app','bootstrap','config','routes','resources','database','public'])fs.cpSync(dir,path.join(app,dir),{recursive:true,filter:p=>!p.replaceAll('\\','/').includes('bootstrap/cache')});for(const pkg of ['budget-control','payables','general-ledger','module-updates','purchasing'])fs.cpSync(path.join('packages',pkg),path.join(app,'packages',pkg),{recursive:true});fs.symlinkSync(path.resolve('vendor'),path.join(app,'vendor'),'junction');fs.copyFileSync('composer.json',path.join(app,'composer.json'));for(const dir of ['bootstrap/cache','storage/logs','storage/framework/cache/data','storage/framework/views','storage/framework/sessions'])fs.mkdirSync(path.join(app,dir),{recursive:true});
|
||||
// Existing bindings, native manifests and business facts remain in place; only application/session files are new.
|
||||
for(const file of ['fixture.json','native-fixture.json'])fs.copyFileSync(path.join(sourceRoot,file),path.join(root,file));
|
||||
const env={...source.env,APP_BASE_PATH:app,SGF_DOSSIERS_QA_ENABLED:'true',SESSION_COOKIE:'sgf_dossier_read_'+tag,RECEIPT_COMPOSITION_STATE:path.join(root,'state.json')};const state={...source,root,app,env,dossiers:true,read_overlay:true,original_root:sourceRoot};fs.writeFileSync(path.join(root,'state.json'),JSON.stringify(state,null,2));
|
||||
const result=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',[path.resolve('scripts/dossier-read-proof.php')],{cwd:app,env,encoding:'utf8',windowsHide:true,timeout:180000});fs.writeFileSync(path.join(root,'read-proof.log'),result.stdout+'\n'+result.stderr);if(result.status!==0||!fs.existsSync(path.join(root,'dossier-read-result.json')))throw Error('Dossier read proof incomplete; private diagnostics retained '+tag);console.log(JSON.stringify({tag,root,result:result.stdout.trim()}));
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/dossier-read-result.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/dossier-read-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/dossier-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator','reviewer','auditor','requester','approver']as$role)$enter($role);$enter('issuer',1);$enter('auditor',1);$base='/api/purchasing-invoices';
|
||||
require dirname(__DIR__).'/scripts/dossier-read-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/dossier-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,11 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned native fixture required');
|
||||
const root=path.join(state.root,'private/ledger-native-'+tag),settings=read(path.join(root,'settings.json')),artifact=read(path.join(settings.common_bundle,'manifest.json')),testRoot=path.join(root,'bundle-boot-'+crypto.randomBytes(6).toString('hex'));fs.mkdirSync(testRoot);let checks=0;
|
||||
for(const [name,expected] of [['modified','FINANCE_BUNDLE_FILE_MISMATCH'],['extra','FINANCE_BUNDLE_EXTRA_OR_MISSING_FILE'],['pin','FINANCE_BUNDLE_MANIFEST_MISMATCH'],['loader','FINANCE_BUNDLE_LOADER_MISMATCH']]){
|
||||
const dir=path.join(testRoot,name);fs.mkdirSync(dir);for(const f of [...artifact.files.map(f=>f.file),'manifest.json']){const to=path.join(dir,f);fs.mkdirSync(path.dirname(to),{recursive:true});fs.copyFileSync(path.join(settings.common_bundle,f),to);}
|
||||
if(name==='modified')fs.appendFileSync(path.join(dir,'payables/bootstrap.php'),'\n// QA altered content\n');if(name==='extra')fs.writeFileSync(path.join(dir,'unexpected.txt'),'QA');
|
||||
const marker=path.join(dir,'executed.txt');if(name==='loader')fs.writeFileSync(path.join(dir,'Bundle.php'),'<?php file_put_contents('+JSON.stringify(marker.replaceAll('\\','/'))+', "should never run");');
|
||||
const cfg=path.join(testRoot,name+'-settings.json');fs.writeFileSync(cfg,JSON.stringify({...settings,common_bundle:dir,common_bundle_sha256:name==='pin'?'0'.repeat(64):settings.common_bundle_sha256}));
|
||||
const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/payable-history-sgf-proof.php',cfg,'native-cycle-bundle'],{encoding:'utf8',windowsHide:true,maxBuffer:1024*1024});const log=(r.stdout??'')+(r.stderr??'');fs.writeFileSync(path.join(testRoot,name+'.log'),log);if(!log.includes(expected)||fs.existsSync(marker))throw Error('Native boot did not fail before execution: '+name);checks++;
|
||||
}
|
||||
const result={passed:true,checks,refused_before_business_boot:['modified','extra','wrong_pin','modified_loader'],loader_marker_never_executed:true,no_fallback:true,original_proof_preserved:true};fs.writeFileSync(path.join(testRoot,'proof.json'),JSON.stringify(result,null,2)+'\n');fs.writeFileSync(path.join(state.root,'finance-bundle-boot-latest.json'),JSON.stringify({root:testRoot,...result},null,2)+'\n');console.log(JSON.stringify(result));
|
||||
@@ -0,0 +1,10 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));if(state.port!==55439)throw Error('Owned QA artifact root required');
|
||||
const tag=crypto.randomBytes(6).toString('hex'),root=path.join(state.root,'private/finance-bundle-'+tag),sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');fs.mkdirSync(root);const files=[];
|
||||
const copy=(source,file)=>{const destination=path.join(root,file);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.copyFileSync(source,destination);files.push({file,sha256:sha(destination)});};
|
||||
copy('packages/finance-bundle/Bundle.php','Bundle.php');
|
||||
for(const name of ['payables','general-ledger']){for(const file of ['bootstrap.php','README.md'])if(fs.existsSync('packages/'+name+'/'+file))copy('packages/'+name+'/'+file,name+'/'+file);for(const dir of ['src','ui'])for(const file of fs.readdirSync('packages/'+name+'/'+dir).sort()){if(!/^[A-Za-z0-9_-]+\.php$/.test(file))throw Error('Unexpected runtime source');copy('packages/'+name+'/'+dir+'/'+file,name+'/'+dir+'/'+file);}}
|
||||
files.sort((a,b)=>a.file.localeCompare(b.file));const manifest=path.join(root,'manifest.json');fs.writeFileSync(manifest,JSON.stringify({contract:'COMMON-FINANCE-BUNDLE-v1',synthetic_only:true,files},null,2)+'\n');const hash=sha(manifest);
|
||||
const code='require $argv[1]."/Bundle.php"; echo json_encode(Tempo\\Finance\\Composition\\Bundle::verify($argv[1],$argv[2]));';
|
||||
const test=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-r',code,root,hash],{encoding:'utf8',windowsHide:true});if(test.status!==0)throw Error('Fresh artifact verification failed');
|
||||
const record={tag,root,manifest_sha256:hash,files:files.length,tests_or_credentials_included:false,installed_or_activated:false};fs.writeFileSync(path.join(state.root,'finance-bundle-latest.json'),JSON.stringify(record,null,2)+'\n');console.log(JSON.stringify(record));
|
||||
@@ -0,0 +1,10 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),artifact=read(path.join(state.root,'finance-bundle-latest.json')),root=path.join(state.root,'private/finance-bundle-check-'+crypto.randomBytes(6).toString('hex'));fs.mkdirSync(root);let checks=0;
|
||||
const manifest=read(path.join(artifact.root,'manifest.json')),run=(dir,pin)=>spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-r','require $argv[1]; try { Tempo\\Finance\\Composition\\Bundle::verify($argv[2],$argv[3]); echo "accepted"; } catch(Throwable $e) { echo "refused"; exit(2); }',path.resolve('packages/finance-bundle/Bundle.php'),dir,pin],{encoding:'utf8',windowsHide:true});
|
||||
if(run(artifact.root,artifact.manifest_sha256).status!==0)throw Error('Valid artifact refused');checks++;
|
||||
for(const kind of ['modified','missing','extra','traversal','pin']){
|
||||
const dir=path.join(root,kind);fs.mkdirSync(dir);for(const f of [...manifest.files.map(f=>f.file),'manifest.json']){if(kind==='missing'&&f==='payables/bootstrap.php')continue;const target=path.join(dir,f);fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(path.join(artifact.root,f),target);}
|
||||
let pin=artifact.manifest_sha256;if(kind==='modified')fs.appendFileSync(path.join(dir,'payables/bootstrap.php'),'\n// QA altered file\n');if(kind==='extra')fs.writeFileSync(path.join(dir,'unexpected.txt'),'QA');if(kind==='traversal'){const m=read(path.join(dir,'manifest.json'));m.files[0].file='../outside.php';fs.writeFileSync(path.join(dir,'manifest.json'),JSON.stringify(m));pin=crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,'manifest.json'))).digest('hex');}if(kind==='pin')pin='0'.repeat(64);
|
||||
if(run(dir,pin).status!==2)throw Error('Invalid artifact not refused: '+kind);checks++;
|
||||
}
|
||||
const output={passed:true,checks,artifact_files:manifest.files.length,manifest_sha256:artifact.manifest_sha256,refused:['modified','missing','extra','traversal','wrong_pin'],private_diagnostics_preserved:true};fs.writeFileSync(path.join(root,'proof.json'),JSON.stringify(output,null,2)+'\n');fs.writeFileSync(path.join(state.root,'finance-bundle-check-latest.json'),JSON.stringify({root,...output},null,2)+'\n');console.log(JSON.stringify(output));
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\ReceiptReader;
|
||||
$out=$s['root'].'/financial-receipt-core.json';if(file_exists($out))throw new LogicException('Preserved core proof');$f=json_decode(file_get_contents($s['root'].'/financial-receipt-fixture.json'),true);$checks=0;$ok=function($v)use(&$checks){$checks++;if(!$v)throw new LogicException('Scoped receipt core invariant failed');};DB::beginTransaction();try{DB::statement('SET TRANSACTION READ ONLY');foreach(['tenant_id','company_id','unit_id'] as $key){$other=$scope;$other[$key]+=100;$ok(ReceiptReader::read(DB::connection(),'recognition',$other,$g['actors']['prepare'],$f['key'],static fn()=>null)===null);}$caught=false;try{ReceiptReader::read(DB::connection(),'recognition',$scope,$g['actors']['prepare'],$f['key'],null);}catch(DomainException){$caught=true;}$ok($caught);$caught=false;try{ReceiptReader::read(DB::connection(),'pa_commands;DROP TABLE users',$scope,$g['actors']['prepare'],$f['key'],static fn()=>null);}catch(InvalidArgumentException){$caught=true;}$ok($caught);$ok(DB::table('ps_payments')->count()===0);}finally{DB::rollBack();}file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'foreign_tenant_company_unit_absent'=>true,'missing_read_authorizer_refused'=>true,'domain_whitelist'=>true,'read_only'=>true],JSON_PRETTY_PRINT));echo "Financial receipt core: $checks checks.\n";
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import vm from 'node:vm';import assert from 'node:assert/strict';
|
||||
const root='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/ledger-6070063f42f2',out=root+'/financial-receipt-form.json';if(fs.existsSync(out))throw Error('Preserved proof');const scripts=[...fs.readFileSync(root+'/financial-receipt-page.html','utf8').matchAll(/<script>([\s\S]*?)<\/script>/g)].map(x=>x[1]);for(const s of scripts)new vm.Script(s);let checks=0;const ok=(v,m)=>{checks++;assert(v,m);},form={elements:{domain:{value:'ledger'},command_key:{value:'11111111-1111-1111-1111-111111111111'}},querySelector:()=>button},button={},status={},result={set innerHTML(v){throw Error('Must never insert HTML');}},calls=[];let fail=false;const ctx={document:{getElementById:id=>({'receipt-lookup':form,'receipt-status':status,'receipt-result':result}[id])},fetch:async(url,options)=>{calls.push({url,options});return {ok:!fail,json:async()=>fail?{message:'Refused'}:{result:'<img src=x onerror=unsafe()>'}};}};vm.createContext(ctx);vm.runInContext(scripts.find(s=>s.includes('receipt-lookup')),ctx);for(const domain of ['ledger','recognition','settlement']){form.elements.domain.value=domain;await form.onsubmit({preventDefault(){}});const c=calls.at(-1);ok(c.url.includes(domain==='ledger'?'/receipts/':'/financial-receipts/'+domain+'/')&&!c.options.method&&!c.options.body,'Explicit domain GET only '+domain);}ok(result.textContent.includes('<img')&&!button.disabled,'Result remains text, no executable HTML');fail=true;await form.onsubmit({preventDefault(){}});ok(status.textContent.includes('Refused')&&result.textContent===''&&!button.disabled&&calls.length===4,'Refusal clears result and never auto-replays');fs.writeFileSync(out,JSON.stringify({passed:true,checks,rendered_scripts_compile:true,mock_get_only:true,no_browser:true},null,2));console.log(JSON.stringify({passed:true,checks}));
|
||||
@@ -0,0 +1,8 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned cost fixture required');const root=path.join(state.root,'private/ledger-'+tag),g=read(path.join(root,'cost-fixture.json')),out=path.join(root,'financial-receipt-http.json');if(fs.existsSync(out)||!fs.existsSync(path.join(root,'financial-receipt-fixture.json')))throw Error('Fresh HTTP proof with private baseline required');
|
||||
await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(8047,'127.0.0.1',()=>s.close(resolve));});const log=fs.openSync(path.join(root,'cost-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-cost-http-router.php')],{windowsHide:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:path.join(root,'settings.json')}});
|
||||
let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};const client=()=>{const jar=new Map();let auth;const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/cost-enter/'+role);ok(r.status===200,'Owned '+role+' entry');auth=r.data;ok(jar.has('sgf_cost_'+tag)&&!jar.has('XSRF-TOKEN'),'Separate session cookie and shared CSRF preserved');},module:async module=>{const old=auth.context,r=await request('/api/context',{...g.scope,module_id:module});ok(r.status===200,'Explicit module transition');auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
|
||||
|
||||
|
||||
const fixture=read(path.join(root,'financial-receipt-fixture.json')),base='/api/general-ledger/financial-receipts/',api=base+'recognition/'+fixture.key;
|
||||
try{const p=client(),v=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await p.request(api)).status===401;}catch{}if(!ready)await new Promise(r=>setTimeout(r,100));}ok(ready,'Anonymous denied');await p.enter('prepare');await v.enter('validate');let r=await p.request(api);ok(r.status===200&&r.data.domain==='recognition'&&JSON.stringify(r.data.result)===JSON.stringify(fixture.result),'Original recognition result');ok(r.headers.get('cache-control').includes('no-store'),'Private receipt');r=await p.request(base+'settlement/'+fixture.key);ok(r.status===200&&r.data.result.qa_fixture&&r.data.operation_executed===false,'Explicit domain same key no execution');ok((await v.request(api)).status===404,'Other actor receipt absent');ok((await v.request(base+'settlement/'+fixture.key)).status===403,'Separate settlement view right required');ok((await p.request('/api/general-ledger/receipts/'+fixture.key)).status===404,'No fallback to ledger');ok((await p.request(base+'unknown/'+fixture.key)).status===422,'Unknown domain refused');ok((await p.request(api+'?company_id=8')).status===422,'Scope injection refused');ok((await p.request(base+'recognition/invalid')).status===422,'Bad key refused');const page=await p.request('/general-ledger/costs?order_id='+g.order_id);ok(page.status===200&&page.text.includes('name="domain"')&&page.text.includes('value="settlement"'),'Integrated selector');fs.writeFileSync(path.join(root,'financial-receipt-page.html'),page.text);const old=await p.module('finance');ok((await p.request(api)).status===409,'Accounting context required');await p.module('accounting');ok((await p.request(api,undefined,undefined,{'X-SGF-Context':old})).status===409,'Stale generation refused');fs.writeFileSync(out,JSON.stringify({passed:true,checks,no_business_commands:true,actual_http:true,visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks}));}finally{server.kill();fs.closeSync(log);}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$out=$s['root'].'/financial-receipt-cases.json';if(file_exists($out))throw new LogicException('Preserved proof');$u=$enter('prepare');$row=DB::table('pa_commands')->where($scope)->where('actor_id',$u->id)->first();$key=$row->command_key;DB::table('pa_access')->insert($scope+['user_id'=>$u->id,'person'=>'cost-qa-person-'.$u->id,'action'=>'payments_view']);
|
||||
// Infrastructure fixture only: this receipt represents no payment or other business effect.
|
||||
DB::table('ps_commands')->insert($scope+['actor_id'=>$u->id,'command_key'=>$key,'request_hash'=>hash('sha256','QA receipt-only infrastructure fixture'),'result'=>json_encode(['qa_fixture'=>true,'state'=>'no_business_effect']),'created_at'=>now()]);$checks=0;$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};$refuse=function($fn)use($ok){$caught=false;try{$fn();}catch(DomainException|InvalidArgumentException|Symfony\Component\HttpKernel\Exception\HttpException){$caught=true;}$ok($caught,'Expected domain/access/key refusal');};$before=$sourceFingerprint();$counts=[DB::table('pa_commands')->count(),DB::table('ps_commands')->count(),DB::table('ps_payments')->count()];$locks=0;$mode=null;DB::listen(function($q)use(&$locks,&$mode){if(str_contains(strtolower($q->sql),'for update'))$locks++;if($mode===null&&(str_contains($q->sql,'"pa_commands"')||str_contains($q->sql,'"ps_commands"')))$mode=['isolation'=>$q->connection->getPdo()->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$q->connection->getPdo()->query('SHOW transaction_read_only')->fetchColumn()];});
|
||||
$a=$ledger->financialReceipt($enter('prepare'),'recognition',$key);$b=$ledger->financialReceipt($enter('prepare'),'settlement',$key);$ok($a['result']===json_decode($row->result,true)&&$a['domain']==='recognition','Actual original recognition receipt');$ok($b['result']['qa_fixture']===true&&$b['domain']==='settlement','Same UUID selects only explicit settlement domain');$ok($a['operation_executed']===false&&$b['operation_executed']===false,'No execution in either domain');$ok($ledger->financialReceipt($enter('prepare'),'settlement',$key)===$b,'Repeated read identical');$ok($ledger->financialReceipt($enter('validate'),'recognition',$key)===null,'Other actor cannot read known key');$refuse(fn()=>$ledger->financialReceipt($enter('validate'),'settlement',$key));$refuse(fn()=>$ledger->financialReceipt($enter('prepare'),'unknown',$key));$refuse(fn()=>$ledger->financialReceipt($enter('prepare'),'recognition','invalid'));$ok($ledger->financialReceipt($enter('prepare'),'recognition',(string)Str::uuid())===null,'Unknown is absent, no fallback');
|
||||
DB::beginTransaction();try{DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['prepare'])->where('action','payments_view')->delete();$refuse(fn()=>$ledger->financialReceipt($enter('prepare'),'settlement',$key));$ok($ledger->financialReceipt($enter('prepare'),'recognition',$key)!==null,'Recognition right does not inherit settlement or vice versa');}finally{DB::rollBack();}
|
||||
$ok($locks===0&&$mode===['isolation'=>'repeatable read','read_only'=>'on'],'Coherent snapshot without writer locks');$ok($before===$sourceFingerprint()&&$counts===[DB::table('pa_commands')->count(),DB::table('ps_commands')->count(),DB::table('ps_payments')->count()]&&$counts[2]===0,'All reads/probes preserve source, rights, receipts and zero payments');file_put_contents($s['root'].'/financial-receipt-fixture.json',json_encode(['key'=>$key,'result'=>$a['result'],'settlement_infrastructure_only'=>true],JSON_PRETTY_PRINT));file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'same_uuid_domains_separate'=>true,'read_only'=>$mode,'writer_locks'=>$locks,'no_payments_or_effects_created'=>true,'synthetic_receipt_fixture_explicit'=>true,'source_rights_unchanged'=>true],JSON_PRETTY_PRINT));echo "Financial receipts: $checks checks; actual recognition and explicit no-effect settlement fixture isolated.\n";
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/geography-result.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/geography-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/geography-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator']as$role)$enter($role);$enter('actor',1);$base='/api/geography';
|
||||
require dirname(__DIR__).'/scripts/geography-proof-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/geography-http-'.$i.'.log',$server->getOutput()."\n".$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const tag=process.argv[2];assert(/^[a-f0-9]{12}$/.test(tag??''));const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),root=path.join(run.root,'private/receipt-composition-'+tag),s=JSON.parse(fs.readFileSync(path.join(root,'state.json'))),out=path.join(root,'geography-demo-assets.json');assert(s.suppliers&&!fs.existsSync(out));if(!fs.existsSync(path.join(root,'geography-build.log'))){const result=spawnSync(process.execPath,['node_modules/vite/bin/vite.js','build','--outDir',path.join(s.app,'public/build')],{cwd:process.cwd(),encoding:'utf8',windowsHide:true,timeout:180000});fs.writeFileSync(path.join(root,'geography-build.log'),result.stdout+'\n'+result.stderr);assert.equal(result.status,0,'private build');}else assert(fs.readFileSync(path.join(root,'geography-build.log'),'utf8').includes('built in'),'prior build succeeded');const manifest=JSON.parse(fs.readFileSync(path.join(s.app,'public/build/manifest.json'))),files=Object.values(manifest).flatMap(v=>[v.file,...(v.css??[])]),checks=['private frontend build succeeds'];for(const file of files){const bytes=fs.readFileSync(path.join(s.app,'public/build',file)),r=await fetch('http://127.0.0.1:8069/build/'+file);assert.equal(r.status,200);assert.equal(crypto.createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex'),crypto.createHash('sha256').update(bytes).digest('hex'));}checks.push('served built assets match private build');const r=await fetch('http://127.0.0.1:8069/geography-workspace.mjs');assert.equal(r.status,200);checks.push('geography module is served through private static whitelist');fs.writeFileSync(out,JSON.stringify({passed:true,checks,assets:files,visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length}));
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/geography-demo-smoke.json';if(is_file($out))throw new LogicException('Proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};$fp=fn()=>hash('sha256',json_encode([DB::table('geo_locations')->get(),DB::table('geo_location_history')->get(),DB::table('geo_commands')->get()]));$before=$fp();$ok(DB::table('geo_locations')->count()===0&&DB::table('geo_location_history')->count()===0,'demo begins without locations or geographic decisions');$client=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:8069','http_errors'=>false,'timeout'=>25]);
|
||||
$enter=function($role,$module='purchasing')use($client){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$client->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>['module'=>$module]]);return ['jar'=>$jar]+json_decode((string)$r->getBody(),true);};$options=fn($v)=>['cookies'=>$v['jar'],'headers'=>['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];
|
||||
foreach(['actor','issuer','validator']as$role){$v=$enter($role);$r=$client->get('/api/geography/entities/company/'.$f['company'],$options($v));$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$d['location']===null&&$d['entity']['editable']===($role!=='validator'),'company location capability matches '.$role);}
|
||||
$v=$enter('actor');$r=$client->get('/geography/company/'.$f['company'],$options($v));$ok($r->getStatusCode()===200&&str_contains((string)$r->getBody(),'geography-workspace.mjs'),'shared geography screen is live');$r=$client->get('/api/geography/catalogue',$options($v));$catalogue=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&count($catalogue['records']['data'])===11,'live reference exposes11 source provinces');$r=$client->get('/api/geography/entities/company/'.$f['company'],$options($v));$d=json_decode((string)$r->getBody(),true);$r=$client->post('/api/geography/entities/company/'.$f['company'].'/preview',$options($v)+['json'=>['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$d['revision'],'catalogue_version'=>$d['catalogue_version'],'territory_key'=>$catalogue['records']['data'][0]['key'],'reason'=>'Prévia de ensaio sem atribuir localização.']]);$p=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$p['impact']['after']['country_code']==='MZ'&&$p['impact']['before']===null&&$p['impact']['after']['version']===$catalogue['version'],'read-only preview carries country and exact source version');$r=$client->get('/api/purchasing-suppliers/'.$n['supplier_ids'][0],$options($v));$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$d['geography_enabled']===true,'supplier record advertises shared geographic selector');
|
||||
$v=$enter('actor','commercial');$r=$client->get('/api/geography/entities/client/'.$f['client'],$options($v));$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$d['entity']['editable']===true&&$d['location']===null,'synthetic associated client can open selector without an initial location');$r=$client->get('/api/geography/segments/client',$options($v));$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$d['data']===[],'segmentation demo begins with no assigned client locations');$r=$client->get('/api/geography/entities/client/'.$f['pending_client'],$options($v));$ok($r->getStatusCode()===403,'pending synthetic client stays outside the selected module');$r=$client->get('/geography/client/'.$f['client'],$options($v));$ok($r->getStatusCode()===200,'client reaches the same live selector');$r=$client->get('/geography-workspace.mjs');$ok($r->getStatusCode()===200&&hash('sha256',(string)$r->getBody())===hash_file('sha256',$s['app'].'/public/geography-workspace.mjs'),'served selector script matches delivered source');$ok($fp()===$before,'smoke and preview persist no geographic location history or command');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'before'=>$before,'after'=>$fp(),'visual_review'=>false,'locations'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." geography demo checks passed.\n";
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/geography-guards.json';if(is_file($out))throw new LogicException('Proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$queries=0;$checks=[];DB::listen(function()use(&$queries){$queries++;});$probe=function($call,$expected,$name)use(&$queries,&$checks){$before=$queries;try{$call();throw new RuntimeException('Guard accepted unexpectedly');}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==$expected||$queries!==$before)throw new RuntimeException($name);$checks[]=$name;}};config(['geography.enabled'=>false]);$probe(fn()=>App\Shared\Geography\Catalogue::guard(),404,'disabled catalogue refuses before SQL');$probe(fn()=>(new App\Shared\Geography\GeographyController)->preview('company',1,new Illuminate\Http\Request,new App\Shared\Geography\Locations),404,'disabled preview refuses before validation and SQL');$probe(fn()=>App\Shared\Geography\Segments::page('client',[]),404,'disabled segmentation refuses before SQL');config(['geography.enabled'=>true]);$app->instance('env','production');$probe(fn()=>App\Shared\Geography\Catalogue::guard(),403,'production refuses synthetic geography before SQL');
|
||||
foreach(['geography/company/1'=>null,'geography/unit/1'=>null,'api/geography/catalogue'=>null,'geography/client/1'=>'commercial','api/geography/segments/client'=>'commercial','geography/supplier/1'=>'purchasing','api/geography/entities/supplier/1/confirm'=>'purchasing','api/geography/segments/supplier'=>'purchasing']as$path=>$owner){if(App\Shared\Context\ModuleNavigation::owner($path)!==$owner)throw new RuntimeException('Wrong module owner');$checks[]='correct module owner: '.$path;}file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'database_queries'=>0,'public_rights_changed'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." geography guard checks passed.\n";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Shared\Geography\{Catalogue,Cartography};
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);if(!($s['geography']??false)||is_file($s['root'].'/geography-baseline.json'))throw new LogicException('Fresh geography composition required');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();Catalogue::guard();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$run=getenv('SGF_ISOLATED_REGRESSION_RUN');$private='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private';$proof=json_decode(file_get_contents(dirname(__DIR__).'/docs/proofs/cartography-preparation-20261103.json'),true);$file=$private.'/cartography-prepared-20261103.json';if(hash_file('sha256',$file)!==$proof['prepared_sha256'])throw new LogicException('Prepared catalogue changed');$c=json_decode(file_get_contents($file),true,128,JSON_THROW_ON_ERROR);if($c['source']['sha256']!==config('geography.source_sha256')||$c['total']!==20407)throw new LogicException('Pinned catalogue required');
|
||||
$definition=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;if(!preg_match('/^CHECK\s*\((.*)\)$/s',$definition,$m))throw new LogicException('QA grants constraint missing');$old=$m[1];
|
||||
// Official A schema, with temporary NOT VALID only to preserve this fresh fixture's procurement grants.
|
||||
$migration=file_get_contents($s['app'].'/database/migrations/2026_10_25_030000_create_services_delivery_a.php');$migration=str_replace('AND unit_id IS NOT NULL))");','AND unit_id IS NOT NULL)) NOT VALID");',$migration,$replacements);if($replacements<1)throw new LogicException('Migration adaptation refused');$copy=$s['root'].'/services-qa-schema.php';file_put_contents($copy,$migration);DB::transaction(function()use($copy,$old){(require $copy)->up();$new=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;if(!preg_match('/^CHECK\s*\((.*)\)(?: NOT VALID)?$/s',$new,$m))throw new LogicException('QA A constraint missing');DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$m[1]}) OR ($old))");});
|
||||
foreach(['actor'=>['company.edit'=>null,'unit.edit'=>$f['unit'],'clients.view'=>null,'clients.create'=>null,'clients.edit'=>null],'issuer'=>['clients.view'=>null,'company.edit'=>null]]as$role=>$rights)foreach($rights as$right=>$unit)DB::table('permission_grants')->insertOrIgnore(['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$unit,'user_id'=>$f[$role],'permission'=>$right,'created_at'=>now(),'updated_at'=>now()]);
|
||||
$ctx=app(App\Shared\Context\CompanyContext::class);$ctx->selectFor($f['actor'],$f['tenant'],$f['company'],$f['unit']);auth()->setUser(App\Models\User::findOrFail($f['actor']));request()->attributes->set('sgf_module','commercial');$f['client']=app(App\Modules\Services\ServiceWorkflow::class)->save('clients',null,$f['actor'],['name'=>'Cliente cartografia QA'],(string)Illuminate\Support\Str::uuid())['id'];request()->attributes->remove('sgf_module');
|
||||
$f['pending_client']=DB::table('a_clients')->insertGetId(['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>null,'name'=>'Cliente sem associação de módulo QA','data'=>'{}','version'=>1,'created_at'=>now(),'updated_at'=>now()]);
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE geo_versions(source_version varchar(64) PRIMARY KEY, metadata jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE geo_catalogue_state(id integer PRIMARY KEY CHECK(id=1),source_version varchar(64) NOT NULL REFERENCES geo_versions);
|
||||
CREATE TABLE geo_territories(territory_key varchar(100) PRIMARY KEY,source_version varchar(64) NOT NULL REFERENCES geo_versions,source_id varchar(30) NOT NULL,level smallint NOT NULL CHECK(level BETWEEN 1 AND 6),designation text,parent_key varchar(100),province varchar(20) NOT NULL,district varchar(20) NOT NULL,administrative_post varchar(20) NOT NULL,locality varchar(20) NOT NULL,urban_neighbourhood varchar(20) NOT NULL,village_quarter varchar(20) NOT NULL,issues jsonb NOT NULL,selectable boolean NOT NULL,UNIQUE(source_version,source_id));
|
||||
CREATE INDEX geo_territory_parent ON geo_territories(source_version,parent_key,level,designation,source_id);
|
||||
CREATE TABLE geo_locations(tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_key bigint NOT NULL,module_key varchar(30) NOT NULL,kind varchar(20) NOT NULL,entity_id bigint NOT NULL,provider varchar(64) NOT NULL,revision integer NOT NULL CHECK(revision>0),selection jsonb,actor_id bigint NOT NULL REFERENCES users,reason text NOT NULL,updated_at timestamptz NOT NULL,PRIMARY KEY(tenant_id,company_id,unit_key,module_key,kind,entity_id,provider));
|
||||
CREATE TABLE geo_location_history(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_key bigint NOT NULL,module_key varchar(30) NOT NULL,kind varchar(20) NOT NULL,entity_id bigint NOT NULL,provider varchar(64) NOT NULL,actor_id bigint NOT NULL REFERENCES users,reason text NOT NULL,"before" jsonb NOT NULL,"after" jsonb NOT NULL,created_at timestamptz NOT NULL);
|
||||
CREATE INDEX geo_history_scope ON geo_location_history(tenant_id,company_id,unit_key,module_key,kind,entity_id,provider,id);
|
||||
CREATE TABLE geo_commands(tenant_id bigint NOT NULL,company_id bigint NOT NULL,actor_id bigint NOT NULL REFERENCES users,operation_key uuid NOT NULL,request_hash varchar(64) NOT NULL,result jsonb NOT NULL,created_at timestamptz NOT NULL,PRIMARY KEY(tenant_id,company_id,actor_id,operation_key));
|
||||
SQL);
|
||||
DB::transaction(function()use($c){DB::table('geo_versions')->insert(['source_version'=>$c['source']['sha256'],'metadata'=>json_encode($c['source']+['diagnostics'=>$c['diagnostics']],JSON_THROW_ON_ERROR)]);foreach(array_chunk($c['nodes'],300)as$chunk){$rows=[];foreach($chunk as$n)$rows[]=['territory_key'=>$n['key'],'source_version'=>$c['source']['sha256'],'source_id'=>$n['source_id'],'level'=>$n['level'],'designation'=>$n['designation'],'parent_key'=>count($n['parent_candidates'])===1?$n['parent_candidates'][0]:null,'issues'=>json_encode($n['issues'],JSON_THROW_ON_ERROR),'selectable'=>$n['selectable']]+$n['codes'];DB::table('geo_territories')->insert($rows);}DB::table('geo_catalogue_state')->insert(['id'=>1,'source_version'=>$c['source']['sha256']]);});
|
||||
foreach(['geo_versions','geo_territories','geo_location_history','geo_commands']as$table)DB::statement("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
|
||||
$ctx->clear();file_put_contents($s['root'].'/fixture.json',json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($s['root'].'/geography-baseline.json',json_encode(['passed'=>true,'catalogue_rows'=>DB::table('geo_territories')->count(),'location_assignments'=>0,'source_version'=>$c['source']['sha256'],'synthetic_client'=>$f['client'],'pending_client_unchanged'=>true,'public_rights_changed'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {createServer} from 'vite';
|
||||
const out='docs/proofs/geography-links-20261103.json';assert(!fs.existsSync(out));const server=await createServer({configFile:false,server:{middlewareMode:true},appType:'custom'}),checks=[];const check=(v,n)=>{assert(v,n);checks.push(n);};try{const {default:Link}=await server.ssrLoadModule('/resources/js/GeographyLink.jsx'),render=props=>renderToStaticMarkup(React.createElement(Link,props));for(const kind of ['company','unit','client'])check(render({enabled:true,kind,id:7}).includes('href="/geography/'+kind+'/7"'),kind+' opens the shared selector');check(render({enabled:false,kind:'client',id:7})==='','disabled composition hides location link');check(render({kind:'client',id:7})==='','absent capability hides location link');check(render({enabled:true,kind:'client',id:0})==='','unsaved client has no location target');check(render({enabled:true,kind:'unknown',id:7})==='','unrecognised entity cannot generate a target');check(render({enabled:true,kind:'client',id:1.5})==='','noninteger record ID is refused');fs.writeFileSync(out,JSON.stringify({passed:true,checks:checks.length,names:checks,source:'resources/js/GeographyLink.jsx',sha256:crypto.createHash('sha256').update(fs.readFileSync('resources/js/GeographyLink.jsx')).digest('hex'),visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks:checks.length}));}finally{await server.close();}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
$enter('actor',0,['module'=>'commercial']);$ctx=app(App\Shared\Context\CompanyContext::class);$ctx->selectFor($f['actor'],$f['tenant'],$f['company'],$f['unit']);auth()->setUser(App\Models\User::findOrFail($f['actor']));request()->attributes->set('sgf_module','commercial');$key=DB::table('geo_territories')->where('level',1)->orderBy('designation')->value('territory_key');$version=App\Shared\Geography\Catalogue::active()->source_version;$selection=App\Shared\Geography\Catalogue::selection($version,$key);$scope=['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_key'=>0,'module_key'=>'commercial','kind'=>'client','provider'=>'sgf'];$ids=[];
|
||||
// Prepared reader fixtures only; the41 command checks retain their own immutable proof.
|
||||
for($i=0;$i<26;$i++){$id=app(App\Modules\Services\ServiceWorkflow::class)->save('clients',null,$f['actor'],['name'=>'Cliente QA paginação '.str_pad((string)$i,2,'0',STR_PAD_LEFT)],(string)Illuminate\Support\Str::uuid())['id'];$ids[]=$id;DB::table('geo_locations')->insert($scope+['entity_id'=>$id,'revision'=>1,'selection'=>json_encode($selection,JSON_THROW_ON_ERROR),'actor_id'=>$f['actor'],'reason'=>'Fixture sintética de leitura paginada.','updated_at'=>now()]);}
|
||||
$filter='?province='.urlencode($selection['codes']['province']);$a=$call('actor',$base.'/segments/client'.$filter);$b=$call('actor',$base.'/segments/client'.$filter.'&after='.$a['data']['next_after']);$ok($a['status']===200&&count($a['data']['data'])===25&&$a['data']['next_after']!==null,'segmentation first page contains25 authorised locations');$ok($b['status']===200&&count($b['data']['data'])===2&&$b['data']['next_after']===null,'segmentation cursor reaches final two locations');$one=array_column($a['data']['data'],'entity_id');$two=array_column($b['data']['data'],'entity_id');$ok(count(array_unique([...$one,...$two]))===27&&array_intersect($one,$two)===[],'cursor pages contain no duplicate or omitted visible locations');
|
||||
DB::table('geo_locations')->insert($scope+['entity_id'=>$f['pending_client'],'revision'=>1,'selection'=>json_encode($selection,JSON_THROW_ON_ERROR),'actor_id'=>$f['actor'],'reason'=>'Fixture negativa sem associação de módulo.','updated_at'=>now()]);$a=$call('actor',$base.'/segments/client'.$filter);$b=$call('actor',$base.'/segments/client'.$filter.'&after='.$a['data']['next_after']);$all=[...array_column($a['data']['data'],'entity_id'),...array_column($b['data']['data'],'entity_id')];$ok(count($all)===27&&!in_array($f['pending_client'],$all,true),'prepared metadata cannot bypass an absent client module association');
|
||||
DB::table('geo_locations')->where($scope)->where('entity_id',$f['pending_client'])->delete();$r=$call('actor',$base.'/segments/client?after=-1');$ok($r['status']===422,'invalid segmentation cursor rejected');$ctx->clear();request()->attributes->remove('sgf_module');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'prepared_reader_fixtures'=>26,'actual_command_checks_reused'=>41,'pages'=>[25,2],'unassociated_client_hidden'=>true,'protected_pending_client_location_fixture_removed'=>true,'database_scope'=>'new QA fixture only','visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." pagination boundary checks passed.\n";
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/geography-pagination.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/geography-pagination-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/geography-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator']as$role)$enter($role);$enter('actor',1);$base='/api/geography';
|
||||
require dirname(__DIR__).'/scripts/geography-pagination-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/geography-pagination-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
$host=DB::connection();$url=fn($kind,$id)=>$base.'/entities/'.$kind.'/'.$id;$enter('issuer',1);
|
||||
$nativeFingerprint=fn()=>hash('sha256',json_encode([$db->table('suppliers')->orderBy('id')->get(),$db->table('audit_logs')->orderBy('id')->get(),$db->table('budget_entries')->orderBy('id')->get()]));$nativeBefore=$nativeFingerprint();$associations=$host->table('identity_audit')->where('action','module.record.associated')->count();$pending=(array)$host->table('a_clients')->where('id',$f['pending_client'])->first();
|
||||
$preview=function($role,$kind,$id,$key,$operation=null,$i=0)use($call,$url){$r=$call($role,$url($kind,$id),null,$i);if($r['status']!==200)throw new RuntimeException('Entity state unavailable');return $call($role,$url($kind,$id).'/preview',['operation_key'=>$operation??(string)Illuminate\Support\Str::uuid(),'revision'=>$r['data']['revision'],'catalogue_version'=>$r['data']['catalogue_version'],'territory_key'=>$key,'reason'=>'Indicação territorial explícita, exclusivamente QA.'],$i);};$commit=fn($role,$kind,$id,$cut,$i=0)=>$call($role,$url($kind,$id).'/confirm',['preview'=>$cut],$i);
|
||||
$ok($host->table('geo_territories')->count()===20407&&$host->table('geo_locations')->count()===0,'all source rows imported without business locations');$r=$call('actor',$base.'/catalogue');$ok($r['status']===200&&count($r['data']['records']['data'])===11,'province reference page is accessible');$provinces=$r['data']['records']['data'];$province=$provinces[0]['key'];$otherProvince=$provinces[1]['key'];$version=$r['data']['version'];$r=$call('actor',$base.'/catalogue?parent='.urlencode($province));$ok($r['status']===200&&$r['data']['records']['data'][0]['level']===2,'cascade follows explicit parent');$ok($call('actor',$base.'/catalogue?page=0')['status']===422,'invalid page is rejected');$ok($call('actor',$base.'/catalogue',null,0,['X-SGF-Context'=>'old'])['status']===409,'stale general reference context is refused');
|
||||
$parent=$host->table('geo_territories as t')->join('geo_territories as p','p.territory_key','=','t.parent_key')->where('p.selectable',true)->groupBy('t.parent_key')->havingRaw('count(*)>25')->value('t.parent_key');$a=$call('actor',$base.'/catalogue?parent='.urlencode($parent).'&page=1');$b=$call('actor',$base.'/catalogue?parent='.urlencode($parent).'&page=2');$ok(count($a['data']['records']['data'])===25&&count($b['data']['records']['data'])>0&&array_intersect(array_column($a['data']['records']['data'],'key'),array_column($b['data']['records']['data'],'key'))===[],'server catalogue pages25 are disjoint');$r=$call('actor',$base.'/catalogue?search=QUARTEL%207A');$ok($r['status']===200&&in_array('7A',array_column(array_column($r['data']['records']['data'],'codes'),'village_quarter'),true),'alphanumeric territorial code survives import and search');
|
||||
$ok($call('actor','/geography/company/'.$f['company'])['status']===200,'common location screen served');$ok($call('actor',$url('company',$f['company']+999))['status']===403,'foreign company ID refused');$ok($call('actor',$url('unit',$f['foreign_unit']))['status']===403,'another active unit cannot be inferred');$ok($preview('validator','company',$f['company'],$province)['status']===403,'read permission does not grant editing');$bad=$host->table('geo_territories')->where('selectable',false)->value('territory_key');$ok($preview('actor','company',$f['company'],$bad)['status']===422,'source ambiguities cannot be assigned');
|
||||
$key=(string)Illuminate\Support\Str::uuid();$p=$preview('actor','company',$f['company'],$province,$key);$cut=$p['data']['preview'];$alternate=$preview('actor','company',$f['company'],$otherProvince,$key)['data']['preview'];$ok($p['status']===200&&$p['data']['impact']['before']===null&&$p['data']['impact']['after']['version']===$version&&$host->table('geo_locations')->count()===0,'preview is read only and presents the exact source version');$ok($commit('actor','company',$f['company'],$cut.'bad')['status']===409,'tampered preview refused');
|
||||
$host->unprepared("CREATE FUNCTION public.geo_qa_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'synthetic geographic history failure'; END \$\$; CREATE TRIGGER geo_qa_fail BEFORE INSERT ON geo_location_history FOR EACH ROW EXECUTE FUNCTION public.geo_qa_fail()");try{$ok($commit('actor','company',$f['company'],$cut)['status']===500,'history failure aborts confirmation');$ok($host->table('geo_locations')->count()===0&&$host->table('geo_commands')->count()===0,'history failure rolls back location and receipt');}finally{$host->unprepared('DROP TRIGGER geo_qa_fail ON geo_location_history; DROP FUNCTION public.geo_qa_fail()');}
|
||||
$r=$commit('actor','company',$f['company'],$cut);$ok($r['status']===201&&$r['data']['revision']===1,'same preview recovers after failure removal');$ok($commit('actor','company',$f['company'],$cut)['status']===200&&$host->table('geo_location_history')->count()===1,'replay preserves one location and history entry');$ok($commit('actor','company',$f['company'],$alternate)['status']===409,'same key cannot change territory');
|
||||
$p1=$preview('actor','company',$f['company'],$otherProvince);$p2=$preview('issuer','company',$f['company'],$province,null,1);$responses=GuzzleHttp\Promise\Utils::unwrap([$clients[0]->postAsync($url('company',$f['company']).'/confirm',$options('actor',0,['preview'=>$p1['data']['preview']])),$clients[1]->postAsync($url('company',$f['company']).'/confirm',$options('issuer',1,['preview'=>$p2['data']['preview']]))]);$statuses=array_map(fn($r)=>$r->getStatusCode(),$responses);sort($statuses);$ok($statuses===[201,409]&&$host->table('geo_location_history')->count()===2,'independent editors commit once and refuse stale concurrent version');
|
||||
$p=$preview('actor','unit',$f['unit'],$province);$grant=$host->table('permission_grants')->where($f['scope'])->where('user_id',$f['actor'])->where('permission','unit.edit')->first();$host->table('permission_grants')->where('id',$grant->id)->delete();try{$ok($commit('actor','unit',$f['unit'],$p['data']['preview'])['status']===403,'editing right revalidated at confirmation');}finally{$host->table('permission_grants')->insert((array)$grant);}$ok($commit('actor','unit',$f['unit'],$p['data']['preview'])['status']===201,'unit uses the shared location mechanism');
|
||||
$p=$preview('actor','company',$f['company'],$province);$host->table('companies')->where('id',$f['company'])->update(['name'=>'Empresa QA revista']);$ok($commit('actor','company',$f['company'],$p['data']['preview'])['status']===409,'changed entity invalidates geographic preview');$p=$preview('actor','company',$f['company'],$province);$host->table('geo_versions')->insert(['source_version'=>str_repeat('a',64),'metadata'=>'{}']);$host->table('geo_catalogue_state')->where('id',1)->update(['source_version'=>str_repeat('a',64)]);try{$ok($commit('actor','company',$f['company'],$p['data']['preview'])['status']===409,'changed active reference requires renewed conference');}finally{$host->table('geo_catalogue_state')->where('id',1)->update(['source_version'=>$version]);}
|
||||
$p=$preview('actor','company',$f['company'],$province);$enter('actor',0,['module'=>'commercial']);$ok($commit('actor','company',$f['company'],$p['data']['preview'])['status']===409,'new context generation invalidates general company preview');$ok($call('actor',$url('client',$f['pending_client']))['status']===403,'pending unassociated client cannot be reached');$p=$preview('actor','client',$f['client'],$province);$ok($p['status']===200&&$commit('actor','client',$f['client'],$p['data']['preview'])['status']===201,'associated client receives explicitly selected location');$enter('issuer',0,['module'=>'commercial']);$ok($call('issuer',$url('client',$f['client']))['status']===200&&$preview('issuer','client',$f['client'],$province)['status']===403,'client consultation does not imply editing');$r=$call('actor',$base.'/segments/client?province='.$provinces[0]['codes']['province']);$ok($r['status']===200&&$r['data']['data'][0]['entity_id']===$f['client'],'client segmentation applies explicit territorial filter');$ok(count($call('actor',$base.'/segments/client?province=ZZ')['data']['data'])===0,'absent filter code does not fabricate matches');
|
||||
$enter('actor',0,['module'=>'purchasing']);$p=$preview('actor','supplier',$n['supplier_ids'][0],$province);$ok($p['status']===200&&$commit('actor','supplier',$n['supplier_ids'][0],$p['data']['preview'])['status']===201,'supplier location reuses original administrative authority');$ok($call('validator',$url('supplier',$n['supplier_ids'][0]))['status']===403,'warehouse cannot inspect supplier location');$ok($call('actor',$url('supplier',$n['foreign_supplier']))['status']===404,'foreign native supplier is inaccessible');$r=$call('actor',$base.'/segments/supplier?province='.$provinces[0]['codes']['province']);$ok($r['status']===200&&count($r['data']['data'])===1,'supplier segmentation rechecks native visibility');$enter('actor',1,['foreign'=>1]);$ok($call('actor',$url('supplier',$n['supplier_ids'][0]),null,1)['status']===403,'unmapped unit cannot reuse supplier location');
|
||||
$p=$preview('actor','supplier',$n['supplier_ids'][0],null);$r=$commit('actor','supplier',$n['supplier_ids'][0],$p['data']['preview']);$ok($r['status']===201&&$r['data']['selection']===null,'explicit removal assigns no replacement territory');$h=$call('actor',$url('supplier',$n['supplier_ids'][0]).'/history?page=1');$ok($h['status']===200&&$h['data']['per_page']===15&&$h['data']['total']===2&&$h['data']['data'][0]['before']['selection']!==null&&$h['data']['data'][0]['after']['selection']===null,'paged history preserves before after author reason and date');
|
||||
foreach(['geo_location_history','geo_territories']as$table){try{$host->table($table)->delete();throw new RuntimeException('Immutable data deleted');}catch(Illuminate\Database\QueryException $e){$ok(str_contains($e->getMessage(),'APPEND_ONLY'),$table.' refuses deletion');}}
|
||||
$ok((array)$host->table('a_clients')->where('id',$f['pending_client'])->first()===$pending&&$host->table('identity_audit')->where('action','module.record.associated')->count()===$associations,'pending client and module associations are unchanged');$ok($nativeFingerprint()===$nativeBefore,'native supplier addresses review and financial evidence are unchanged');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'concurrent_edit_statuses'=>$statuses,'catalogue_rows'=>20407,'native_before'=>$nativeBefore,'native_after'=>$nativeFingerprint(),'purpose'=>'segmentation_only','pending_client_unchanged'=>true,'module_associations_unchanged'=>true,'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." geographic integration checks passed.\n";
|
||||
@@ -0,0 +1,36 @@
|
||||
param([Parameter(Mandatory=$true)][string]$Source,[Parameter(Mandatory=$true)][string]$Output)
|
||||
$ErrorActionPreference='Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$absolute=(Resolve-Path -LiteralPath $Source).Path
|
||||
$before=(Get-FileHash -LiteralPath $absolute -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$zip=[IO.Compression.ZipFile]::OpenRead($absolute)
|
||||
function Read-XmlEntry([string]$Name){
|
||||
$entry=$zip.GetEntry($Name);if($null -eq $entry){throw "Missing workbook part: $Name"}
|
||||
$stream=$entry.Open();$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null
|
||||
$reader=[Xml.XmlReader]::Create($stream,$settings)
|
||||
try{$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader);return ,$doc}finally{$reader.Dispose();$stream.Dispose()}
|
||||
}
|
||||
try{
|
||||
$book=Read-XmlEntry 'xl/workbook.xml';$shared=Read-XmlEntry 'xl/sharedStrings.xml'
|
||||
$ns=[Xml.XmlNamespaceManager]::new($shared.NameTable);$ns.AddNamespace('m','http://schemas.openxmlformats.org/spreadsheetml/2006/main')
|
||||
$strings=@($shared.SelectNodes('/m:sst/m:si',$ns)|ForEach-Object {($_.SelectNodes('.//m:t',$ns)|ForEach-Object {$_.InnerText}) -join ''})
|
||||
$sheet=Read-XmlEntry 'xl/worksheets/sheet1.xml';$sn=[Xml.XmlNamespaceManager]::new($sheet.NameTable);$sn.AddNamespace('m','http://schemas.openxmlformats.org/spreadsheetml/2006/main')
|
||||
$rows=[Collections.Generic.List[object]]::new();$formulas=0;$hidden=0
|
||||
foreach($row in $sheet.SelectNodes('/m:worksheet/m:sheetData/m:row',$sn)){
|
||||
if($row.GetAttribute('hidden') -eq '1'){$hidden++}
|
||||
$cells=[ordered]@{}
|
||||
foreach($cell in $row.SelectNodes('m:c',$sn)){
|
||||
$key=$cell.GetAttribute('r') -replace '[0-9]','';$valueNode=$cell.SelectSingleNode('m:v',$sn);$value=if($valueNode){$valueNode.InnerText}else{''};$type=$cell.GetAttribute('t')
|
||||
if($type -eq 's'){$value=$strings[[int]$value]}elseif($type -eq 'inlineStr'){$value=($cell.SelectNodes('.//m:t',$sn)|ForEach-Object {$_.InnerText}) -join ''}
|
||||
if($cell.SelectSingleNode('m:f',$sn)){$formulas++}
|
||||
if($value -ne ''){$cells[$key]=$value}
|
||||
}
|
||||
if($cells.Count -gt 0){$rows.Add([ordered]@{row=[int]$row.GetAttribute('r');cells=$cells})}
|
||||
}
|
||||
$sheets=@($book.SelectNodes('//*[local-name()="sheet"]')|ForEach-Object {$_.GetAttribute('name')})
|
||||
$result=[ordered]@{source_name=[IO.Path]::GetFileName($absolute);sha256=$before;sheets=$sheets;dimension=$sheet.SelectSingleNode('/m:worksheet/m:dimension',$sn).GetAttribute('ref');populated_rows=$rows.Count;formula_cells=$formulas;hidden_rows=$hidden;external_links=@($zip.Entries|Where-Object {$_.FullName -like 'xl/externalLinks/*'}).Count;rows=$rows.ToArray()}
|
||||
$json=$result|ConvertTo-Json -Depth 12
|
||||
[IO.File]::WriteAllText($Output,$json,[Text.UTF8Encoding]::new($false))
|
||||
[ordered]@{sha256=$before;sheets=$sheets;dimension=$result.dimension;populated_rows=$rows.Count;formula_cells=$formulas;hidden_rows=$hidden;sample=@($rows|Select-Object -First 9)}|ConvertTo-Json -Depth 8
|
||||
}finally{$zip.Dispose()}
|
||||
if((Get-FileHash -LiteralPath $absolute -Algorithm SHA256).Hash.ToLowerInvariant() -ne $before){throw 'Source changed during read'}
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import {validateOrigins} from './audit-module-matrix.mjs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
@@ -8,14 +9,14 @@ const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const roots={sgf:root,compras:path.resolve(root,'../compras')};
|
||||
const planPath=path.join(root,'docs/integration/sgf-flow-plan.json');
|
||||
const digest=b=>crypto.createHash('sha256').update(b).digest('hex');
|
||||
const planBytes=fs.readFileSync(planPath), plan=JSON.parse(planBytes);
|
||||
const planBytes=fs.readFileSync(planPath), plan=JSON.parse(planBytes);validateOrigins(plan);
|
||||
const requireValue=(condition,message)=>{if(!condition)throw Error(message);};
|
||||
requireValue(plan.schema_version===1&&plan.status==='preparation_only','Unsupported plan');
|
||||
const modules=new Set(plan.modules), sourceIds=new Set(), stages=new Map();
|
||||
requireValue(modules.size===6,'Expected the six existing modules');
|
||||
const sources=plan.sources.map(s=>{
|
||||
requireValue(!sourceIds.has(s.id),'Duplicate source');sourceIds.add(s.id);
|
||||
requireValue(roots[s.repository]&&/^(app|packages|resources|docs)\//.test(s.path)&&/\.(php|js|md)$/.test(s.path),'Unexpected source path');
|
||||
requireValue(roots[s.repository]&&/^(app|packages|resources|docs|integrations|public)\//.test(s.path)&&/\.(php|js|jsx|mjs|md)$/.test(s.path),'Unexpected source path');
|
||||
const base=fs.realpathSync(roots[s.repository]), file=fs.realpathSync(path.resolve(base,s.path));
|
||||
requireValue(file.startsWith(base+path.sep)&&!s.path.split('/').includes('..'),'Source escapes repository');
|
||||
return {...s,sha256:digest(fs.readFileSync(file)),meaning:'source_present_only_not_operational_proof'};
|
||||
@@ -42,5 +43,9 @@ const report={id:plan.id,date:plan.date,kind:'static_preparation_inventory',plan
|
||||
limitations:['Source existence is not execution, parity, acceptance or deployment evidence.','Dependencies represent the selected synthetic path, not universal product eligibility rules.','No stage transition engine, inventory adapter or journal engine implemented by this inventory.'],
|
||||
preservation:{database_connections:0,source_execution:false,application_routes_changed:false,rights_changed:false,protected_six_records_untouched:true,blocked_cache_untouched:true,
|
||||
demos_changed:false,purchasing_activated:false,publication:false,checkpoint:false},visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync(path.join(root,'docs/proofs/module-flow-preparation-20261102.json'),JSON.stringify(report,null,2)+'\n');
|
||||
const outputName=process.argv[2]??'module-flow-preparation-20261102.json';
|
||||
requireValue(/^[a-z0-9-]+\.json$/.test(outputName),'Proof filename required');
|
||||
const output=path.join(root,'docs/proofs',outputName);
|
||||
requireValue(!fs.existsSync(output),'Completed proof preserved; choose a new proof filename');
|
||||
fs.writeFileSync(output,JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({modules:modules.size,stages:stages.size,sources:sources.length,mandatory_cycles:0,kind:report.kind,database_connections:0,operational_integration_validated:false},null,2));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
$id=$db->table('purchase_orders')->where('tenant_id',$n['tenant'])->value('id');$invoice=$db->table('invoices')->where('status','validated')->orderBy('id')->value('id');$url=$base.'/orders/'.$id;$before=hash('sha256',json_encode([$db->table('invoices')->orderBy('id')->get(),$db->table('budget_consumptions')->orderBy('id')->get(),$db->table('audit_logs')->orderBy('id')->get()]));$preview=function()use($call,$url,$invoice){$state=$call('reviewer',$url);$p=$call('reviewer',$url.'/preview',['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$state['data']['revision'],'action'=>'validate','reason'=>'Sonda de fronteira sem repetir a decisão validada.','data'=>['invoice_id'=>$invoice]]);if($p['status']!==200)throw new RuntimeException('Original idempotent validation preview unavailable');return $p['data']['preview'];};$confirm=fn($cut)=>$call('reviewer',$url.'/confirm',['preview'=>$cut]);
|
||||
$cut=$preview();$enter('reviewer');$ok($confirm($cut)['status']===409,'new session context generation invalidates invoice preview');$cut=$preview();$version=$db->table('suppliers')->where('id',$n['supplier_ids'][0])->value('version');$db->table('suppliers')->where('id',$n['supplier_ids'][0])->update(['version'=>$version+1]);try{$ok($confirm($cut)['status']===409,'supplier source version change requires new matching preview');}finally{$db->table('suppliers')->where('id',$n['supplier_ids'][0])->update(['version'=>$version]);}
|
||||
$cut=$preview();$file=$s['root'].'/binding.json';$bytes=file_get_contents($file);$profile=json_decode($bytes,true);$key=implode(':',array_values($f['scope'])).':'.$f['reviewer'];$profile['bindings'][$key]['warehouse_ids'][]=999999;file_put_contents($file,json_encode($profile));try{$ok($confirm($cut)['status']===409,'changed source binding invalidates invoice preview');}finally{file_put_contents($file,$bytes);}
|
||||
$after=hash('sha256',json_encode([$db->table('invoices')->orderBy('id')->get(),$db->table('budget_consumptions')->orderBy('id')->get(),$db->table('audit_logs')->orderBy('id')->get()]));$ok($before===$after,'boundary probes persist no revalidation audit consumption or invoice change');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'before'=>$before,'after'=>$after,'valid45_checks_not_repeated'=>true,'probe'=>'rollback-only original idempotent validation preview and rejected confirmations'],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." invoice boundary checks passed.\n";
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/invoice-boundaries.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/invoice-boundary-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/invoice-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator','reviewer','auditor']as$role)$enter($role);$enter('issuer',1);$enter('auditor',1);$base='/api/purchasing-invoices';
|
||||
require dirname(__DIR__).'/scripts/invoice-boundary-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/invoice-boundary-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/invoice-result.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/invoice-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/invoice-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator','reviewer','auditor']as$role)$enter($role);$enter('issuer',1);$enter('auditor',1);$base='/api/purchasing-invoices';
|
||||
require dirname(__DIR__).'/scripts/invoice-proof-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/invoice-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const tag=process.argv[2];assert(/^[a-f0-9]{12}$/.test(tag??''));const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),root=path.join(run.root,'private/receipt-composition-'+tag),s=JSON.parse(fs.readFileSync(path.join(root,'state.json'))),out=path.join(root,'invoice-demo-assets.json');assert(s.invoices&&!fs.existsSync(out));if(!fs.existsSync(path.join(root,'invoice-build.log'))){const result=spawnSync(process.execPath,['node_modules/vite/bin/vite.js','build','--outDir',path.join(s.app,'public/build')],{cwd:process.cwd(),encoding:'utf8',windowsHide:true,timeout:180000});fs.writeFileSync(path.join(root,'invoice-build.log'),result.stdout+'\n'+result.stderr);assert.equal(result.status,0,'private build');}else assert(fs.readFileSync(path.join(root,'invoice-build.log'),'utf8').includes('built in'),'prior build succeeded');const manifest=JSON.parse(fs.readFileSync(path.join(s.app,'public/build/manifest.json'))),files=Object.values(manifest).flatMap(v=>[v.file,...(v.css??[])]),checks=['private frontend build succeeds'];for(const file of files){const bytes=fs.readFileSync(path.join(s.app,'public/build',file)),r=await fetch('http://127.0.0.1:8070/build/'+file);assert.equal(r.status,200);assert.equal(crypto.createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex'),crypto.createHash('sha256').update(bytes).digest('hex'));}checks.push('served built assets match private build');const r=await fetch('http://127.0.0.1:8070/supplier-invoice-workspace.mjs');assert.equal(r.status,200);checks.push('invoice module is served through private static whitelist');fs.writeFileSync(out,JSON.stringify({passed:true,checks,assets:files,visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length}));
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/invoice-demo-smoke.json';if(is_file($out))throw new LogicException('Proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.invoice_demo_verify'=>$s['nativeConnection']]);$db=DB::connection('invoice_demo_verify');$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};$fp=fn()=>hash('sha256',json_encode([$db->table('purchase_orders')->orderBy('id')->get(),$db->table('goods_receipts')->orderBy('id')->get(),$db->table('invoices')->orderBy('id')->get(),$db->table('budget_entries')->orderBy('id')->get(),$db->table('budget_consumptions')->get(),$db->table('audit_logs')->orderBy('id')->get()]));$before=$fp();$ok($db->table('purchase_orders')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('goods_receipts')->count()===0&&$db->table('invoices')->count()===0&&$db->table('budget_consumptions')->count()===0,'main demo starts before order receipt invoice or consumption');$client=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:8070','http_errors'=>false,'timeout'=>25]);
|
||||
foreach(['issuer','reviewer','auditor','actor','validator']as$role){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$client->get('/qa/enter/'.$role,['cookies'=>$jar]);$v=json_decode((string)$r->getBody(),true);$o=['cookies'=>$jar,'headers'=>['Accept'=>'application/json','X-SGF-Context'=>$v['context']]];$r=$client->get('/api/purchasing-invoices',$o);$d=json_decode((string)$r->getBody(),true);$ok(in_array($role,['actor','validator'],true)?$r->getStatusCode()===403:($r->getStatusCode()===200&&$d['data']===[]),'invoice access and empty order list match '.$role);if($role==='issuer'){$r=$client->get('/purchasing/invoices',$o);$ok($r->getStatusCode()===200&&str_contains((string)$r->getBody(),'supplier-invoice-workspace.mjs'),'invoice workspace is live');$r=$client->get('/api/session',$o);$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$d['context']['purchasing']['supplier_invoices']===true,'session exposes explicit invoice composition capability');$r=$client->get('/api/purchasing-invoices/orders/'.$n['foreign_order'],$o);$ok($r->getStatusCode()===403,'foreign order stays hidden in live demo');}if($role==='actor'){$r=$client->get('/api/purchasing-orders',$o);$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&count($d['data'])===1,'buyer starts with one approved award and no issued main order');}if($role==='validator'){$r=$client->get('/api/purchasing-receipts',$o);$d=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$d['data']===[],'warehouse waits for actual main order');}}
|
||||
$r=$client->get('/supplier-invoice-workspace.mjs');$ok($r->getStatusCode()===200&&hash('sha256',(string)$r->getBody())===hash_file('sha256',$s['app'].'/public/supplier-invoice-workspace.mjs'),'served invoice script matches delivery');$ok($fp()===$before,'demo smoke leaves business and budget evidence unchanged');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'before'=>$before,'after'=>$fp(),'main_orders'=>0,'invoices'=>0,'consumptions'=>0,'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." invoice demo checks passed.\n";
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/invoice-guards.json';if(is_file($out))throw new LogicException('Proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$queries=0;$checks=[];DB::listen(function()use(&$queries){$queries++;});$probe=function($fn,$status,$name)use(&$checks,&$queries){$before=$queries;try{$fn();throw new RuntimeException('Guard unexpectedly accepted');}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==$status||$queries!==$before)throw new RuntimeException($name);$checks[]=$name;}};config(['receipt_qa.invoices_enabled'=>false]);$rt=new App\Modules\Purchasing\ReceiptRuntime;$probe(fn()=>$rt->run('purchasing_invoices.prepare',fn()=>true),404,'disabled invoice runtime refuses before SQL');$probe(fn()=>(new App\Modules\Purchasing\InvoiceController)->preview(1,new Illuminate\Http\Request,$rt),404,'disabled invoice preview refuses before validation and SQL');config(['receipt_qa.invoices_enabled'=>true]);$app->instance('env','production');$probe(fn()=>$rt->run('purchasing_invoices.prepare',fn()=>true),403,'production refuses synthetic invoice workflow');foreach(['purchasing/invoices','api/purchasing-invoices','api/purchasing-invoices/orders/1/confirm']as$path){if(App\Shared\Context\ModuleNavigation::owner($path)!=='purchasing')throw new RuntimeException('Wrong module owner');$checks[]='invoice route remains in purchasing: '.$path;}file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'database_queries'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." invoice guard checks passed.\n";
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {createServer} from 'vite';
|
||||
const out='docs/proofs/invoice-home-20261103.json';assert(!fs.existsSync(out));const server=await createServer({configFile:false,server:{middlewareMode:true},appType:'custom'}),checks=[];const check=(v,n)=>{assert(v,n);checks.push(n);};try{const {default:Home}=await server.ssrLoadModule('/resources/js/PurchasingHome.jsx'),ctx={company:{name:'QA'},unit:{id:7},purchasing:{supplier_invoices:true,orders:true,receipts:true}},render=(context,rights)=>renderToStaticMarkup(React.createElement(Home,{context,can:(p,u)=>rights.includes(p)&&u===7,navigate(){}}));check(render(ctx,['purchasing_invoices.view']).includes('href="/purchasing/invoices"'),'invoice view and capability expose link');check(!render(ctx,[]).includes('href="/purchasing/invoices"'),'missing right hides link');check(!render({...ctx,unit:{id:8}},['purchasing_invoices.view']).includes('href="/purchasing/invoices"'),'another unit right cannot expose link');check(!render({...ctx,purchasing:{supplier_invoices:false}},['purchasing_invoices.view']).includes('href="/purchasing/invoices"'),'disabled composition hides link');check(!render({...ctx,unit:null},['purchasing_invoices.view']).includes('href="/purchasing/invoices"'),'unit selection required');const old=render(ctx,['purchasing_orders.view','purchasing_receipts.view']);check(old.includes('href="/purchasing/orders"')&&old.includes('href="/purchasing/receipts"'),'existing order and receipt navigation remains available');fs.writeFileSync(out,JSON.stringify({passed:true,checks:checks.length,names:checks,source:'resources/js/PurchasingHome.jsx',sha256:crypto.createHash('sha256').update(fs.readFileSync('resources/js/PurchasingHome.jsx')).digest('hex'),visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks:checks.length}));}finally{await server.close();}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);if(!($s['invoices']??false)||is_file($s['root'].'/invoice-baseline.json'))throw new LogicException('Fresh invoice composition required');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();App\Modules\Purchasing\BudgetAdapter::guard();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);if(!$n['finance_reviewer']||!$n['finance_auditor'])throw new LogicException('Distinct native finance identities required');$old=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;if(!preg_match('/^CHECK\s*\((.*)\)$/s',$old,$m))throw new LogicException('QA constraint required');DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$m[1]}) OR (unit_id IS NOT NULL AND permission IN ('purchasing_invoices.view','purchasing_invoices.prepare','purchasing_invoices.validate')))");
|
||||
foreach(['reviewer','auditor']as$role){$user=App\Models\User::create(['name'=>'QA invoice '.$role,'email'=>$role.'-'.Illuminate\Support\Str::uuid().'@example.invalid','password'=>Illuminate\Support\Str::random(40)]);$f[$role]=$user->id;DB::table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company']]);foreach(['company.view'=>null,'unit.view'=>$f['unit']]as$right=>$unit)DB::table('permission_grants')->insert(['user_id'=>$user->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$unit,'permission'=>$right,'created_at'=>now(),'updated_at'=>now()]);}
|
||||
foreach(['issuer'=>['view','prepare','validate'],'reviewer'=>['view','validate'],'auditor'=>['view','validate']]as$role=>$rights)foreach($rights as$r)DB::table('permission_grants')->insert($f['scope']+['user_id'=>$f[$role],'permission'=>'purchasing_invoices.'.$r,'created_at'=>now(),'updated_at'=>now()]);$file=$s['root'].'/binding.json';$p=json_decode(file_get_contents($file),true);$prefix=implode(':',array_values($f['scope'])).':';$base=$p['bindings'][$prefix.$f['issuer']];foreach(['issuer'=>'finance','reviewer'=>'finance_reviewer','auditor'=>'finance_auditor']as$role=>$native){$p['bindings'][$prefix.$f[$role]]=$base;$p['bindings'][$prefix.$f[$role]]['actor_id']=$n[$native];}$p['internal_manifest_sha256']=hash_file('sha256',$s['root'].'/internal/manifest.json');file_put_contents($file,json_encode($p,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($s['root'].'/fixture.json',json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($s['root'].'/invoice-baseline.json',json_encode(['passed'=>true,'approved_award'=>!($s['integrated']??false),'initial_orders'=>0,'initial_receipts'=>0,'initial_invoices'=>0,'initial_consumptions'=>0,'native_main_reserved_minor'=>10000,'native_main_committed_minor'=>0,'qa_rights'=>['purchasing_invoices.view','purchasing_invoices.prepare','purchasing_invoices.validate']],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
use App\Models\{PurchaseAward,User};
|
||||
use App\Services\PurchaseWorkflow;
|
||||
if(!($s['invoices']??false)||is_file($s['root'].'/invoice-negative-prepared.json'))throw new LogicException('Fresh invoice negative fixture required');$f=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);$award=PurchaseAward::withoutGlobalScopes()->findOrFail($f['foreign_award']);$user=User::findOrFail($award->created_by);auth()->setUser($user);$order=app(PurchaseWorkflow::class)->issue($user,$award);$f['foreign_order']=$order->id;$f['foreign_order_line']=$order->items()->firstOrFail()->id;file_put_contents($s['root'].'/native-fixture.json',json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($s['root'].'/invoice-negative-prepared.json',json_encode(['passed'=>true,'foreign_order_prepared'=>true,'main_book_changed'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);$out=$s['root'].'/invoice-progress-demo.json';if(!($s['invoiceProgress']??false)||is_file($out))throw new LogicException('New matching demonstration required');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.matching_demo'=>$s['nativeConnection']]);$db=DB::connection('matching_demo');$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};$fingerprint=fn()=>hash('sha256',json_encode([$db->table('requisitions')->orderBy('id')->get(),$db->table('invoices')->orderBy('id')->get(),$db->table('audit_logs')->orderBy('id')->get(),DB::table('permission_grants')->orderBy('id')->get()]));$before=$fingerprint();$ok($db->table('requisitions')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('invoices')->where('tenant_id',$n['tenant'])->count()===0,'new demo contains no main requests or invoices');$client=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:8081','http_errors'=>false,'timeout'=>25]);$jar=new GuzzleHttp\Cookie\CookieJar;$r=$client->get('/qa/enter/issuer',['cookies'=>$jar]);$v=json_decode((string)$r->getBody(),true);$options=['cookies'=>$jar,'headers'=>['Accept'=>'application/json','X-SGF-Context'=>$v['context']]];$r=$client->get('/purchasing/invoices',$options);$ok($r->getStatusCode()===200,'native-authorised finance workspace remains available');$r=$client->get('/api/purchasing-invoices',$options);$data=json_decode((string)$r->getBody(),true);$ok($r->getStatusCode()===200&&$data['data']===[],'empty invoice workspace invents no matching source');foreach(['supplier-invoice-workspace.mjs','invoice-matching.mjs']as$file){$r=$client->get('/'.$file);$ok($r->getStatusCode()===200&&hash('sha256',(string)$r->getBody())===hash_file('sha256',$s['app'].'/public/'.$file),'served matching module matches '.$file);}$ok($fingerprint()===$before,'demo probes preserve request invoice audit and permission data');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'before'=>$before,'after'=>$fingerprint(),'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." matching demo checks passed.\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
$fingerprint=function($connection){$result=[];foreach($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename")as$table){if(!preg_match('/^[a-z0-9_]+$/D',$table->tablename))throw new LogicException('Unexpected fixture table');$name=$table->tablename;$row=$connection->selectOne('SELECT count(*) n,md5(coalesce(string_agg(row_to_json(t)::text,\'\' ORDER BY row_to_json(t)::text),\'\')) h FROM "'.$name.'" t');$result[$name]=[(int)$row->n,$row->h];}$result['sequences']=$connection->select("SELECT sequencename,last_value FROM pg_sequences WHERE schemaname='public' ORDER BY sequencename");return $result;};
|
||||
$before=['host'=>$fingerprint(DB::connection()),'native'=>$fingerprint($db)];$id=(int)$db->table('purchase_orders')->where('tenant_id',$n['tenant'])->orderBy('id')->value('id');if(!$id)throw new LogicException('Completed source order required');$url='/api/purchasing-invoices/orders/'.$id;$r=$call('issuer',$url);$view=$r['data'];$ok($r['status']===200&&count($view['matching_progress'])===1,'authorised order exposes one matching projection per original line');$row=$view['matching_progress'][0];$ok($row['ordered_milli']===1000&&$row['accepted_milli']===1000&&$row['returned_milli']===0&&$row['reversed_milli']===0,'native source order and accepted physical quantity are preserved');
|
||||
if($s['progress_variant']==='available'){$ok($row['validated_milli']===0&&$row['pending_milli']===0&&$row['remaining_milli']===1000,'accepted unbilled1UN is available without inventing invoice or reservation');}else{$ok($row['validated_milli']===1000&&$row['pending_milli']===700&&$row['remaining_milli']===0,'validated40+60 exhaust1UN while incompatible pending70 remains separately visible');$pending=array_values(array_filter($view['invoices'],fn($v)=>$v['status']==='pending'));$ok(count($pending)===1&&$pending[0]['items'][0]['quantity_milli']===700,'original pending document is neither hidden rewritten nor deducted as a reservation');$r=$call('reviewer',$url);$pending=array_values(array_filter($r['data']['invoices'],fn($v)=>$v['status']==='pending'));$ok($r['status']===200&&$pending[0]['can_validate']===true,'read discrepancy does not replace original independent-review permission decision');}
|
||||
$ok($call('actor',$url)['status']===403&&$call('validator',$url)['status']===403,'procurement and warehouse cannot read economic matching projection');$foreign=$db->table('purchase_orders')->where('tenant_id','<>',$n['tenant'])->value('id')??999999;$ok($call('issuer','/api/purchasing-invoices/orders/'.$foreign)['status']===403,'foreign or unknown order remains outside authorised origin');$enter('issuer',1,['foreign'=>1]);$ok($call('issuer',$url,null,1)['status']===403,'unmapped unit cannot reuse matching projection');$ok($call('issuer',$url,null,0,['X-SGF-Context'=>'old'])['status']===409,'obsolete context header cannot read matching projection');
|
||||
$user=App\Models\User::findOrFail($f['issuer']);auth()->setUser($user);request()->setUserResolver(fn()=>$user);app(App\Shared\Context\CompanyContext::class)->selectFor($user->id,$f['tenant'],$f['company'],$f['unit']);$expected=(new App\Modules\Purchasing\ReceiptRuntime)->run('purchasing_invoices.view',function()use($id){$controller=new App\Modules\Purchasing\InvoiceController;$method=new ReflectionMethod($controller,'state');$state=$method->invoke($controller,Tempo\Purchasing\Embedded\Models\PurchaseOrder::findOrFail($id));return hash('sha256',Tempo\Finance\Payables\Payables::canonical([$state]));});$ok($expected===$view['revision'],'single observed state yields same original source revision before UI capability decoration');config(['receipt_qa.invoice_progress_enabled'=>false]);$legacy=(new App\Modules\Purchasing\InvoiceController)->show($id,new App\Modules\Purchasing\ReceiptRuntime)->getData(true);$ok($legacy['matching_progress']===null&&$legacy['revision']===$expected,'disabled projection preserves original revision and invoice display');config(['receipt_qa.invoice_progress_enabled'=>true]);$projection=new App\Modules\Purchasing\InvoiceMatchingProgress;$p=$projection->quantities(1000,1000,0,0,400,700);$ok($p['remaining_milli']===600&&$p['pending_milli']===700,'pending70 does not reserve or consume remaining60 after validated40');$p=$projection->quantities(1000,1200,0,0,400,0);$ok($p['remaining_milli']===600,'remaining quantity cannot exceed frozen order even with extra accepted input');$p=$projection->quantities(1000,1000,200,100,400,0);$ok($p['accepted_net_milli']===700&&$p['remaining_milli']===300,'existing return and reversal facts reduce accepted matching capacity');$p=$projection->quantities(1000,1000,200,100,800,0);$ok($p['remaining_milli']===0&&$p['deficit_milli']===100,'read deficit is explicit and remaining quantity is not negative');
|
||||
$after=['host'=>$fingerprint(DB::connection()),'native'=>$fingerprint($db)];$ok(json_encode($before)===json_encode($after),'all reused source tables and sequences unchanged by read projection');file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'variant'=>$s['progress_variant'],'order_id'=>$id,'quantities'=>$row,'financial_commands_repeated'=>0,'before_sha256'=>hash('sha256',json_encode($before)),'after_sha256'=>hash('sha256',json_encode($after)),'tables_checked'=>count($before['host'])+count($before['native'])-2,'new_right_names'=>[],'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." invoice progress read checks passed.\n";
|
||||
@@ -0,0 +1,8 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const variant=process.argv[2];const cases={available:{tag:'ce4c9bef1a51',proof:'receipt-ui-http.json'},partial:{tag:'da77bb730751',proof:'invoice-result.json'}};if(!cases[variant])throw Error('Explicit completed invoice progress book required');const selected=cases[variant];const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),tag=crypto.randomBytes(6).toString('hex'),sourceRoot=path.join(run.root,'private/receipt-composition-'+selected.tag),source=JSON.parse(fs.readFileSync(path.join(sourceRoot,'state.json'))),root=path.join(run.root,'private/invoice-progress-read-'+tag),app=path.join(root,'app');
|
||||
if(!JSON.parse(fs.readFileSync(path.join(sourceRoot,selected.proof))).passed||source.env.DB_DATABASE!=='budget_core_sgf_'+selected.tag||source.env.DB_PORT!=='55439'||source.env.SGF_ISOLATED_REGRESSION_RUN!==run.id)throw Error('Owned completed regression book required');
|
||||
fs.mkdirSync(root);for(const dir of ['app','bootstrap','config','routes','resources','database','public'])fs.cpSync(dir,path.join(app,dir),{recursive:true,filter:p=>!p.replaceAll('\\','/').includes('bootstrap/cache')});for(const pkg of ['budget-control','payables','general-ledger','module-updates','purchasing'])fs.cpSync(path.join('packages',pkg),path.join(app,'packages',pkg),{recursive:true});fs.symlinkSync(path.resolve('vendor'),path.join(app,'vendor'),'junction');fs.copyFileSync('composer.json',path.join(app,'composer.json'));for(const dir of ['bootstrap/cache','storage/logs','storage/framework/cache/data','storage/framework/views','storage/framework/sessions'])fs.mkdirSync(path.join(app,dir),{recursive:true});
|
||||
// Existing bindings, native manifests and business facts remain in place; only application/session files are new.
|
||||
for(const file of ['fixture.json','native-fixture.json'])fs.copyFileSync(path.join(sourceRoot,file),path.join(root,file));
|
||||
const env={...source.env,APP_BASE_PATH:app,SGF_DOSSIERS_QA_ENABLED:'true',SGF_PURCHASING_FOCUS_QA_ENABLED:'true',SGF_PURCHASING_CONTINUITY_QA_ENABLED:'true',SGF_INVOICE_PROGRESS_QA_ENABLED:'true',SESSION_COOKIE:'sgf_dossier_read_'+tag,RECEIPT_COMPOSITION_STATE:path.join(root,'state.json')};const state={...source,root,app,env,invoiceProgress:true,progress_variant:variant,focus:true,dossiers:true,read_overlay:true,original_root:sourceRoot};fs.writeFileSync(path.join(root,'state.json'),JSON.stringify(state,null,2));
|
||||
const result=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',[path.resolve('scripts/invoice-progress-read-proof.php')],{cwd:app,env,encoding:'utf8',windowsHide:true,timeout:180000});fs.writeFileSync(path.join(root,'read-proof.log'),result.stdout+'\n'+result.stderr);if(result.status!==0||!fs.existsSync(path.join(root,'invoice-progress-read-result.json')))throw Error('Dossier read proof incomplete; private diagnostics retained '+tag);console.log(JSON.stringify({tag,root,result:result.stdout.trim()}));
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/invoice-progress-read-result.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/invoice-progress-read-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
|
||||
try{
|
||||
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
|
||||
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/invoice-progress-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator','reviewer','auditor']as$role)$enter($role);$enter('issuer',1);$enter('auditor',1);$base='/api/purchasing-invoices';
|
||||
require dirname(__DIR__).'/scripts/invoice-progress-read-body.php';
|
||||
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/invoice-progress-http-'.$i.'.log',$server->getOutput().$server->getErrorOutput());}}
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import {quantity,appendInvoiceMatching,pendingQuantityWarnings} from '../public/invoice-matching.mjs';
|
||||
const tag=process.argv[2];assert(/^[a-f0-9]{12}$/.test(tag??''));const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),root=path.join(run.root,'private/invoice-progress-read-'+tag),out=path.join(root,'invoice-progress-ui.json');assert(!fs.existsSync(out));const checks=[],ok=(v,name)=>{assert(v,name);checks.push(name);};
|
||||
class Element{constructor(tag,text){this.tag=tag;this.textContent=text??'';this.value='';this.checked=false;this.children=[];this.dataset={focusEnabled:'true',context:'qa-context',csrf:'qa-csrf'};this.isConnected=true;}append(...nodes){this.children.push(...nodes);}prepend(...nodes){this.children.unshift(...nodes);}replaceChildren(...nodes){this.children=[...nodes];}setAttribute(k,v){this[k]=v;}dispatchEvent(){} }
|
||||
globalThis.Option=class extends Element{constructor(text,value){super('option',text);this.value=value;}};globalThis.FormData=class{*[Symbol.iterator](){} };const walk=e=>[e,...e.children.flatMap(walk)],paginator=data=>({data,current_page:1,last_page:1,per_page:25,total:data.length}),rev='a'.repeat(64),calls=[];let elements=new Map,mismatchReceipt=false;
|
||||
globalThis.document={createElement:tag=>new Element(tag),querySelector:selector=>{if(!elements.has(selector))elements.set(selector,new Element(selector));return elements.get(selector);}};
|
||||
const request={id:3,number:'REQ-EXACT',status:'approved',items:[],steps:[],total_minor:10000,process_version:3};const invoice={id:77,number:'INV-EXACT',status:'validated',issued_on:'2026-11-03',total_minor:10000,created_by:1,validated_by:2,items:[],purchase_order_id:41};
|
||||
const responses={
|
||||
'/api/purchasing-requests':{new_revision:rev,centres:[],can_prepare:false,requests:paginator([])},'/api/purchasing-requests/3':{request,capabilities:{},history:[],revision:rev},
|
||||
'/api/purchasing-sourcing/requests/3':{request,capabilities:{},comparison:{policy:{minimum_proposals:3,minimum_evaluators:2},ready:true,rows:[],top_ids:[]},quotes:[],award:{id:2,status:'approved',total_minor:10000,approvals:[]},history:[]},
|
||||
'/api/purchasing-orders/awards/2':{basis:{award:{id:2,status:'approved',approvals:[],total_minor:10000},quote:{supplier:{name:'QA'},items:[],currency:'MZN'}},existing:{order:{id:41,number:'OC-EXACT',items:[]}},can_issue:false},
|
||||
'/api/purchasing-receipts/orders/41':{order:{id:41,number:'OC-EXACT'},progress:{state:'complete'},history:{...paginator([]),last_page:2},can_confirm:false},
|
||||
'/api/purchasing-receipts/history/91':{receipt:{id:91,purchase_order_id:41,number:'REC-EXACT'},documents_enabled:false},
|
||||
'/api/purchasing-invoices/orders/41':{order:{id:41,number:'OC-EXACT',currency:'MZN',items:[]},invoices:[invoice],can_prepare:false,revision:rev,receipt_progress:{},physical_corrections:[]},
|
||||
'/api/purchasing-obligations/77':{documents:[{local_id:77,number:'INV-EXACT',supplier_name:'QA',total_minor:10000,status:'validated',order_id:41,missing:[],lines:[]}],obligations:[],permissions:{},proposals:[],events:[],revision:rev,pagination:{proposals:{page:1,last_page:1},events:{page:1,last_page:1}}},
|
||||
'/api/purchasing-dossiers/3':{request,view_revision:rev,_links:[{href:'/purchasing/requests?request=3',label:'Consultar esta requisição'}],stages:Object.fromEntries(['quotes','awards','orders','receipts','invoices','obligations'].map(k=>[k,{accessible:false}])),events:{accessible:true,data:[],pagination:{page:1,last_page:1}}}
|
||||
};
|
||||
globalThis.fetch=async(url,options={})=>{calls.push({url,method:options.method??'GET'});const pathname=url.split('?')[0];let data=responses[pathname];if(!data&&(/\/history$/.test(pathname)||/^\/api\/purchasing-(sourcing|orders|receipts|invoices|obligations|dossiers)$/.test(pathname)))data=paginator([]);if(!data)throw Error('Unexpected mock read '+url);if(mismatchReceipt&&pathname==='/api/purchasing-receipts/history/91')data={receipt:{id:91,purchase_order_id:42,number:'REC-MISMATCH'},documents_enabled:false};return {ok:true,json:async()=>structuredClone(data)};};
|
||||
const settle=()=>new Promise(resolve=>setTimeout(resolve,10));let sequence=0;async function mount(file,query){elements=new Map;calls.length=0;globalThis.location={search:query};await import('../public/'+file+'.mjs?case='+tag+'-'+sequence++);await settle();await settle();return {calls:[...calls],detail:document.querySelector(file==='order-workspace'?'#award-detail':file==='sourcing-workspace'?'#process-detail':'#detail'),status:document.querySelector(file==='receipt-workspace'?'#message':'#status')};}
|
||||
|
||||
ok(quantity(0)==='0.000'&&quantity(1)==='0.001'&&quantity(1000)==='1.000'&&quantity(-10)==='-0.010'&&quantity(1234567)==='1234.567','milli quantities render exact three decimals without floating rounding');assert.throws(()=>quantity(1.2));assert.throws(()=>quantity(Number.MAX_SAFE_INTEGER+1));checks.push('unsafe or fractional milli quantities are refused in display');
|
||||
const row={line_id:9,description:'Material QA',unit:'UN',ordered_milli:1000,accepted_milli:1000,returned_milli:0,reversed_milli:0,accepted_net_milli:1000,validated_milli:400,pending_milli:700,remaining_milli:600,deficit_milli:0};const pending={status:'pending',items:[{purchase_order_item_id:9,quantity_milli:700}]};ok(pendingQuantityWarnings(pending,[row])[0].includes('0.700')&&pendingQuantityWarnings(pending,[row])[0].includes('0.600'),'pending overlap warning compares original quantity with actual remaining quantity');ok(!pendingQuantityWarnings({...pending,status:'validated'},[row]).length,'validated invoice is not warned as a new pending claim');ok(!pendingQuantityWarnings({status:'pending',items:[{purchase_order_item_id:9,quantity_milli:600}]},[row]).length,'other pending documents do not reserve remaining quantity in UI');const box=new Element('section');appendInvoiceMatching(box,[{...row,remaining_milli:0,deficit_milli:100}]);ok(walk(box).some(v=>v.textContent.includes('0.100 UN j')),'quantity deficit remains explicit without negative available value');
|
||||
const d=responses['/api/purchasing-invoices/orders/41'];d.matching_progress=[row];d.can_prepare=true;d.order.items=[{id:9,description:'Material QA',quantity:'1.000',unit:'UN',unit_price_minor:10000,tax_rate:'0'}];d.invoices[0].status='pending';d.invoices[0].can_validate=true;d.invoices[0].items=pending.items;responses['/api/purchasing-invoices/orders/41/history']={...paginator([]),last_page:3};const result=await mount('supplier-invoice-workspace','?order=41&invoice=77');ok(walk(result.detail).some(v=>v.textContent==='Restante actual para conferir: 0.600 UN'),'real invoice module wires matching capacity next to explicit line choice');ok(walk(result.detail).some(v=>v.role==='note'&&v.textContent.includes('excede o restante actual')),'real invoice module shows overlap note without changing document state');const qtyLabel=walk(result.detail).find(v=>v.tag==='label'&&v.textContent.startsWith('Quantidade facturada'));ok(qtyLabel.children[0].value===''&&qtyLabel.children[0].disabled,'remaining capacity does not prefill or choose invoice quantity');ok(calls.every(v=>v.method==='GET'),'opening matching projection never registers validates or recognises a document');
|
||||
const next=walk(result.detail).find(v=>v.tag==='button'&&v.textContent==='Seguinte'),pendingReads=[],baseFetch=globalThis.fetch;globalThis.fetch=async(url,options)=>{if(url.includes('/history?page=2'))return await new Promise(resolve=>pendingReads.push(resolve));return baseFetch(url,options);};const first=next.onclick(),second=next.onclick();const payload=marker=>({ok:true,json:async()=>({...paginator([{id:1,created_at:'2026-11-03',action:'invoice.created',user_id:1,before:null,after:{marker}}]),current_page:2,last_page:3})});pendingReads[1](payload('new-history-response'));await second;pendingReads[0](payload('old-history-response'));await first;ok(walk(result.detail).some(v=>v.textContent.includes('new-history-response'))&&!walk(result.detail).some(v=>v.textContent.includes('old-history-response')),'late invoice history response cannot overwrite latest requested page');fs.writeFileSync(out,JSON.stringify({passed:true,checks,dom_simulation:true,business_commands:0,visual_review:false},null,2));console.log(JSON.stringify({passed:true,checks:checks.length}));
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
$tenant=$n['tenant'];$ok($db->table('purchase_orders')->where('tenant_id',$tenant)->count()===0&&$db->table('invoices')->count()===0,'main fixture begins before order receipt or invoice');$ok($call('issuer',$base)['status']===200&&count($call('issuer',$base)['data']['data'])===0,'finance sees no invented source order');$ok($call('actor',$base)['status']===403&&$call('validator',$base)['status']===403,'procurement and warehouse cannot inspect invoice economics');$ok($call('issuer',$base.'/orders/'.$n['foreign_order'])['status']===403,'actual foreign-tenant order is inaccessible');
|
||||
$source='/api/purchasing-orders/awards/'.$n['award'];$r=$call('actor',$source);$p=$call('actor',$source.'/preview',['revision'=>$r['data']['revision']]);$r=$call('actor',$source.'/issue',['preview'=>$p['data']['preview']]);$ok($r['status']===201,'source adjudication issues an actual SGF order');$order=$r['data']['order'];$id=$order['id'];$line=$order['items'][0]['id'];$url=$base.'/orders/'.$id;
|
||||
$preview=function($role,$action,$data,$key=null,$i=0)use($call,$url){$r=$call($role,$url,null,$i);if($r['status']!==200)throw new RuntimeException('Invoice state unavailable');return $call($role,$url.'/preview',['operation_key'=>$key??(string)Illuminate\Support\Str::uuid(),'revision'=>$r['data']['revision'],'action'=>$action,'reason'=>'Conferência documental exclusivamente sintética.','data'=>$data],$i);};$confirm=fn($role,$cut,$i=0)=>$call($role,$url.'/confirm',['preview'=>$cut],$i);$document=fn($number,$quantity,$amount)=>['number'=>$number,'issued_on'=>date('Y-m-d'),'subtotal'=>$amount,'tax'=>'0.00','total'=>$amount,'items'=>[['purchase_order_item_id'=>$line,'quantity'=>$quantity]]];
|
||||
$r=$call('issuer',$url);$ok($r['status']===200&&$r['data']['can_prepare']===true&&$r['data']['invoices']===[],'issued order reaches invoice workspace without source rebinding');$ok($call('issuer','/purchasing/invoices')['status']===200,'supplier invoice workspace served');$ok($preview('issuer','register',$document('QA-NF-01','0.400','40.00'))['status']===422,'no invoice is registered before accepted receipt');
|
||||
$receipt='/api/purchasing-receipts/orders/'.$id;$r=$call('validator',$receipt);$p=$call('validator',$receipt.'/preview',['revision'=>$r['data']['revision'],'idempotency_key'=>(string)Illuminate\Support\Str::uuid(),'delivery_note'=>'QA-INVOICE-ACCEPT','received_on'=>date('Y-m-d'),'warehouse_id'=>$n['warehouse'],'items'=>[['purchase_order_item_id'=>$line,'received_quantity'=>'1.000','accepted_quantity'=>'1.000','quality_status'=>'compliant','stock_item_id'=>$n['stock'],'notes'=>null]]]);$r=$call('validator',$receipt.'/confirm',['preview'=>$p['data']['preview']]);$ok($r['status']===201&&count($r['data']['movements'])===1,'warehouse accepts actual order once through original writer');$physical=fn()=>hash('sha256',json_encode([$db->table('goods_receipts')->orderBy('id')->get(),$db->table('goods_receipt_items')->orderBy('id')->get(),$db->table('stock_movements')->orderBy('id')->get()]));$physicalBefore=$physical();
|
||||
$fingerprint=function()use($db){$h=hash_init('sha256');foreach(['invoices','invoice_items','budget_consumptions','budget_entries','audit_logs']as$table)foreach($db->table($table)->orderBy('id')->get()as$row)hash_update($h,json_encode((array)$row));return hash_final($h);};$doc=$document(' qa-nf-01 ','0.400','40.00');$bad=$doc;$bad['total']='41.00';$ok($preview('issuer','register',$bad)['status']===422,'document total mismatch is refused');$bad=$doc;$bad['items'][0]['purchase_order_item_id']=$n['foreign_order_line'];$ok($preview('issuer','register',$bad)['status']===422,'foreign source line cannot be substituted');$bad=$doc;$bad['items'][0]['unit_price']='101.00';$ok($preview('issuer','register',$bad)['status']===422,'price divergence from frozen order is refused');$bad=$doc;$bad['items'][0]['quantity']='1.001';$ok($preview('issuer','register',$bad)['status']===422,'quantity beyond accepted source is refused');
|
||||
$before=$fingerprint();$key=(string)Illuminate\Support\Str::uuid();$p0=$preview('issuer','register',$doc,$key);$p1=$preview('issuer','register',$doc,$key,1);$ok($p0['status']===200&&$p0['data']['impact']['invoice']['number']==='QA-NF-01'&&!isset($p0['data']['impact']['invoice']['id'])&&$p0['data']['impact']['budget_consumption_minor']===0,'original registration preview normalises document and consumes no budget');$ok($fingerprint()===$before,'invoice preview persists no invoice audit budget entry or consumption');$other=$doc;$other['number']='QA-OTHER';$alternate=$preview('issuer','register',$other,$key)['data']['preview'];$ok($confirm('issuer',$p0['data']['preview'].'broken')['status']===409,'tampered invoice preview refused');
|
||||
$db->unprepared("CREATE FUNCTION public.invoice_qa_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.action='sgf.invoice.command' THEN RAISE EXCEPTION 'synthetic invoice receipt failure'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER invoice_qa_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION public.invoice_qa_fail()");try{$ok($confirm('issuer',$p0['data']['preview'])['status']===500,'failure recording SGF origin aborts registration');$ok($fingerprint()===$before,'invoice lines and native audit roll back with failed command receipt');}finally{$db->unprepared('DROP TRIGGER invoice_qa_fail ON audit_logs; DROP FUNCTION public.invoice_qa_fail()');}
|
||||
$responses=GuzzleHttp\Promise\Utils::unwrap([$clients[0]->postAsync($url.'/confirm',$options('issuer',0,['preview'=>$p0['data']['preview']])),$clients[1]->postAsync($url.'/confirm',$options('issuer',1,['preview'=>$p1['data']['preview']]))]);$registration=array_map(fn($r)=>$r->getStatusCode(),$responses);sort($registration);$ok($registration===[200,201],'independent sessions recover same registered invoice once');$invoice=json_decode((string)$responses[0]->getBody(),true)['invoice_id'];$ok($db->table('invoices')->count()===1&&$db->table('invoice_items')->count()===1&&$db->table('budget_consumptions')->count()===0,'pending invoice is unique and consumes no commitment');$ok($confirm('issuer',$alternate)['status']===409,'same operation key with another number refused');$ok($preview('issuer','register',$doc)['status']===422,'normalised supplier document number cannot be registered twice');$ok($preview('issuer','validate',['invoice_id'=>$invoice])['status']===403,'preparer with validation right cannot approve own invoice');
|
||||
$p=$preview('reviewer','validate',['invoice_id'=>$invoice]);$stale=$p['data']['preview'];$p=$preview('issuer','register',$document('QA-OVERLAP','0.700','70.00'));$r=$confirm('issuer',$p['data']['preview']);$overlap=$r['data']['invoice_id'];$ok($r['status']===201&&$db->table('invoices')->count()===2,'another pending documentary invoice is retained without consuming budget');$ok($confirm('reviewer',$stale)['status']===409,'new invoice after preview requires renewed review');$pOverlap=$preview('reviewer','validate',['invoice_id'=>$overlap]);$ok($pOverlap['status']===200,'overlapping pending invoice has original matching before other validation');$a=$preview('reviewer','validate',['invoice_id'=>$invoice]);$b=$preview('auditor','validate',['invoice_id'=>$invoice],null,1);$ok($a['status']===200&&$a['data']['impact']['budget_consumption_minor']===4000,'validation impact shows original40MZN commitment consumption');$before=$fingerprint();
|
||||
$db->unprepared("CREATE FUNCTION public.invoice_qa_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.action='sgf.invoice.command' THEN RAISE EXCEPTION 'synthetic validation receipt failure'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER invoice_qa_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION public.invoice_qa_fail()");try{$ok($confirm('reviewer',$a['data']['preview'])['status']===500&&$fingerprint()===$before,'failed validation receipt rolls back status native audit and consumption');}finally{$db->unprepared('DROP TRIGGER invoice_qa_fail ON audit_logs; DROP FUNCTION public.invoice_qa_fail()');}
|
||||
$grant=DB::table('permission_grants')->where($f['scope'])->where('user_id',$f['reviewer'])->where('permission','purchasing_invoices.validate')->first();DB::table('permission_grants')->where('id',$grant->id)->delete();try{$ok($confirm('reviewer',$a['data']['preview'])['status']===403,'validation permission is rechecked after preview');}finally{DB::table('permission_grants')->insert((array)$grant);}
|
||||
$responses=GuzzleHttp\Promise\Utils::unwrap([$clients[0]->postAsync($url.'/confirm',$options('reviewer',0,['preview'=>$a['data']['preview']])),$clients[1]->postAsync($url.'/confirm',$options('auditor',1,['preview'=>$b['data']['preview']]))]);$validation=array_map(fn($r)=>$r->getStatusCode(),$responses);$winner=$validation[0]===201?'reviewer':'auditor';$winnerCut=$validation[0]===201?$a['data']['preview']:$b['data']['preview'];sort($validation);$ok($validation===[201,409],'distinct eligible reviewers commit once and refuse stale concurrent decision');$ok($db->table('budget_consumptions')->count()===1&&(int)$db->table('budget_consumptions')->sum('amount_minor')===4000,'one original40MZN consumption is recorded');$ok($confirm($winner,$winnerCut,$winner==='auditor'?1:0)['status']===200&&$db->table('budget_consumptions')->count()===1,'validated command replay does not consume twice');$ok($confirm('reviewer',$pOverlap['data']['preview'])['status']===409,'validation of another invoice invalidates old matching preview');$ok($preview('reviewer','validate',['invoice_id'=>$overlap])['status']===422&&$db->table('invoices')->where('id',$overlap)->value('status')==='pending','overlapping document remains pending when cumulative accepted quantity is exceeded');
|
||||
$p=$preview('issuer','register',$document('QA-NF-02','0.600','60.00'));$r=$confirm('issuer',$p['data']['preview']);$second=$r['data']['invoice_id'];$ok($r['status']===201,'remaining0.6UN is registered with the original cumulative matching');$p=$preview('reviewer','validate',['invoice_id'=>$second]);$r=$confirm('reviewer',$p['data']['preview']);$ok($r['status']===201&&$r['data']['status']==='validated','distinct finance validates remaining60MZN');$ok($db->table('budget_consumptions')->count()===2&&(int)$db->table('budget_consumptions')->sum('amount_minor')===10000,'two partial invoices consume exactly100MZN in the same budget');$ok((int)$db->table('budget_entries')->where('tenant_id',$tenant)->sum('committed_minor')===0&&$db->table('budget_entries')->where('tenant_id',$tenant)->where('kind','commit')->count()===1,'single order commitment is consumed without another reservation or commitment');$ok($db->table('invoices')->where('status','validated')->count()===2&&$db->table('invoices')->where('status','pending')->count()===1,'original documents and incompatible pending document are preserved');$ok($physical()===$physicalBefore,'invoice registration and matching create no additional physical movement');
|
||||
$r=$call('reviewer',$url.'/history?page=1');$r2=$call('reviewer',$url.'/history?page=2');$ok($r['status']===200&&$r['data']['per_page']===15&&count($r['data']['data'])===10&&$r2['data']['data']===[],'invoice history contains only invoice events with server pagination');$enter('issuer',1,['foreign'=>1]);$ok($call('issuer',$url,null,1)['status']===403,'unmapped unit cannot reuse an invoice source');$p=$preview('issuer','register',$document('QA-FULL','0.001','0.10'));$ok($p['status']===422,'fully invoiced receipt cannot be invoiced again');
|
||||
$obligations=$db->getSchemaBuilder()->hasTable('pa_obligations');$ok(!$obligations||$db->table('pa_obligations')->count()===0,'matching does not recognise a payable automatically');$ok($db->table('external_payments')->count()===0,'matching creates no payment');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'registration_statuses'=>$registration,'validation_statuses'=>$validation,'validated_invoices'=>2,'pending_invoices'=>1,'consumed_minor'=>10000,'receipt_movements_unchanged'=>true,'physical_fingerprint'=>$physicalBefore,'payable_table_present'=>$obligations,'payable_auto_recognised'=>false,'payment_created'=>false,'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." supplier invoice integration checks passed.\n";
|
||||
@@ -0,0 +1,2 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import assert from 'node:assert/strict';import {spawnSync} from 'node:child_process';
|
||||
const tags=process.argv.slice(2);assert(tags.length===2&&tags.every(t=>/^[a-f0-9]{12}$/.test(t)));const run=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json')),hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'),sources=['BudgetContext','BudgetControl','BudgetFailure'].map(name=>({name,file:'packages/budget-control/src/'+name+'.php',sha256:hash('packages/budget-control/src/'+name+'.php')})),probes=[];for(const tag of tags){const root=path.join(run.root,'private/receipt-composition-'+tag),s=JSON.parse(fs.readFileSync(path.join(root,'state.json')));assert(s.invoices);for(const source of sources)assert.equal(hash(path.join(s.app,source.file)),source.sha256);probes.push({source_copy_matches:true,runtime_reflection_verified:false});}const out='docs/proofs/invoice-budget-writer-manifest-20261103.json';assert(!fs.existsSync(out));fs.writeFileSync(out,JSON.stringify({passed:true,kind:'source_copy_fingerprint_supplement',sources,probes,runtime_probe_limit:'Standalone runtime reflection probe exceeded its30s limit; private diagnostics retained. No business commands were executed or repeated.',invoice_business_proof:{file:'docs/proofs/supplier-invoice-composition-20261103.json',sha256:hash('docs/proofs/supplier-invoice-composition-20261103.json')},business_commands_repeated:false,financial_commands_executed:0},null,2)+'\n');console.log(JSON.stringify({writer_sources:3,qa_demo_source_copy_hashes_match:true,business_proof_unchanged:true}));
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true);if(!($s['invoices']??false)||is_file($s['root'].'/invoice-writer.json'))throw new LogicException('New invoice writer probe required');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$out=[];foreach(['BudgetContext','BudgetControl','BudgetFailure']as$name){$r=new ReflectionClass('Tempo\Finance\Budget'.chr(92).$name);$out[$name]=['sha256'=>hash_file('sha256',$r->getFileName()),'scope'=>str_starts_with(str_replace('\\','/',realpath($r->getFileName())),str_replace('\\','/',$s['app']).'/')?'private_composition':'workspace'];}file_put_contents($s['root'].'/invoice-writer.json',json_encode(['passed'=>true,'classes'=>$out,'financial_commands_executed'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
$checks=0;$cases=[];
|
||||
$assert=function(bool $ok,string $why)use(&$checks):void{$checks++;if(!$ok)throw new RuntimeException($why);};
|
||||
$reject=function(callable $run,string $class='DomainException',?int $status=null)use($assert):void{
|
||||
try{$run();}catch(Throwable $e){if(!$e instanceof $class)throw $e;if($status!==null&&(!$e instanceof Symfony\Component\HttpKernel\Exception\HttpExceptionInterface||$e->getStatusCode()!==$status))throw $e;$assert(true,'Refused');return;}throw new RuntimeException('Expected refusal absent');
|
||||
};
|
||||
$case=function(string $name,callable $run)use(&$cases,&$checks,$financialFingerprint,$g,$enter):void{
|
||||
$n=$checks;DB::beginTransaction();try{$enter('prepare');$run();$cases[]=['name'=>$name,'assertions'=>$checks-$n,'passed'=>true];}
|
||||
finally{while(DB::transactionLevel()>0)DB::rollBack();$enter('read');}
|
||||
if($financialFingerprint()!==$g['financial_fingerprint']||DB::table('gl_journals')->count()!==0)throw new RuntimeException('Case left writes outside its rollback frame');
|
||||
echo 'Passed ledger case: '.$name."\n";
|
||||
};
|
||||
$prepare=fn(array $overrides=[])=>$command('prepare','prepare',array_replace($g['body'],$overrides));
|
||||
$review=fn(array $p,string $decision='approved')=>$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>$decision,'reason'=>'Independent exact synthetic journal review.']);
|
||||
$postBody=fn(array $p)=>['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply the reviewed synthetic impact once.'];
|
||||
$case('prepare-independent-review-post-and-source-reconciliation',function()use($prepare,$review,$postBody,$command,$ledger,$enter,$g,$assert):void{
|
||||
$p=$prepare();$assert(DB::table('gl_journals')->count()===0,'Preparation does not post');
|
||||
$snapshot=json_decode(DB::table('gl_proposals')->where('id',$p['proposal_id'])->value('snapshot'),true);
|
||||
$assert($snapshot['source']['principal_minor']===6000&&$snapshot['source']['due_on']===null,'Principal and unknown due preserved');
|
||||
$assert($snapshot['lines'][0]['debit_minor']===6000&&$snapshot['lines'][1]['credit_minor']===6000&&!$snapshot['impact']['financial_balance_changed'],'Visible exact impact');
|
||||
$review($p);$assert(DB::table('gl_journals')->count()===0,'Approval still does not post');
|
||||
$posted=$command('prepare','post',$postBody($p));$assert($posted['state']==='posted'&&DB::table('gl_journals')->count()===1&&DB::table('gl_lines')->count()===2,'One header and two lines');
|
||||
DB::select('SELECT gl_assert_balanced(?)',[$posted['journal_id']]);
|
||||
$all=$ledger->listing($enter('read'),['period_key'=>'QA-2026-10']);$debit=$ledger->listing($enter('read'),['account_id'=>$g['debit_account']]);$credit=$ledger->listing($enter('read'),['account_id'=>$g['credit_account']]);
|
||||
$assert($all['totals']===['debit_minor'=>6000,'credit_minor'=>6000,'net_minor'=>0],'Period reconciles');
|
||||
$assert($debit['totals']['net_minor']===6000&&$credit['totals']['net_minor']===-6000,'Both account extracts reconcile');
|
||||
$assert(DB::table('gl_events')->count()===3&&DB::table('gl_events')->whereNull('reason')->count()===0,'Author date reason before/after history');
|
||||
$assert(DB::table('pa_obligations')->where('id',$g['obligation_id'])->value('principal_minor')===6000,'No second debt or change of principal');
|
||||
});
|
||||
$case('repetition-conflicting-key-and-recovery-after-period-version',function()use($prepare,$review,$postBody,$command,$g,$assert,$reject):void{
|
||||
$key=(string)Illuminate\Support\Str::uuid();$p=$command('prepare','prepare',$g['body'],$key);$again=$command('prepare','prepare',$g['body'],$key);
|
||||
$assert($again['replayed']&&$again['proposal_id']===$p['proposal_id'],'Same request recovers proposal');
|
||||
$other=$prepare();$assert($other['proposal_id']===$p['proposal_id']&&DB::table('gl_proposals')->count()===1,'Other key same preparation does not duplicate');
|
||||
$reject(fn()=>$command('prepare','prepare',array_replace($g['body'],['reason'=>'Changed key content.']),$key));
|
||||
$review($p);$body=$postBody($p);$postKey=(string)Illuminate\Support\Str::uuid();$j=$command('prepare','post',$body,$postKey);
|
||||
$period=(array)DB::table('gl_periods')->first();unset($period['id']);$period['version']=2;$period['status']='closed';DB::table('gl_periods')->insert($period);
|
||||
$a=$command('prepare','post',$body,$postKey);$b=$command('prepare','post',$body);
|
||||
$assert($a['journal_id']===$j['journal_id']&&$b['journal_id']===$j['journal_id']&&$a['replayed'],'Existing journal recovered after period closes, no new write');
|
||||
$assert(DB::table('gl_journals')->count()===1&&DB::table('gl_events')->where('kind','journal.posted')->count()===1,'One posting event');
|
||||
$reject(fn()=>$command('prepare','post',array_replace($body,['reason'=>'Different posting content.'])));
|
||||
});
|
||||
$case('rights-revoked-actor-person-and-no-inheritance',function()use($ledger,$enter,$command,$g,$scope,$f,$assert,$reject):void{
|
||||
$reject(fn()=>$command('read','prepare',$g['body']),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,403);
|
||||
$engine=$ledger->engine($enter('prepare'));DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.prepare'])->delete();
|
||||
$reject(fn()=>$engine->command('prepare',$g['body'],(string)Illuminate\Support\Str::uuid()),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,403);
|
||||
$engine=$ledger->engine($enter('validate'));DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>'QA_CHANGED_PERSON']);
|
||||
$reject(fn()=>$engine->command('validate',['proposal_id'=>1,'revision'=>str_repeat('a',64),'decision'=>'approved','reason'=>'Person changed.'],(string)Illuminate\Support\Str::uuid()),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,409);
|
||||
config(['general_ledger.synthetic_enabled'=>false]);try{$reject(fn()=>$ledger->listing($enter('read')),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,403);}finally{config(['general_ledger.synthetic_enabled'=>true]);}
|
||||
});
|
||||
$case('independence-rejection-and-preparer-ownership',function()use($prepare,$review,$postBody,$command,$g,$scope,$f,$reject,$assert):void{
|
||||
$p=$prepare();DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.validate','created_at'=>now(),'updated_at'=>now()]);
|
||||
$reject(fn()=>$command('prepare','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Self review forbidden.']));
|
||||
$person=DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['prepare'],'action'=>'view'])->value('person');$old=DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->value('person');
|
||||
DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>$person]);$reject(fn()=>$review($p));DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>$old]);
|
||||
$review($p,'rejected');$reject(fn()=>$command('prepare','post',$postBody($p)));$assert(DB::table('gl_journals')->count()===0,'Rejection does not post');
|
||||
$p2=$prepare(['reason'=>'New independent proposal after refusal.']);$review($p2);
|
||||
DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors']['validate'],'permission'=>'general_ledger.post','created_at'=>now(),'updated_at'=>now()]);$reject(fn()=>$command('validate','post',$postBody($p2)));
|
||||
$command('prepare','post',$postBody($p2));$assert(DB::table('gl_reviews')->count()===2,'Original refusal preserved');
|
||||
});
|
||||
$case('source-rule-period-latest-capture-and-revision-changes',function()use($prepare,$review,$postBody,$command,$scope,$g,$reject,$assert):void{
|
||||
foreach(['source','rule','period','latest','revision'] as $change){DB::beginTransaction();try{
|
||||
$p=$prepare();$review($p);
|
||||
if($change==='source'){$row=DB::table('pa_imported_sources')->where('id',$g['local_source_id'])->first();$doc=json_decode($row->payload,true);$doc['number']='QA_CHANGED_AFTER_REVIEW';$payload=Tempo\Finance\Payables\Payables::canonical($doc);DB::table('pa_imported_sources')->where('id',$row->id)->update(['payload'=>$payload,'receipt_hash'=>hash('sha256',$payload)]);}
|
||||
if($change==='rule'){$r=(array)DB::table('gl_rules')->first();unset($r['id']);$r['version']=2;DB::table('gl_rules')->insert($r);}
|
||||
if($change==='period'){$r=(array)DB::table('gl_periods')->first();unset($r['id']);$r['version']=2;$r['status']='closed';DB::table('gl_periods')->insert($r);}
|
||||
if($change==='latest')$prepare(['reason'=>'Later proposal supersedes the earlier capture.']);
|
||||
$body=$postBody($p);if($change==='revision')$body['revision']=str_repeat('f',64);
|
||||
$reject(fn()=>$command('prepare','post',$body));$assert(DB::table('gl_journals')->count()===0,'Changed state makes no journal');
|
||||
}finally{DB::rollBack();}}
|
||||
});
|
||||
$case('invalid-period-accounts-currency-foreign-scopes-and-extra-fields',function()use($prepare,$command,$scope,$g,$reject,$assert):void{
|
||||
$reject(fn()=>$prepare(['journal_on'=>'2026-11-01']));$reject(fn()=>$prepare(['journal_on'=>'2026-02-30']),InvalidArgumentException::class);
|
||||
$reject(fn()=>$command('prepare','prepare',$g['body']+['company_id'=>999]),InvalidArgumentException::class);$reject(fn()=>$prepare(['obligation_id'=>PHP_INT_MAX]));
|
||||
$p=$prepare();$row=(array)DB::table('gl_proposals')->where('id',$p['proposal_id'])->first();unset($row['id']);
|
||||
foreach(['tenant_id','company_id','unit_id'] as $key){$foreign=DB::table('gl_proposals')->insertGetId(array_replace($row,[$key=>$scope[$key]+10000]));$reject(fn()=>$command('prepare','post',['proposal_id'=>$foreign,'revision'=>$p['revision'],'reason'=>'Foreign proposal cannot post.']));}
|
||||
DB::beginTransaction();try{$rule=(array)DB::table('gl_rules')->first();unset($rule['id']);$rule['version']=2;$rule['currency']='USD';DB::table('gl_rules')->insert($rule);$reject(fn()=>$prepare());}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{$account=DB::table('gl_accounts')->insertGetId($scope+['code'=>'QA-AGGREGATOR','label'=>'Not a movement account','is_movement'=>false,'currency'=>'MZN']);$rule=(array)DB::table('gl_rules')->first();unset($rule['id']);$rule['version']=2;$rule['debit_account_id']=$account;DB::table('gl_rules')->insert($rule);$reject(fn()=>$prepare());}finally{DB::rollBack();}
|
||||
$assert(DB::table('gl_journals')->count()===0,'No invalid effect posted');
|
||||
});
|
||||
$case('failure-at-every-posting-boundary-and-idempotent-recovery',function()use($prepare,$review,$postBody,$command,$assert):void{
|
||||
foreach(['after_header','after_lines','after_event','after_receipt'] as $point){DB::beginTransaction();try{
|
||||
$p=$prepare();$review($p);$key=(string)Illuminate\Support\Str::uuid();$failed=false;
|
||||
try{$command('prepare','post',$postBody($p),$key,function($name)use($point){if($name===$point)throw new RuntimeException('QA_FAIL_'.$point);});}catch(RuntimeException $e){$failed=$e->getMessage()==='QA_FAIL_'.$point;}
|
||||
$assert($failed&&DB::table('gl_journals')->count()===0&&DB::table('gl_lines')->count()===0,'Atomic rollback at '.$point);
|
||||
$assert(DB::table('gl_events')->where('kind','journal.posted')->count()===0&&DB::table('gl_commands')->where('command_key',$key)->count()===0,'No false event or command receipt');
|
||||
$posted=$command('prepare','post',$postBody($p),$key);$again=$command('prepare','post',$postBody($p),$key);
|
||||
$assert($again['replayed']&&$again['journal_id']===$posted['journal_id']&&DB::table('gl_journals')->count()===1,'Recovery creates one effect');
|
||||
}finally{DB::rollBack();}}
|
||||
});
|
||||
$case('database-balance-guards-and-append-only-history',function()use($prepare,$review,$postBody,$command,$scope,$enter,$assert):void{
|
||||
$p=$prepare();$review($p);
|
||||
$proposal=DB::table('gl_proposals')->where('id',$p['proposal_id'])->first();$snapshot=json_decode($proposal->snapshot,true);$reviewId=DB::table('gl_reviews')->where('proposal_id',$p['proposal_id'])->value('id');
|
||||
foreach(['unequal_header','wrong_principal','missing_line','foreign_account'] as $variant){
|
||||
$blocked=false;try{DB::transaction(function()use($variant,$scope,$proposal,$snapshot,$reviewId){
|
||||
$id=DB::table('gl_journals')->insertGetId($scope+['effect_key'=>$proposal->effect_key,'obligation_id'=>$proposal->obligation_id,'proposal_id'=>$proposal->id,'review_id'=>$reviewId,'source_revision'=>$snapshot['source']['recognition_revision'],'journal_on'=>$snapshot['journal_on'],'currency'=>'MZN','debit_minor'=>$variant==='wrong_principal'?6001:6000,'credit_minor'=>$variant==='unequal_header'?5999:($variant==='wrong_principal'?6001:6000),'snapshot'=>$proposal->snapshot,'posted_by'=>$proposal->prepared_by,'posted_person'=>$proposal->prepared_person,'reason'=>'Direct SQL must retain journal invariants.','created_at'=>now()]);
|
||||
foreach($snapshot['lines'] as $n=>$line){if($variant==='missing_line'&&$n===1)continue;$account=$line['account_id'];if($variant==='foreign_account'&&$n===0)$account=DB::table('gl_accounts')->insertGetId(array_replace($scope,['unit_id'=>$scope['unit_id']+10000])+['code'=>'QA-FOREIGN','label'=>'Foreign account','is_movement'=>true,'currency'=>'MZN']);
|
||||
DB::table('gl_lines')->insert($scope+['journal_id'=>$id,'line_no'=>$n+1,'account_id'=>$account,'debit_minor'=>$line['debit_minor'],'credit_minor'=>$line['credit_minor']]);}
|
||||
DB::select('SELECT gl_assert_balanced(?)',[$id]);
|
||||
});}catch(Illuminate\Database\QueryException $e){$blocked=in_array($e->errorInfo[0]??'',['23514','23503'],true);}
|
||||
$assert($blocked&&DB::table('gl_journals')->count()===0,'Database refuses '.$variant);
|
||||
}
|
||||
$posted=$command('prepare','post',$postBody($p));
|
||||
foreach(Tempo\Finance\GeneralLedger\LedgerSchema::TABLES as $table){$blocked=false;try{DB::transaction(fn()=>DB::table($table)->where('id',DB::table($table)->value('id'))->update(['id'=>DB::raw('id')]));}catch(Illuminate\Database\QueryException $e){$blocked=str_contains($e->getMessage(),'GL_APPEND_ONLY');}$assert($blocked,'Append-only '.$table);}
|
||||
$assert((int)DB::selectOne('SELECT sum(debit_minor) AS d,sum(credit_minor) AS c FROM gl_lines')->d===6000,'Line sum is exact');
|
||||
$bad=false;try{DB::transaction(function()use($scope,$posted){$line=(array)DB::table('gl_lines')->where('journal_id',$posted['journal_id'])->first();unset($line['id']);$line['line_no']=3;DB::table('gl_lines')->insert($line);});}catch(Illuminate\Database\QueryException $e){$bad=($e->errorInfo[0]??'')==='23514';}$assert($bad,'Third line cannot change a posted journal');
|
||||
$assert(DB::table('gl_lines')->count()===2,'Original lines preserved after direct SQL refusal');
|
||||
});
|
||||
$assert($financialFingerprint()===$g['financial_fingerprint'],'Final financial baseline unchanged');
|
||||
file_put_contents($s['root'].'/ledger-cases.json',json_encode(['passed'=>true,'cases'=>$cases,'assertions'=>$checks,'financial_fingerprint_equal'=>true,'all_test_journals_rolled_back'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['cases'=>count($cases),'assertions'=>$checks,'passed'=>true])."\n";
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Accounting\LedgerComposition;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$output=$s['root'].'/composition-cases.json';if(file_exists($output))throw new LogicException('Completed combined proof preserved');$checks=0;$cases=[];
|
||||
$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};
|
||||
$refuse=function($fn,$status=null)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException $e){$caught=$status===null||($e instanceof Symfony\Component\HttpKernel\Exception\HttpException&&$e->getStatusCode()===$status);}$ok($caught,'Expected combined access/completeness refusal');};
|
||||
$case=function($name,$fn)use(&$checks,&$cases,$enter){$at=$checks;DB::beginTransaction();try{$fn();$cases[]=['name'=>$name,'checks'=>$checks-$at,'passed'=>true];}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}};
|
||||
$fingerprint=function(){ $h=hash_init('sha256');foreach(['gl_journals','gl_lines','gl_commands','gl_period_checks','gl_period_closures','permission_grants','wo_orders','gl_cost_sources','dc_calculations','dc_outbox','si_invoices','sr_records','sr_effects','pa_obligations'] as $t){hash_update($h,$t);hash_update($h,DB::table($t)->orderBy($t==='dc_outbox'?'event_id':'id')->get()->toJson());}return hash_final($h);};$before=$fingerprint();$workspace=app(LedgerComposition::class);
|
||||
$case('bounded-recorded-source-panels',function()use($workspace,$enter,$ok,$cf){$d=$workspace->workspace($enter('read'));$ok($d['all_current_source_views']&&array_column($d['panels']['sales']['rows'],'invoice_id')===[$cf['invoice_id']],'Actual invoice visible in composed scope');$ok($d['panels']['costs']['rows'][0]['calculation_version']===2&&$d['panels']['costs']['rows'][0]['recorded_decision']==='approved','Recorded successor cost metadata');$ok($d['panels']['suppliers']['rows'][0]['principal_minor']===6000&&$d['book']['accounts']===[],'Principal labelled separately and account universe not loaded');$ok($d['recorded_state_is_not_current_conference']===true,'Navigation does not claim current dependency conference');$html=view('accounting.ledger-composition',['data'=>$workspace->workspace($enter('prepare'))])->render();foreach(['/general-ledger/sales?invoice_id=','/general-ledger/costs?order_id=','/general-ledger/flow?source=','data-action="period-prepare"'] as $text)$ok(str_contains($html,$text),'Workspace source/period entry: '.$text);});
|
||||
$case('each-live-source-right-and-whole-book-right',function()use($workspace,$enter,$ok,$refuse,$scope,$g,$command){foreach(['service_invoices.view'=>'sales','service_receivables.view'=>'sales','workshop.view'=>'costs','direct_costs.view'=>'costs','supplier_payables.view'=>'suppliers'] as $right=>$panel){DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission',$right)->delete();$d=$workspace->workspace($enter('prepare'));$ok($d['panels'][$panel]===['allowed'=>false]&&!$d['all_current_source_views'],'Hidden source has no metadata/count: '.$right);$html=view('accounting.ledger-composition',['data'=>$d])->render();$ok(!str_contains($html,'data-action='),'Partial source access does not expose close forms');$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'No partial conference after provider right revoked.']));
|
||||
}finally{DB::rollBack();}}DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','general_ledger.view')->delete();$refuse(fn()=>$workspace->workspace($enter('prepare')));$refuse(fn()=>$workspace->workspace($enter('technician')));});
|
||||
$case('eligible-foreign-contexts',function()use($workspace,$enter,$scope,$ok){foreach(['unit','company','tenant'] as $kind){DB::beginTransaction();try{$u=$enter('read');$company=$kind==='unit'?DB::table('companies')->where('id',$scope['company_id'])->first():($kind==='company'?DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id','<>',$scope['company_id'])->first():DB::table('companies')->where('tenant_id','<>',$scope['tenant_id'])->first());$unit=DB::table('units')->insertGetId(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'name'=>'SQL-ONLY foreign workspace '.$kind,'created_at'=>now(),'updated_at'=>now()]);if(!DB::table('company_memberships')->where(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id])->exists())DB::table('company_memberships')->insert(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id]);foreach(['company.view'=>null,'unit.view'=>$unit,'general_ledger.view'=>$unit,'service_invoices.view'=>$unit,'service_receivables.view'=>$unit,'workshop.view'=>$unit,'direct_costs.view'=>$unit,'supplier_payables.view'=>$unit] as $right=>$v)Tests\Support\IdentityFixture::grant($u->id,$company->tenant_id,$company->id,$right,$v);DB::table('pa_access')->insert(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'unit_id'=>$unit,'user_id'=>$u->id,'person'=>'foreign-workspace-'.$u->id,'action'=>'view']);app(CompanyContext::class)->selectFor($u->id,$company->tenant_id,$company->id,$unit);try{$d=$workspace->workspace($u);}catch(DomainException){$ok(true,'Foreign context refused before any result');continue;}$ok(array_sum(array_map(fn($p)=>count($p['rows']??[]),$d['panels']))===0&&$d['book']['journals']===[],'No source/book leak in eligible foreign '.$kind);}finally{DB::rollBack();}}});
|
||||
$case('independent-pagination-no-per-row-source-queries',function()use($workspace,$enter,$ok,$g){$template=(array)DB::table('wo_orders')->where('id',$g['order_id'])->first();$mapping=(array)DB::table('gl_cost_sources')->where('order_id',$g['order_id'])->first();unset($template['id'],$mapping['id']);for($i=0;$i<25;$i++){$row=$template;$row['reference']='SQL-ONLY-PAGE-'.$i;$id=DB::table('wo_orders')->insertGetId($row);DB::table('gl_cost_sources')->insert(array_merge($mapping,['order_id'=>$id]));}$db=DB::connection();$db->enableQueryLog();$db->flushQueryLog();$a=$workspace->workspace($enter('read'));$queriesA=count($db->getQueryLog());$db->flushQueryLog();$b=$workspace->workspace($enter('read'),['costs_page'=>2]);$queriesB=count($db->getQueryLog());$db->disableQueryLog();$ok(count($a['panels']['costs']['rows'])===25&&count($b['panels']['costs']['rows'])===1&&$b['panels']['costs']['pagination']['total']===26,'Cost pagination bounded25 with stable total26');$ok($a['panels']['sales']===$b['panels']['sales']&&$a['panels']['suppliers']===$b['panels']['suppliers'],'Changing costs page preserves other source pages');$ok($queriesA===$queriesB,'One row and25 rows use equal query count');$ok($b['panels']['costs']['rows'][0]['calculation_id']===null&&$b['panels']['costs']['rows'][0]['recorded_decision']===null,'SQL-only uncalculated perimeter is not labelled zero/approved');});
|
||||
$case('unavailable-provider-not-zero',function()use($workspace,$enter,$refuse){DB::statement('ALTER TABLE pa_imported_sources RENAME TO qa_unavailable_receipts');$refuse(fn()=>$workspace->workspace($enter('read')),503);});
|
||||
$case('feature-default-off-and-complete-period-blocker',function()use($workspace,$enter,$refuse,$command){config(['general_ledger.composition_enabled'=>false]);try{$refuse(fn()=>$workspace->workspace($enter('read')),403);}finally{config(['general_ledger.composition_enabled'=>true]);}$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Unopened invoice, unposted supplier and unresolved cost must block joint conference.']));});
|
||||
$case('sql-three-lines-only-for-invoice',function()use($ok){$original=(array)DB::table('gl_journals')->where('snapshot->source->effect_type','workshop.direct_cost.validated')->first();$proposal=(array)DB::table('gl_proposals')->where('id',$original['proposal_id'])->first();$review=(array)DB::table('gl_reviews')->where('id',$original['review_id'])->first();$snapshot=json_decode($original['snapshot'],true);$credit=$snapshot['lines'][1];$credit['credit_minor']=10000;$last=$credit;$last['credit_minor']=17500;$snapshot['lines']=[$snapshot['lines'][0],$credit,$last];$snapshot=Payables::canonical($snapshot);$key=hash('sha256','SQL-ONLY reviewed cost three lines');unset($proposal['id'],$review['id'],$original['id']);$proposal['effect_key']=$key;$proposal['snapshot']=$snapshot;$proposal['revision']=hash('sha256',$snapshot);$pid=DB::table('gl_proposals')->insertGetId($proposal);$review['proposal_id']=$pid;$review['revision']=$proposal['revision'];$rid=DB::table('gl_reviews')->insertGetId($review);$original['proposal_id']=$pid;$original['review_id']=$rid;$original['snapshot']=$snapshot;$original['effect_key']=$key;$jid=DB::table('gl_journals')->insertGetId($original);foreach(json_decode($snapshot,true)['lines'] as $i=>$line)DB::table('gl_lines')->insert(['tenant_id'=>$original['tenant_id'],'company_id'=>$original['company_id'],'unit_id'=>$original['unit_id'],'journal_id'=>$jid,'line_no'=>$i+1,'account_id'=>$line['account_id'],'debit_minor'=>$line['debit_minor'],'credit_minor'=>$line['credit_minor']]);$caught=false;DB::beginTransaction();try{DB::select('SELECT gl_assert_balanced(?)',[$jid]);}catch(Illuminate\Database\QueryException $e){$caught=str_contains($e->getMessage(),'GL_UNBALANCED_JOURNAL');}finally{DB::rollBack();}$ok($caught,'Reviewed balanced cost split into three matching lines rejected by SQL cardinality');});
|
||||
$ok($before===$fingerprint(),'All rollback probes preserve original records, books, permissions and source state');file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'cases'=>$cases,'rollback_only'=>true,'private_synthetic_database_only'=>true,'preservation_verified'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo 'Composition regression: '.count($cases)." cases/$checks checks.\n";
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import vm from 'node:vm';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned fixture required');const root=path.join(state.root,'private/ledger-'+tag),demo=read(path.join(root,'cost-demo.json')),out=path.join(root,'composition-demo-http.json');if(fs.existsSync(out))throw Error('Completed proof preserved');let checks=0,prepareHtml;const ok=(v,m)=>{checks++;assert(v,m);};
|
||||
for(const role of ['prepare','validate','read']){const jar=new Map();let context='';const get=async url=>{const r=await fetch(new URL(url,demo.url),{headers:{Accept:'application/json','X-SGF-Context':context,Cookie:[...jar].map(([k,v])=>k+'='+v).join('; ')},redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data};};const entry=await get('/qa/cost-enter/'+role+'?open=workspace');ok(entry.status===302,'Role opens unified workspace');const auth=await get('/qa/cost-enter/'+role);context=auth.data.context;const w=await get('/api/general-ledger/workspace');ok(w.status===200&&w.data.all_current_source_views&&w.data.book.journals.length===1&&w.data.checks.rows.length===0&&w.data.period.status==='open','Initial composed state not consumed');ok(!w.data.panels.sales.rows[0].confirmed_opening&&w.data.panels.sales.rows[0].original_journal_id===null&&w.data.panels.costs.rows[0].calculation_version===2&&w.data.panels.costs.rows[0].recorded_correction_result_id===null&&w.data.panels.suppliers.rows[0].original_journal_id===null,'Existing sources await individual financial/accounting decisions');const page=await get('/general-ledger/workspace');ok(page.status===200&&(page.text.includes('data-action="period-prepare"')===(role==='prepare')),'Role-specific initial controls');if(role==='prepare')prepareHtml=page.text;for(const url of ['/general-ledger/sales?invoice_id=1','/general-ledger/costs?order_id=1','/general-ledger/flow?source=1','/general-ledger','/']){const r=await get(url);ok(r.status===200,'Existing linked workflow '+url);if(url==='/general-ledger')ok(r.text.includes('/general-ledger/workspace'),'Generic diary links back to composition');}}
|
||||
const script=[...prepareHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)].at(-1)?.[1];if(!script)throw Error('Rendered form script missing');const form={dataset:{action:'period-close'},fields:{check_id:'9',revision:'b'.repeat(64),reason:'Individual exact period closure.'},button:{},querySelector(){return this.button;}},error={textContent:''},calls=[],mock={fail:false},sandbox={document:{querySelectorAll:()=>[form],querySelector:()=>error},FormData:class{constructor(f){return new Map(Object.entries(f.fields));}},crypto:{randomUUID:crypto.randomUUID},location:{reload(){}},fetch:async(url,options)=>{calls.push({url,options,body:JSON.parse(options.body)});return{ok:!mock.fail,json:async()=>mock.fail?{message:'Expected stale source refusal.'}:{closure_id:1}};}};vm.createContext(sandbox);vm.runInContext(script,sandbox);const submit=()=>form.onsubmit({preventDefault(){}});await submit();ok(calls[0].body.check_id===9&&calls[0].body.revision==='b'.repeat(64),'Numeric check ID, exact revision remains string');await submit();ok(calls[0].options.headers['Idempotency-Key']===calls[1].options.headers['Idempotency-Key'],'Unchanged retry preserves command key');form.fields.reason='Different deliberate closure basis.';await submit();ok(calls[2].options.headers['Idempotency-Key']!==calls[1].options.headers['Idempotency-Key'],'Changed basis gets new command key');mock.fail=true;await submit();ok(error.textContent.includes('Expected stale source refusal.')&&form.button.disabled===false,'Refusal shown for new conference, button recovers');
|
||||
fs.writeFileSync(out,JSON.stringify({passed:true,checks,roles:3,no_business_commands:true,forms_mock_checks:4,actual_browser:false,visual_review:false,initial_journals:1,initial_groups:0,initial_customer_openings:0,initial_closures:0},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,no_business_commands:true}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned cost fixture required');const root=path.join(state.root,'private/ledger-'+tag),g=read(path.join(root,'cost-fixture.json')),out=path.join(root,'composition-http.json');if(fs.existsSync(out)||!fs.existsSync(path.join(root,'ledger-composition-fixture.json')))throw Error('Fresh HTTP proof with private baseline required');
|
||||
await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(8047,'127.0.0.1',()=>s.close(resolve));});const log=fs.openSync(path.join(root,'cost-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-cost-http-router.php')],{windowsHide:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:path.join(root,'settings.json')}});
|
||||
let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};const client=()=>{const jar=new Map();let auth;const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/cost-enter/'+role);ok(r.status===200,'Owned '+role+' entry');auth=r.data;ok(jar.has('sgf_cost_'+tag)&&!jar.has('XSRF-TOKEN'),'Separate session cookie and shared CSRF preserved');},module:async module=>{const old=auth.context,r=await request('/api/context',{...g.scope,module_id:module});ok(r.status===200,'Explicit module transition');auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
|
||||
try{
|
||||
const p=client(),v=client(),r=client(),tech=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await r.request('/api/general-ledger/workspace')).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}ok(ready,'Anonymous workspace denied');await p.enter('prepare');await v.enter('validate');await r.enter('read');await tech.enter('technician');ok((await tech.request('/api/general-ledger/workspace')).status===403,'Technician does not inherit ledger access');
|
||||
let d=await r.request('/api/general-ledger/workspace',undefined,undefined,{'X-Cost-QA-Proof':'read'});ok(d.status===200&&d.data.all_current_source_views&&d.data.book.accounts.length===0,'All bounded source panels and no account catalogue preload');ok(d.headers.get('cache-control').includes('no-store'),'Private workspace not cached');const mode=read(path.join(root,'cost-http-read.json'));ok(mode.transaction.isolation==='repeatable read'&&mode.transaction.read_only==='on'&&mode.writer_locks===0,'One coherent read-only transaction without writer locks');for(const panel of ['sales','costs','suppliers'])ok(d.data.panels[panel].rows.length===1&&d.data.panels[panel].pagination.per_page===25,'One actual source in '+panel);const closed=d.data.period.status==='closed';
|
||||
let view=await p.request('/general-ledger/workspace');ok(view.status===200,'Unified Blade view renders');for(const link of ['/general-ledger/sales?invoice_id=','/general-ledger/costs?order_id=','/general-ledger/flow?source='])ok(view.text.includes(link),'Workflow origin link '+link);ok(view.text.includes('data-action="period-prepare"')!==closed,'Period command appears only in open preparer view');fs.writeFileSync(path.join(root,'composition-preparer.html'),view.text);view=await r.request('/general-ledger/workspace');ok(view.status===200&&!view.text.includes('data-action='),'Reader has no financial command forms');
|
||||
for(const query of ['invoices_page=0','costs_page=100001','suppliers_page%5Bx%5D=1','company_id=9','journals_page=1&unknown=1'])ok((await r.request('/api/general-ledger/workspace?'+query)).status===422,'Malformed or foreign-scope query rejected: '+query);
|
||||
d=await r.request('/api/general-ledger/workspace?costs_page=2&invoices_page=1');ok(d.status===200&&d.data.panels.costs.rows.length===0&&d.data.panels.sales.rows.length===1,'Cost page independent of invoice page');
|
||||
const body={period_key:'QA-2026-10',reason:'Explicit joint conference requires every current source.'};ok((await p.request('/api/general-ledger/period-prepare',body,undefined,{'X-CSRF-TOKEN':''})).status===419,'CSRF enforced');ok((await r.request('/api/general-ledger/period-prepare',body)).status===403,'Reader cannot prepare period');if(!closed)ok((await p.request('/api/general-ledger/period-prepare',body)).status===409,'Unresolved cross-source state blocks conference through HTTP');
|
||||
const old=await p.module('finance');ok((await p.request('/api/general-ledger/workspace')).status===409,'Accounting module context required');await p.module('accounting');ok((await p.request('/api/general-ledger/period-prepare',body,undefined,{'X-SGF-Context':old})).status===409,'Stale context generation refused');
|
||||
const guide=await r.request('/qa/cost-demo');ok(guide.status===200&&guide.text.includes('Percurso financeiro composto QA'),'Composition guide replaces standalone fixture guide only in new composition');
|
||||
fs.writeFileSync(out,JSON.stringify({passed:true,checks,actual_http:true,actual_csrf:true,period_closed:closed,read_only_repeatable_read:true,writer_locks:0,independent_pagination:true,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,period_closed:closed}));
|
||||
}finally{server.kill();fs.closeSync(log);}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Modules\Services\{ServicePermissions,DocumentPreview,InternalConfirmation,ServiceReceivables};
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use App\Shared\Context\ModuleRecords;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
if(config('general_ledger.composition_enabled')!==true||config('sales_ledger.synthetic_enabled')!==true||config('cost_ledger.correction_enabled')!==true)throw new LogicException('Explicit new multi-source composition required');
|
||||
config(['modules.contracted'=>['commercial','finance','workshop','accounting'],'modules.companies.'.$scope['tenant_id'].':'.$scope['company_id']=>['contracted'=>['commercial','finance','workshop','accounting'],'enabled'=>['commercial','finance','workshop','accounting']]]);
|
||||
if($phase==='ledger-cost-composition-ready'){
|
||||
$output=$s['root'].'/ledger-composition-fixture.json';if(file_exists($output)||DB::table('si_invoices')->exists()||DB::table('pa_obligations')->exists()||DB::table('gl_journals')->count()!==1)throw new LogicException('Fresh composition after original cost preparation required');Illuminate\Support\Facades\Date::setTestNow('2026-10-29 12:00:00');$oldGrants=DB::table('permission_grants')->pluck('id')->all();$base=['manager'=>$enter('prepare'),'reader'=>App\Models\User::findOrFail($g['actors']['validate']),'tenant'=>$scope['tenant_id'],'company'=>$scope['company_id'],'unit'=>$scope['unit_id']];
|
||||
foreach(ServicePermissions::AREAS as $area){$unit=ServicePermissions::unitScoped($area)?$scope['unit_id']:null;foreach(ServicePermissions::ACTIONS as $a)Tests\Support\IdentityFixture::grant($g['actors']['prepare'],$scope['tenant_id'],$scope['company_id'],$area.'.'.$a,$unit);Tests\Support\IdentityFixture::grant($g['actors']['validate'],$scope['tenant_id'],$scope['company_id'],$area.'.view',$unit);}
|
||||
Tests\Support\IdentityFixture::grant($g['actors']['prepare'],$scope['tenant_id'],$scope['company_id'],InternalConfirmation::PERMISSION,$scope['unit_id']);foreach(['issuer_documents','client_documents','draft_documents'] as $area){$unit=$area==='draft_documents'?$scope['unit_id']:null;foreach(['view','edit'] as $a)Tests\Support\IdentityFixture::grant($g['actors']['prepare'],$scope['tenant_id'],$scope['company_id'],$area.'.'.$a,$unit);Tests\Support\IdentityFixture::grant($g['actors']['validate'],$scope['tenant_id'],$scope['company_id'],$area.'.view',$unit);}Tests\Support\IdentityFixture::grant($g['actors']['prepare'],$scope['tenant_id'],$scope['company_id'],DocumentPreview::CONFIGURE);
|
||||
$sale=Tests\Support\ReceivablesFixture::create($base);DB::table('permission_grants')->whereNotIn('id',$oldGrants)->delete();
|
||||
$def=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((".substr($def,6).") OR (unit_id IS NOT NULL AND permission='supplier_payables.view'))");
|
||||
foreach(['prepare','validate','read'] as $role){$actor=$g['actors'][$role];foreach(['service_invoices.view','service_receivables.view','supplier_payables.view'] as $right)Tests\Support\IdentityFixture::grant($actor,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);foreach(match($role){'prepare'=>['service_receivables.prepare','service_receivables.allocate','service_receivables.correction_request'],'validate'=>['service_receivables.confirm','service_receivables.correction_approve'],default=>[]} as $right)Tests\Support\IdentityFixture::grant($actor,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);if($role!=='read')DB::table('pa_access')->insert($scope+['user_id'=>$actor,'person'=>'cost-qa-person-'.$actor,'action'=>$role==='prepare'?'prepare':'validate']);}
|
||||
foreach(ModuleRecords::AREAS as $area)foreach(DB::table('a_'.$area)->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->get(['id','version']) as $row)foreach(['commercial','finance','accounting'] as $module)ModuleRecords::associate($area,$row->id,$g['actors']['prepare'],['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>str_ends_with($area,'_drafts')?$scope['unit_id']:null],$module,'Explicit evidence sharing only for this NEW combined synthetic fixture.',false,$row->version);
|
||||
$common=['created_by'=>$g['actors']['prepare'],'reason'=>'Compose existing authorised source rules in one new QA book.','created_at'=>now()];$accounts=[];foreach(['QA-CLIENTES','QA-VENDAS','QA-IMPOSTO-TESTE','QA-LIQUIDACAO-CLIENTE','QA-CONTRAPARTIDA','QA-FORNECEDOR'] as $code)$accounts[$code]=DB::table('gl_accounts')->insertGetId($scope+['code'=>$code,'label'=>'Conta artificial '.$code,'is_movement'=>true,'currency'=>'MZN']);
|
||||
foreach(['QA-SALES'=>['sales.invoice','QA-CLIENTES','QA-VENDAS','QA-IMPOSTO-TESTE'],'QA-RECEIVABLE'=>['receivable.allocated','QA-LIQUIDACAO-CLIENTE','QA-CLIENTES',null],'QA-RECEIVABLE-REVERSE'=>['receivable.reversed','QA-CLIENTES','QA-LIQUIDACAO-CLIENTE',null],'QA-PAYABLE-JOURNAL'=>['supplier_payable.recognised','QA-CONTRAPARTIDA','QA-FORNECEDOR',null]] as $rule=>[$type,$d,$c,$tax])DB::table('gl_rules')->insert($scope+$common+['rule_key'=>$rule,'version'=>1,'policy'=>$type==='supplier_payable.recognised'?Tempo\Finance\GeneralLedger\PayableJournal::POLICY:'QA-SALES-JOURNAL-v1','effect_type'=>$type,'currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts[$d],'credit_account_id'=>$accounts[$c],'tax_account_id'=>$tax?$accounts[$tax]:null]);
|
||||
DB::statement('CREATE TABLE pa_imported_sources(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,payload jsonb,receipt_hash varchar(64))');$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);$doc=$bundle['source'];unset($doc['local_id']);$doc['document']=990004;$doc['number']='QA-COMPOSITION-SUPPLIER-60';$doc['payment_count']=0;$payload=Payables::canonical($doc);$local=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>$payload,'receipt_hash'=>hash('sha256',$payload)]);$finance=app(SupplierPayables::class);$p=$finance->engine($enter('prepare'))->command('prepare',$local,'Existing authorised controlled synthetic supplier60 receipt, not live import.',(string)Str::uuid());$o=$finance->engine($enter('validate'))->command('validate',$p['proposal_id'],'Distinct confirmation of original supplier60.',(string)Str::uuid());
|
||||
file_put_contents($output,json_encode(['passed'=>true,'scope'=>$scope,'invoice_id'=>$sale['invoice'],'draft_id'=>$sale['draft'],'supplier_obligation_id'=>$o['obligation_id'],'supplier_source_id'=>$local,'cost_order_id'=>$g['order_id'],'sales_accounts'=>$accounts,'initial_journals'=>1,'initial_customer_debt'=>false,'initial_correction_groups'=>0,'existing_qa_rights_union_only'=>true,'purchasing_not_activated'=>true,'fixture_day'=>'2026-10-29'],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Three source families composed in a new book: actual test invoice1000, cost275/current295 and controlled supplier debt60; no customer debt/correction/postings beyond original275.\n";exit;
|
||||
}
|
||||
$cf=json_decode(file_get_contents($s['root'].'/ledger-composition-fixture.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
if($phase==='ledger-cost-composition-financial-core'){require __DIR__.'/financial-receipt-core.php';exit;}
|
||||
if($phase==='ledger-cost-composition-financial-receipts'){require __DIR__.'/financial-receipt-proof.php';exit;}
|
||||
if($phase==='ledger-cost-composition-industrial-compatibility'){
|
||||
$out=$s['root'].'/composition-industrial-compatibility.json';if(file_exists($out))throw new LogicException('Completed compatibility preserved');$before=$sourceFingerprint();$u=$enter('read');$w=app(App\Modules\Accounting\LedgerComposition::class)->workspace($u);$p=(array)DB::table('gl_periods')->where($scope)->where('period_key','QA-2026-10')->orderBy('version')->first();$a=app(App\Modules\Accounting\SalesLedgerSource::class)->period($u,$p);$b=app(App\Modules\Accounting\CostLedgerSource::class)->period($u,$p);$engine=$ledger->engine($u);$fn=(new ReflectionProperty($engine,'additionalPeriod'))->getValue($engine);$packet=$fn($p);$report=$ledger->report($u,'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);
|
||||
if(file_exists($s['root'].'/composition-demo-baseline.json')){$hash=hash_init('sha256');foreach(['wo_orders','wo_entries','wo_decisions','wo_commands','dc_rates','dc_calculations','dc_decisions','dc_commands','dc_events','dc_outbox','permission_grants','pa_access','pa_obligations','pa_commands','si_invoices','sr_records','sr_effects','gl_proposals','gl_reviews','gl_journals','gl_lines','gl_corrections','gl_correction_results','gl_period_checks','gl_period_reviews','gl_period_closures','gl_commands'] as $table){hash_update($hash,$table);hash_update($hash,DB::table($table)->orderBy($table==='dc_outbox'?'event_id':'id')->get()->toJson());}if(hash_final($hash)!==json_decode(file_get_contents($s['root'].'/composition-demo-baseline.json'),true)['fingerprint'])throw new LogicException('Old team composition changed since its valid baseline');}
|
||||
if(config('industrial_ledger.synthetic_enabled')!==false||DB::getSchemaBuilder()->hasTable('gl_industrial_sources')||array_keys($w['panels'])!==['sales','costs','suppliers']||$packet!==['facts'=>$a['facts']+$b['facts'],'pending'=>$a['pending']+$b['pending'],'hash'=>hash('sha256',$a['hash'].$b['hash'])]||$report['totals']['debit_minor']!==($w['period']->status==='closed'?330500:27500)||$sourceFingerprint()!==$before)throw new LogicException('Existing8057 composition/provider packet or source state changed');file_put_contents($out,json_encode(['passed'=>true,'old8057_industrial_flag_off'=>true,'new_schema_absent'=>true,'existing_three_panels_unchanged'=>true,'two_provider_packet_hash_unchanged'=>true,'observed_gross_minor'=>$report['totals']['debit_minor'],'source_outbox_rights_unchanged'=>true,'read_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Existing8057 three-source composition/source/hash preserved, industrial default off/schema absent.\n";exit;
|
||||
}
|
||||
if(in_array($phase,['ledger-cost-composition-baseline','ledger-cost-composition-preservation'],true)){
|
||||
$base=$s['root'].'/composition-demo-baseline.json';$out=$s['root'].'/composition-demo-preservation.json';$hash=hash_init('sha256');foreach(['wo_orders','wo_entries','wo_decisions','wo_commands','dc_rates','dc_calculations','dc_decisions','dc_commands','dc_events','dc_outbox','permission_grants','pa_access','pa_obligations','pa_commands','si_invoices','sr_records','sr_effects','gl_proposals','gl_reviews','gl_journals','gl_lines','gl_corrections','gl_correction_results','gl_period_checks','gl_period_reviews','gl_period_closures','gl_commands'] as $t){hash_update($hash,$t);hash_update($hash,DB::table($t)->orderBy($t==='dc_outbox'?'event_id':'id')->get()->toJson());}$fingerprint=hash_final($hash);
|
||||
if($phase==='ledger-cost-composition-baseline'){if(file_exists($base))throw new LogicException('Completed baseline preserved');file_put_contents($base,json_encode(['passed'=>true,'fingerprint'=>$fingerprint],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Composition team baseline recorded privately.\n";exit;}
|
||||
if(file_exists($out)||$fingerprint!==json_decode(file_get_contents($base),true)['fingerprint']||DB::table('gl_journals')->count()!==1||DB::table('gl_corrections')->exists()||DB::table('gl_period_closures')->exists()||DB::table('sr_effects')->exists())throw new LogicException('Team composition changed; no reset');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'all_business_source_outbox_rights_book_hashes_unchanged'=>true,'initial_journals'=>1,'initial_corrections'=>0,'initial_customer_effects'=>0,'initial_closures'=>0,'real_or_public_grants'=>0,'new_effect_types'=>0,'no_reset'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Team composition preserved after linked read/form proofs; no business commands.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-composition-workflow'){require __DIR__.'/ledger-composition-workflow.php';exit;}
|
||||
if($phase==='ledger-cost-composition-cases'){require __DIR__.'/ledger-composition-cases.php';exit;}
|
||||
throw new LogicException('Unknown composition phase');
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Modules\Services\ServiceReceivables;
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$costG=$g;$cg=json_decode(file_get_contents($s['root'].'/cost-correction-fixture.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
$preserved=[];foreach(['wo_orders','wo_entries','wo_decisions','dc_rates','dc_calculations','dc_decisions','dc_outbox','permission_grants'] as $table)$preserved[$table]=hash('sha256',DB::table($table)->orderBy($table==='dc_outbox'?'event_id':'id')->get()->toJson());
|
||||
$g['invoice_id']=$cf['invoice_id'];$g['accounts']=$cf['sales_accounts'];$sr=app(ServiceReceivables::class);
|
||||
|
||||
if(file_exists($s['root'].'/composition-workflow.json')||DB::table('sr_effects')->exists()||DB::table('gl_journals')->count()!==(config('industrial_ledger.correction_enabled')===true?4:(config('industrial_ledger.synthetic_enabled')===true?2:1)))throw new LogicException('Fresh unconsumed sales workflow required');
|
||||
$checks=0;$ok=function($v,$why)use(&$checks){$checks++;if(!$v)throw new LogicException($why);};
|
||||
$originHash=hash('sha256',DB::table('si_invoices')->where('id',$g['invoice_id'])->value('snapshot'));
|
||||
$p=$sr->propose($enter('prepare')->id,['kind'=>'obligation','invoice_id'=>$g['invoice_id'],'currency'=>'MZN','source'=>'TEST-MANUAL','external_id'=>'SALES-QA-OPEN','revision'=>0,'reason'=>'Explicit financial opening of the actual test invoice.'],(string)Str::uuid());$ob=$p['id'];
|
||||
$sr->decide($ob,$enter('validate')->id,['version'=>1,'revision'=>0,'decision'=>'approved','reason'=>'Independent documentary opening review.'],(string)Str::uuid());
|
||||
$post=function($kind,$id,$rule)use($command){$p=$command('prepare','effect-prepare',['effect_type'=>$kind,'source_id'=>$id,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>$rule,'reason'=>'Capture exact synthetic commercial financial effect.']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent exact commercial accounting review.']);$body=['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply reviewed commercial effect once.'];$key=(string)Str::uuid();$j=$command('prepare','post',$body,$key);return [$j,$command('prepare','post',$body,$key)];};
|
||||
[$invoiceJournal,$repeat]=$post('sales.invoice',$g['invoice_id'],'QA-SALES');$ok($repeat['replayed']&&DB::table('gl_lines')->count()===(config('industrial_ledger.correction_enabled')===true?11:(config('industrial_ledger.synthetic_enabled')===true?7:5)),'Three-line invoice posted once');
|
||||
$allocations=[];$receipts=[];$rev=fn()=>DB::table('sr_effects')->where('obligation_id',$ob)->count();
|
||||
foreach(['400.00','600.00'] as $n=>$amount){
|
||||
$p=$sr->propose($enter('prepare')->id,['kind'=>'receipt','invoice_id'=>$g['invoice_id'],'obligation_id'=>$ob,'currency'=>'MZN','amount'=>$amount,'date'=>'2026-10-29','evidence'=>'Explicit synthetic receipt; no money movement.','source'=>'TEST-MANUAL','external_id'=>'SALES-QA-RECEIPT-'.$n,'revision'=>$rev(),'reason'=>'Individual receipt preparation.'],(string)Str::uuid());$receipts[]=$p['id'];
|
||||
$sr->decide($p['id'],$enter('validate')->id,['version'=>1,'revision'=>$rev(),'decision'=>'approved','reason'=>'Independent receipt confirmation.'],(string)Str::uuid());
|
||||
$a=$sr->allocate($p['id'],$enter('prepare')->id,['version'=>1,'revision'=>$rev(),'amount'=>$amount],(string)Str::uuid());$allocations[]=$a['id'];
|
||||
$position=$sr->position($ob,$enter('read')->id);$ok($position['balance']===($n===0?'600.00':'0.00'),'Authoritative partial/full balance');
|
||||
$post('receivable.allocated',$a['id'],'QA-RECEIVABLE');$ok($sr->position($ob,$enter('read')->id)['balance']===$position['balance'],'Accounting never deducts financial balance again');
|
||||
}
|
||||
$p=$sr->propose($enter('prepare')->id,['kind'=>'correction','invoice_id'=>$g['invoice_id'],'obligation_id'=>$ob,'currency'=>'MZN','source'=>'TEST-MANUAL','external_id'=>'SALES-QA-REVERSE-400','revision'=>$rev(),'action'=>'reverse_allocation','target_effect_id'=>$allocations[0],'reason'=>'Reverse first synthetic allocation with original preserved.'],(string)Str::uuid());
|
||||
$reversal=$sr->decide($p['id'],$enter('validate')->id,['version'=>1,'revision'=>$rev(),'decision'=>'approved','reason'=>'Independent integral allocation reversal review.'],(string)Str::uuid());
|
||||
$ok($sr->position($ob,$enter('read')->id)['balance']==='400.00','Financial reversal restores400');$post('receivable.reversed',$reversal['id'],'QA-RECEIVABLE-REVERSE');
|
||||
$ok($sr->position($ob,$enter('read')->id)['balance']==='400.00','Reversal journal does not affect financial balance');
|
||||
$p=$command('prepare','prepare',['obligation_id'=>$cf['supplier_obligation_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-PAYABLE-JOURNAL','reason'=>'Capture original approved supplier60 in combined QA book.']);
|
||||
$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Distinct supplier journal review.']);
|
||||
$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply original supplier60 once.']);
|
||||
$p=$command('prepare','correct-prepare',['journal_id'=>$cg['original_journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Previously authorised indivisible cost275 to295 in combined book.']);
|
||||
$command('validate','correct-validate',['correction_id'=>$p['correction_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Distinct exact original275 and successor295 pair review.']);
|
||||
$pairBody=['correction_id'=>$p['correction_id'],'revision'=>$p['revision'],'reason'=>'Apply both cost correction parts atomically.'];$pairKey=(string)Str::uuid();$pair=$command('prepare','correct-post',$pairBody,$pairKey);$again=$command('prepare','correct-post',$pairBody,$pairKey);$other=$command('prepare','correct-post',$pairBody);
|
||||
$ok($again['replayed']&&$pair['result_id']===$other['result_id']&&DB::table('gl_correction_results')->count()===(config('industrial_ledger.correction_enabled')===true?2:1),'Cost correction replay never duplicates a pair');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Capture full commercial cycle and pending evidence.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent reconciled sales period review.']);$closeBody=['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Close artificial commercial period once.'];$closeKey=(string)Str::uuid();
|
||||
foreach(array_merge(['service_receivables.view','direct_costs.view','supplier_payables.view'],config('industrial_ledger.synthetic_enabled')===true?['industrial_costs.view']:[]) as $right){DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where('user_id',$costG['actors']['prepare'])->where('permission',$right)->delete();$caught=false;try{$command('prepare','period-close',$closeBody,$closeKey);}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught&&!DB::table('gl_period_closures')->exists(),'Joint close revalidates provider permission: '.$right);}finally{DB::rollBack();}}
|
||||
DB::beginTransaction();try{
|
||||
$u=$enter('prepare');$r=$dc->rate($costG['order_id'],$u->id,['entry_id'=>$costG['material_id'],'entry_version'=>1,'identifier'=>'QA-COST-MATERIAL','version'=>3,'previous_id'=>$cg['replacement_rate_id'],'currency'=>'MZN','unit'=>'UN','amount'=>'60.00','evidence'=>'Rollback-only same-value successor to test joint version conference.'],(string)Str::uuid());
|
||||
$dc->decide($costG['order_id'],$enter('validate')->id,['kind'=>'rate','id'=>$r['id'],'version'=>3,'decision'=>'approved','reason'=>'Rollback-only distinct successor rate review.'],(string)Str::uuid());
|
||||
$calc=$dc->calculate($costG['order_id'],$enter('prepare')->id,['version'=>3,'previous_id'=>$cg['replacement_calculation_id'],'currency'=>'MZN','lines'=>[['entry_id'=>$costG['material_id'],'entry_version'=>1,'rate_id'=>$r['id'],'rate_version'=>3],['entry_id'=>$costG['time_id'],'entry_version'=>1,'rate_id'=>$costG['rate_ids']['time'],'rate_version'=>1]]],(string)Str::uuid());
|
||||
$dc->decide($costG['order_id'],$enter('validate')->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>3,'decision'=>'approved','reason'=>'Rollback-only same amount295 with new authoritative version.'],(string)Str::uuid());
|
||||
$caught=false;try{$command('prepare','period-close',$closeBody,$closeKey);}catch(DomainException){$caught=true;}$ok($caught&&!DB::table('gl_period_closures')->exists(),'Joint close refuses changed cost version even at same amount295');
|
||||
}finally{DB::rollBack();}
|
||||
if(config('industrial_ledger.synthetic_enabled')===true){DB::beginTransaction();try{$industrial=app(App\Modules\Industry\IndustrialCosts::class);$fixture=json_decode(file_get_contents($s['root'].'/industrial-fixture.json'),true);$last=DB::table('ic_inputs')->where('lot_id',$fixture['lot_id'])->where('kind','material_rate')->orderByDesc('version')->first();$r=$industrial->input($fixture['lot_id'],$enter('prepare')->id,['kind'=>'material_rate','source_ref'=>$last->source_ref,'version'=>$last->version+1,'previous_id'=>$last->id,'data'=>json_decode($last->data,true),'evidence'=>'Rollback-only same-value industrial version before joint close.'],(string)Str::uuid());$industrial->decide($fixture['lot_id'],$enter('validate')->id,['type'=>'input','id'=>$r['id'],'version'=>$last->version+1,'decision'=>'approved','reason'=>'Distinct same-value industrial successor; new conference required.'],(string)Str::uuid());$caught=false;try{$command('prepare','period-close',$closeBody,$closeKey);}catch(DomainException){$caught=true;}$ok($caught&&!DB::table('gl_period_closures')->exists(),'Joint close revalidates industrial dependency version at same amount');}finally{DB::rollBack();}}
|
||||
if(isset($compositionCloseRace))$compositionCloseRace($closeBody);
|
||||
$closed=$command('prepare','period-close',$closeBody,$closeKey);$closedRepeat=$command('prepare','period-close',$closeBody,$closeKey);$closedOther=$command('prepare','period-close',$closeBody);$ok($closedRepeat['replayed']&&$closedOther['closure_id']===$closed['closure_id']&&DB::table('gl_period_closures')->count()===1,'Recover after rejected stale/missing-right close without duplication');
|
||||
$book=$ledger->listing($enter('read'));$ok($book['totals']===['debit_minor'=>(config('industrial_ledger.correction_enabled')===true?872500:(config('industrial_ledger.synthetic_enabled')===true?510500:330500)),'credit_minor'=>(config('industrial_ledger.correction_enabled')===true?872500:(config('industrial_ledger.synthetic_enabled')===true?510500:330500)),'net_minor'=>0]&&count($book['journals'])===(config('industrial_ledger.correction_enabled')===true?11:(config('industrial_ledger.synthetic_enabled')===true?9:8))&&count($book['lines'])===(config('industrial_ledger.correction_enabled')===true?23:(config('industrial_ledger.synthetic_enabled')===true?19:17)),'Eight journals/seventeen lines reconcile to gross3305 each side');
|
||||
$client=$ledger->listing($enter('read'),['account_id'=>$g['accounts']['QA-CLIENTES']]);$ok($client['totals']['net_minor']===40000,'Client account agrees with financial balance400');
|
||||
$ok($originHash===hash('sha256',DB::table('si_invoices')->where('id',$g['invoice_id'])->value('snapshot')),'Original issued invoice remains immutable');DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');
|
||||
$proof=(['passed'=>true,'checks'=>$checks,'invoice_id'=>$g['invoice_id'],'obligation_id'=>$ob,'receipts'=>$receipts,'allocations'=>$allocations,'reversal_effect_id'=>$reversal['id'],'invoice_journal_id'=>$invoiceJournal['journal_id'],'journals'=>(config('industrial_ledger.correction_enabled')===true?11:(config('industrial_ledger.synthetic_enabled')===true?9:8)),'lines'=>(config('industrial_ledger.correction_enabled')===true?23:(config('industrial_ledger.synthetic_enabled')===true?19:17)),'gross_debit_minor'=>(config('industrial_ledger.correction_enabled')===true?872500:(config('industrial_ledger.synthetic_enabled')===true?510500:330500)),'gross_credit_minor'=>(config('industrial_ledger.correction_enabled')===true?872500:(config('industrial_ledger.synthetic_enabled')===true?510500:330500)),'client_balance_minor'=>40000,'financial_balance_minor'=>40000,'period_closed'=>true,'origin_unchanged'=>true]);
|
||||
|
||||
foreach($preserved as $table=>$hash)$ok(hash('sha256',DB::table($table)->orderBy($table==='dc_outbox'?'event_id':'id')->get()->toJson())===$hash,'Source/outbox/rights unchanged: '.$table);
|
||||
$ok(hash('sha256',Payables::canonical((array)DB::table('gl_journals')->where('id',$cg['original_journal_id'])->first()))===$cg['original_journal_sha256'],'Original cost journal immutable');
|
||||
$ok($ledger->report($enter('read'),'account-ledger',['from_on'=>'2026-10-01','to_on'=>'2026-10-31','account_id'=>$costG['accounts']['QA-CUSTO-DIRECTO']])['closing_net_minor']===29500,'Cost account reconciles295');
|
||||
$ok(app(SupplierPayables::class)->engine($enter('read'))->positionForSource($cf['supplier_source_id'])['balance_minor']===6000,'Supplier debt unchanged60');
|
||||
foreach(['sales_invoice_id'=>[$cf['invoice_id'],4,240000],'cost_order_id'=>[$costG['order_id'],3,84500],'obligation_id'=>[$cf['supplier_obligation_id'],1,6000]] as $filter=>[$id,$count,$amount]){$book=$ledger->listing($enter('read'),[$filter=>$id]);$ok(count($book['journals'])===$count&&$book['totals']['debit_minor']===$amount,'Typed source filter: '.$filter);}
|
||||
$workspace=app(App\Modules\Accounting\LedgerComposition::class)->workspace($enter('read'));$ok($workspace['all_current_source_views']&&$workspace['book']['accounts']===[]&&$workspace['period']->status==='closed','Unified read workspace and joint closed period');
|
||||
$proof+=['combined_checks'=>$checks,'source_outbox_rights_preserved'=>true,'supplier_balance_minor'=>6000,'cost_balance_minor'=>29500,'typed_source_filters_separate'=>true,'joint_period_closed'=>true];file_put_contents($s['root'].'/composition-workflow.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
|
||||
echo 'Combined composition: '.$checks." checks, journal totals reconciled and joint QA close.\n";
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$output=$s['root'].'/cost-cases.json';if(file_exists($output)||DB::table('gl_proposals')->exists())throw new LogicException('Fresh cost regression book required');$before=$sourceFingerprint();$checks=0;$cases=[];
|
||||
$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};
|
||||
$refuse=function($fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught,'Expected cost source/permission/version refusal');};
|
||||
$case=function($name,$fn)use(&$cases,&$checks,$enter){$at=$checks;DB::beginTransaction();try{$fn();$cases[]=['name'=>$name,'checks'=>$checks-$at,'passed'=>true];}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}};
|
||||
$capture=fn()=>$command('prepare','effect-prepare',$body);$approve=fn($p)=>$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent exact cost conference.']);$postBody=fn($p)=>['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Individual actual selected cost posting.'];
|
||||
$newCost=function($materialAmount=null,$timeAmount=null,$partial=false)use($dc,$enter,$g){
|
||||
$rates=$g['rate_ids'];$versions=['material'=>1,'time'=>1];foreach(['material'=>$materialAmount,'time'=>$timeAmount] as $kind=>$amount)if($amount!==null){$u=$enter('prepare');$r=$dc->rate($g['order_id'],$u->id,['entry_id'=>$g[$kind.'_id'],'entry_version'=>1,'identifier'=>'QA-COST-'.strtoupper($kind),'version'=>2,'previous_id'=>$rates[$kind],'currency'=>'MZN','unit'=>$kind==='time'?'H':'UN','amount'=>$amount,'evidence'=>'Explicit rollback-only successor rate.'],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'rate','id'=>$r['id'],'version'=>2,'decision'=>'approved','reason'=>'Independent successor rate QA review.'],(string)Str::uuid());$rates[$kind]=$r['id'];$versions[$kind]=2;}
|
||||
$lines=[];foreach(['material','time'] as $kind)$lines[]=['entry_id'=>$g[$kind.'_id'],'entry_version'=>1,'rate_id'=>$partial&&$kind==='material'?null:$rates[$kind],'rate_version'=>$partial&&$kind==='material'?null:$versions[$kind]];$u=$enter('prepare');$r=$dc->calculate($g['order_id'],$u->id,['version'=>2,'previous_id'=>$g['calculation_id'],'currency'=>'MZN','lines'=>$lines],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'calculation','id'=>$r['id'],'version'=>2,'decision'=>'approved','reason'=>'Validate selected composition only, preserve incompleteness where applicable.'],(string)Str::uuid());return $r;
|
||||
};
|
||||
$case('complete-source-rights-and-explicit-impact',function()use($ledger,$enter,$g,$capture,$command,$approve,$postBody,$ok,$refuse){
|
||||
$w=$ledger->costs($enter('read'),$g['order_id']);$ok($w['cost']['source']['principal_minor']===27500&&$w['book']['journals']===[],'Approved cost is actual275 and not automatically posted');$p=$capture();$snap=json_decode(DB::table('gl_proposals')->where('id',$p['proposal_id'])->value('snapshot'),true);$ok(array_column($snap['lines'],'account_code')===['QA-CUSTO-DIRECTO','QA-COMPENSACAO-ENSAIO'],'Artificial impact explicit before review');$refuse(fn()=>$command('read','post',$postBody($p)));$refuse(fn()=>$command('prepare','post',$postBody($p)));$approve($p);$j=$command('prepare','post',$postBody($p));$other=$command('prepare','post',$postBody($p));$ok($j['journal_id']===$other['journal_id']&&DB::table('gl_journals')->count()===1,'Different key recovers one source effect');$refuse($capture);$refuse(fn()=>$ledger->costs($enter('read'),999999));
|
||||
});
|
||||
$case('partial175-and-non-cent-representable-cost',function()use($newCost,$ledger,$enter,$g,$refuse,$capture,$ok,$command){$newCost(null,null,true);$w=$ledger->costs($enter('read'),$g['order_id']);$ok($w['cost']['facts']['conference']['snapshot']['known_subtotal_exact']==='175'&&$w['cost']['source']===null&&$w['cost']['pending']!==null,'Approved incomplete composition retains175 known and no total');$refuse($capture);$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Incomplete source cannot close.']));});
|
||||
$case('exact-rational-no-rounding',function()use($newCost,$capture,$refuse,$ledger,$enter,$g,$ok){$newCost(null,'0.01');$w=$ledger->costs($enter('read'),$g['order_id']);$ok($w['cost']['facts']['conference']['snapshot']['selected_direct_total_exact']==='100.0175','Actual calculator preserves exact non-cent value');$refuse($capture);});
|
||||
$case('revoked-source-rights-and-old-capture',function()use($capture,$approve,$command,$postBody,$newCost,$refuse,$g){$p=$capture();$approve($p);$newCost('60.00');$refuse(fn()=>$command('prepare','post',$postBody($p)));});
|
||||
$case('revoked-rights-before-recovery',function()use($capture,$approve,$command,$postBody,$refuse,$scope,$g){$p=$capture();$approve($p);$key=(string)Str::uuid();$command('prepare','post',$postBody($p),$key);DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','general_ledger.post')->delete();$refuse(fn()=>$command('prepare','post',$postBody($p),$key));});
|
||||
$case('source-access-and-person-independence',function()use($capture,$approve,$refuse,$scope,$g,$enter,$command){$p=$capture();DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['prepare']]);$refuse(fn()=>$approve($p));DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','workshop.view')->delete();$refuse($capture);});
|
||||
$case('fault-boundaries-atomic-recovery',function()use($capture,$approve,$postBody,$command,$ok){$p=$capture();$approve($p);foreach(['after_header','after_lines','after_event','after_receipt'] as $step){$caught=false;$key=(string)Str::uuid();$count=DB::table('gl_commands')->count();try{$command('prepare','post',$postBody($p),$key,function($name)use($step){if($name===$step)throw new RuntimeException('injected-'.$step);});}catch(RuntimeException $e){$caught=str_starts_with($e->getMessage(),'injected-');}$ok($caught&&!DB::table('gl_journals')->exists()&&!DB::table('gl_lines')->exists()&&DB::table('gl_commands')->count()===$count,'Atomic rollback at '.$step);}$key=(string)Str::uuid();$one=$command('prepare','post',$postBody($p),$key);$two=$command('prepare','post',$postBody($p),$key);$ok($two['replayed']&&$one['journal_id']===$two['journal_id'],'Retry after failures recovers one complete result');});
|
||||
$case('stable-perimeter-no-second-recognition-and-close-refusal',function()use($capture,$approve,$command,$postBody,$newCost,$refuse,$ok){$p=$capture();$approve($p);$command('prepare','post',$postBody($p));$newCost('60.00');$refuse($capture);$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Changed already-posted cost requires an explicit future correction scope.']));$ok(DB::table('gl_journals')->count()===1,'Successor calculation cannot create another principal recognition');});
|
||||
$case('guards-and-perimeter-immutability',function()use($ok){$old=config('cost_ledger.synthetic_enabled');config(['cost_ledger.synthetic_enabled'=>false]);$caught=false;try{App\Shared\Testing\TestingDatabaseGuard::assertSafe();}catch(RuntimeException){$caught=true;}finally{config(['cost_ledger.synthetic_enabled'=>$old]);}$ok($caught,'No QA flag means guard refuses this isolated extension');DB::beginTransaction();try{DB::table('gl_cost_sources')->update(['reason'=>'Cannot replace selected perimeter.']);throw new LogicException('Expected append-only refusal');}catch(Illuminate\Database\QueryException){DB::rollBack();$ok(true,'Mapping immutable');}});
|
||||
$ok($sourceFingerprint()===$before&&!DB::table('gl_journals')->exists()&&!DB::table('gl_proposals')->exists(),'All original workshop/cost/outbox/rights state restored after rollback-only cases');file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'cases'=>$cases,'rollback_only'=>true,'source_outbox_rights_unchanged'=>true,'no_rounding_or_duplicate_recognition'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost regression: ".count($cases)." cases/$checks checks; original sources/outbox/rights unchanged.\n";
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$output=$s['root'].'/cost-coexistence.json';if(file_exists($output)||DB::table('pa_obligations')->exists()||DB::table('gl_proposals')->exists())throw new LogicException('Fresh mixed cost/supplier fixture required');$costBefore=app(App\Modules\Accounting\CostLedgerSource::class)->source($enter('read'),$g['order_id']);$outboxBefore=DB::table('dc_outbox')->orderBy('event_id')->get()->toJson();
|
||||
DB::statement('CREATE TABLE pa_imported_sources(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,payload jsonb,receipt_hash varchar(64))');$def=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((".substr($def,6).") OR (unit_id IS NOT NULL AND permission='supplier_payables.view'))");
|
||||
foreach(['prepare','validate','read'] as $role){$actor=$g['actors'][$role];Tests\Support\IdentityFixture::grant($actor,$scope['tenant_id'],$scope['company_id'],'supplier_payables.view',$scope['unit_id']);if($role!=='read')DB::table('pa_access')->insert($scope+['user_id'=>$actor,'action'=>$role==='prepare'?'prepare':'validate','person'=>'cost-qa-person-'.$actor]);}
|
||||
$accounts=[];foreach(['QA-CONTRAPARTIDA','QA-FORNECEDOR'] as $code)$accounts[$code]=DB::table('gl_accounts')->insertGetId($scope+['code'=>$code,'label'=>'Conta artificial para coexistência com custo','is_movement'=>true,'currency'=>'MZN']);DB::table('gl_rules')->insert($scope+['created_by'=>$g['actors']['prepare'],'reason'=>'Explicit existing supplier60 rule alongside selected cost275.','created_at'=>now(),'rule_key'=>'QA-PAYABLE-JOURNAL','version'=>1,'policy'=>Tempo\Finance\GeneralLedger\PayableJournal::POLICY,'effect_type'=>'supplier_payable.recognised','currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts['QA-CONTRAPARTIDA'],'credit_account_id'=>$accounts['QA-FORNECEDOR']]);
|
||||
$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);$doc=$bundle['source'];unset($doc['local_id']);$doc['document']=990002;$doc['number']='QA-COST-MIXED-SUPPLIER-60';$doc['payment_count']=0;$payload=Payables::canonical($doc);$local=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>$payload,'receipt_hash'=>hash('sha256',$payload)]);$payable=app(SupplierPayables::class);$p=$payable->engine($enter('prepare'))->command('prepare',$local,'Explicit controlled synthetic supplier receipt reused for integration regression.',(string)Str::uuid());$o=$payable->engine($enter('validate'))->command('validate',$p['proposal_id'],'Independent existing supplier60 recognition in separate integration fixture.',(string)Str::uuid());if($o['obligation_id']!==$g['order_id'])throw new LogicException('Fresh fixture must exercise coinciding supplier obligation/order IDs');
|
||||
$post=function($p)use($command){$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent exact typed source review.']);return $command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply typed source once in shared synthetic book.']);};$cost=$post($command('prepare','effect-prepare',$body));$supplier=$post($command('prepare','prepare',['obligation_id'=>$o['obligation_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-PAYABLE-JOURNAL','reason'=>'Capture approved supplier60 alongside independent technical cost275.']));
|
||||
$costBook=$ledger->listing($enter('read'),['cost_order_id'=>$g['order_id']]);$supplierBook=$ledger->listing($enter('read'),['obligation_id'=>$o['obligation_id']]);$report=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);
|
||||
if(count($costBook['journals'])!==1||count($costBook['events'])!==3||$costBook['totals']['debit_minor']!==27500||count($supplierBook['journals'])!==1||count($supplierBook['events'])!==3||$supplierBook['totals']['debit_minor']!==6000||$report['totals']['debit_minor']!==33500||DB::table('gl_journals')->count()!==2||DB::table('gl_lines')->count()!==4)throw new LogicException('Cost and supplier namespaces mixed or reports unreconciled');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Joint completeness of selected cost and independently recognised supplier effect.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent joint source conference.']);$command('prepare','period-close',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Close both source families once.']);
|
||||
if(Payables::canonical(app(App\Modules\Accounting\CostLedgerSource::class)->source($enter('read'),$g['order_id']))!==Payables::canonical($costBefore)||DB::table('dc_outbox')->orderBy('event_id')->get()->toJson()!==$outboxBefore||$payable->engine($enter('read'))->positionForSource($local)['balance_minor']!==6000)throw new LogicException('Accounting changed authoritative cost/outbox or supplier balance');DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'coinciding_order_supplier_obligation_ids'=>true,'one_writer'=>true,'cost_filter_journals'=>1,'cost_filter_events'=>3,'supplier_filter_journals'=>1,'supplier_filter_events'=>3,'journals'=>2,'lines'=>4,'debit_minor'=>33500,'credit_minor'=>33500,'actual_selected_cost_minor'=>27500,'supplier_balance_minor'=>6000,'cost_source_and_outbox_unchanged'=>true,'joint_qa_period_closed'=>true,'supplier_receipt_controlled_synthetic_not_live_import'=>true,'no_payment_or_stock_effect'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Mixed cost/supplier book: coinciding IDs isolated,275+60=335MZN, two journals/four lines and one joint period close; authority preserved.\n";
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$output=$s['root'].'/cost-correction-cases.json';if(file_exists($output)||DB::table('gl_corrections')->exists())throw new LogicException('Fresh correction regression required');$checks=0;$cases=[];$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};$refuse=function($fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught,'Expected individual/group/source/authority refusal');};
|
||||
$case=function($name,$fn)use(&$cases,&$checks,$enter){$at=$checks;DB::beginTransaction();try{$fn();$cases[]=['name'=>$name,'checks'=>$checks-$at,'passed'=>true];}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}};
|
||||
$revise=function()use($dc,$enter,$g,$cg){$u=$enter('prepare');$r=$dc->rate($g['order_id'],$u->id,['entry_id'=>$g['material_id'],'entry_version'=>1,'identifier'=>'QA-COST-MATERIAL','version'=>3,'previous_id'=>$cg['replacement_rate_id'],'currency'=>'MZN','unit'=>'UN','amount'=>'60.00','evidence'=>'Version-only successor changes evidence without changing295.'],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'rate','id'=>$r['id'],'version'=>3,'decision'=>'approved','reason'=>'Independent version-only successor rate review.'],(string)Str::uuid());$u=$enter('prepare');$calc=$dc->calculate($g['order_id'],$u->id,['version'=>3,'previous_id'=>$cg['replacement_calculation_id'],'currency'=>'MZN','lines'=>[['entry_id'=>$g['material_id'],'entry_version'=>1,'rate_id'=>$r['id'],'rate_version'=>3],['entry_id'=>$g['time_id'],'entry_version'=>1,'rate_id'=>$g['rate_ids']['time'],'rate_version'=>1]]],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>3,'decision'=>'approved','reason'=>'Independent unchanged295 with new source version.'],(string)Str::uuid());};
|
||||
$case('explicit-asymmetric-impact-and-indivisible-application',function()use($captureCorrection,$approveCorrection,$command,$postCorrectionBody,$ledger,$enter,$g,$ok,$refuse){$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Uncorrected current295 blocks completeness.']));$p=$captureCorrection();$c=DB::table('gl_corrections')->where('id',$p['correction_id'])->first();$a=DB::table('gl_proposals')->where('id',$c->reverse_proposal_id)->first();$b=DB::table('gl_proposals')->where('id',$c->replacement_proposal_id)->first();$sa=json_decode($a->snapshot,true);$sb=json_decode($b->snapshot,true);$ok($sa['impact']['debit_minor']===27500&&$sb['impact']['debit_minor']===29500&&$sa['source']['effect_type']==='workshop.direct_cost.reversal'&&$sb['source']['effect_type']==='workshop.direct_cost.replacement','Four lines explicitly capture275/295 and distinct cost types');$refuse(fn()=>$command('validate','validate',['proposal_id'=>$a->id,'revision'=>$a->revision,'decision'=>'approved','reason'=>'Cannot review isolated pair part.']));$approveCorrection($p);$refuse(fn()=>$command('prepare','post',['proposal_id'=>$a->id,'revision'=>$a->revision,'reason'=>'Cannot post isolated pair part.']));$key=(string)Str::uuid();$r=$command('prepare','correct-post',$postCorrectionBody($p),$key);$same=$command('prepare','correct-post',$postCorrectionBody($p),$key);$other=$command('prepare','correct-post',$postCorrectionBody($p));$ok($same['replayed']&&$other['result_id']===$r['result_id']&&DB::table('gl_journals')->count()===3,'Same/other key and source identity recover one pair');$refuse($captureCorrection);$book=$ledger->listing($enter('read'),['cost_order_id'=>$g['order_id']]);$ok(count($book['journals'])===3&&count($book['events'])===9&&$book['totals']['debit_minor']===84500,'Typed original and both pair parts retain correct history and gross total');});
|
||||
$case('faults-at-pair-boundaries-rollback-and-recovery',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$ok){$p=$captureCorrection();$approveCorrection($p);foreach(['after_header','after_lines','after_event','after_correction_first','after_correction_result','after_receipt'] as $step){$caught=false;$count=DB::table('gl_commands')->count();try{$command('prepare','correct-post',$postCorrectionBody($p),(string)Str::uuid(),function($name)use($step){if($name===$step)throw new RuntimeException('cost-pair-injected-'.$step);});}catch(RuntimeException $e){$caught=str_starts_with($e->getMessage(),'cost-pair-injected-');}$ok($caught&&DB::table('gl_journals')->count()===1&&DB::table('gl_lines')->count()===2&&!DB::table('gl_correction_results')->exists()&&DB::table('gl_commands')->count()===$count,'No partial pair/result/receipt at '.$step);}$r=$command('prepare','correct-post',$postCorrectionBody($p));$ok(DB::table('gl_journals')->count()===3&&DB::table('gl_correction_results')->count()===1,'Recovery completes both parts exactly once');});
|
||||
$case('database-rejects-orphan-cost-reversal',function()use($captureCorrection,$approveCorrection,$ledger,$enter,$ok){$p=$captureCorrection();$approveCorrection($p);$c=DB::table('gl_corrections')->where('id',$p['correction_id'])->first();$part=DB::table('gl_proposals')->where('id',$c->reverse_proposal_id)->first();$engine=$ledger->engine($enter('prepare'));$caught=false;DB::beginTransaction();try{(new ReflectionMethod($engine,'post'))->invoke($engine,['proposal_id'=>$part->id,'revision'=>$part->revision],'SQL adversarial orphan probe, rollback only.');DB::statement('SET CONSTRAINTS ALL IMMEDIATE');}catch(Illuminate\Database\QueryException $e){$caught=str_contains($e->getMessage(),'GL_COST_CORRECTION_PAIR_REQUIRED');}finally{DB::rollBack();}$ok($caught&&DB::table('gl_journals')->count()===1&&!DB::table('gl_correction_results')->exists(),'Deferred SQL constraint rejects balanced individual orphan');});
|
||||
$case('changed-source-version-requires-new-conference',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$revise,$refuse,$ok){$old=$captureCorrection();$approveCorrection($old);$revise();$refuse(fn()=>$command('prepare','correct-post',$postCorrectionBody($old)));$fresh=$captureCorrection();$ok($fresh['revision']!==$old['revision']&&$fresh['correction_id']!==$old['correction_id'],'New exact version creates another immutable group capture');$refuse(fn()=>$approveCorrection($old));$approveCorrection($fresh);$command('prepare','correct-post',$postCorrectionBody($fresh));$ok(DB::table('gl_correction_results')->count()===1&&DB::table('gl_journals')->count()===3,'Only newly conferred group applies');});
|
||||
$case('rights-person-and-replay-authority',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$scope,$g,$refuse){$p=$captureCorrection();DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['prepare']]);$refuse(fn()=>$approveCorrection($p));DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['validate']]);$approveCorrection($p);$key=(string)Str::uuid();$command('prepare','correct-post',$postCorrectionBody($p),$key);DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','general_ledger.correct_post')->delete();$refuse(fn()=>$command('prepare','correct-post',$postCorrectionBody($p),$key));});
|
||||
$case('readers-foreign-original-disabled-capability-and-closed-period',function()use($command,$correctionBody,$captureCorrection,$scope,$g,$refuse){$refuse(fn()=>$command('read','correct-prepare',$correctionBody));$refuse(fn()=>$command('prepare','correct-prepare',array_replace($correctionBody,['journal_id'=>999999])));$refuse(fn()=>$command('prepare','correct-prepare',array_replace($correctionBody,['journal_on'=>'2026-10-28'])));$old=config('cost_ledger.correction_enabled');config(['cost_ledger.correction_enabled'=>false]);try{$refuse($captureCorrection);}finally{config(['cost_ledger.correction_enabled'=>$old]);}$period=(array)DB::table('gl_periods')->where($scope)->first();unset($period['id']);$period['version']=2;$period['status']='closed';DB::table('gl_periods')->insert($period);$refuse($captureCorrection);});
|
||||
$case('new-change-after-correction-blocks-close-no-implicit-chain',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$revise,$refuse,$ok){$p=$captureCorrection();$approveCorrection($p);$command('prepare','correct-post',$postCorrectionBody($p));$period=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Capture resolved current correction before later change.']);$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Independent period snapshot.']);$revise();$refuse(fn()=>$command('prepare','period-close',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'reason'=>'Changed source must refuse old period capture.']));$refuse($captureCorrection);$ok(!DB::table('gl_period_closures')->exists()&&DB::table('gl_correction_results')->count()===1,'No silent correction chain or close after subsequent source change');});
|
||||
$ok($originalsIntact()&&DB::table('gl_journals')->count()===1&&!DB::table('gl_corrections')->exists(),'All probes rolled back, original/source/cost/outbox/rights remain exact');file_put_contents($output,json_encode(['passed'=>true,'cases'=>$cases,'checks'=>$checks,'rollback_only'=>true,'source_originals_outbox_rights_unchanged'=>true,'asymmetric_pair_and_orphan_sql_enforced'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost correction regression: ".count($cases)." cases/$checks checks, exact asymmetric pair, SQL orphan refusal, atomic failures and preserved originals.\n";
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
$output=$s['root'].'/cost-correction-coexistence.json';if(file_exists($output)||DB::table('gl_corrections')->exists()||DB::table('pa_obligations')->exists())throw new LogicException('Fresh correction mixed-source fixture required');$costSource=app(App\Modules\Accounting\CostLedgerSource::class)->source($enter('read'),$g['order_id']);$outbox=DB::table('dc_outbox')->orderBy('event_id')->get()->toJson();
|
||||
DB::statement('CREATE TABLE pa_imported_sources(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,payload jsonb,receipt_hash varchar(64))');$def=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((".substr($def,6).") OR (unit_id IS NOT NULL AND permission='supplier_payables.view'))");foreach(['prepare','validate','read'] as $role){$actor=$g['actors'][$role];Tests\Support\IdentityFixture::grant($actor,$scope['tenant_id'],$scope['company_id'],'supplier_payables.view',$scope['unit_id']);if($role!=='read')DB::table('pa_access')->insert($scope+['user_id'=>$actor,'action'=>$role==='prepare'?'prepare':'validate','person'=>'cost-qa-person-'.$actor]);}
|
||||
$accounts=[];foreach(['QA-CONTRAPARTIDA','QA-FORNECEDOR'] as $code)$accounts[$code]=DB::table('gl_accounts')->insertGetId($scope+['code'=>$code,'label'=>'Artificial supplier integration account','is_movement'=>true,'currency'=>'MZN']);foreach(['QA-PAYABLE-JOURNAL'=>['supplier_payable.recognised','QA-CONTRAPARTIDA','QA-FORNECEDOR'],'QA-CORRECTION-REVERSE'=>['journal.reversal','QA-FORNECEDOR','QA-CONTRAPARTIDA'],'QA-CORRECTION-REPLACE'=>['journal.replacement','QA-CONTRAPARTIDA','QA-FORNECEDOR']] as $rule=>[$kind,$d,$c])DB::table('gl_rules')->insert($scope+['created_by'=>$g['actors']['prepare'],'reason'=>'Existing approved supplier correction in same new cost-correction schema.','created_at'=>now(),'rule_key'=>$rule,'version'=>1,'policy'=>Tempo\Finance\GeneralLedger\PayableJournal::POLICY,'effect_type'=>$kind,'currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts[$d],'credit_account_id'=>$accounts[$c]]);
|
||||
$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);$doc=$bundle['source'];unset($doc['local_id']);$doc['document']=990003;$doc['number']='QA-CORRECTION-MIXED-SUPPLIER-60';$doc['payment_count']=0;$payload=Payables::canonical($doc);$local=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>$payload,'receipt_hash'=>hash('sha256',$payload)]);$finance=app(SupplierPayables::class);$p=$finance->engine($enter('prepare'))->command('prepare',$local,'Controlled synthetic supplier receipt, not a live import.',(string)Str::uuid());$o=$finance->engine($enter('validate'))->command('validate',$p['proposal_id'],'Independent supplier60 recognition.',(string)Str::uuid());if($o['obligation_id']!==$g['order_id'])throw new LogicException('Coinciding financial/order IDs required');$p=$command('prepare','prepare',['obligation_id'=>$o['obligation_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-PAYABLE-JOURNAL','reason'=>'Existing approved supplier60 beside original cost275.']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent supplier journal review.']);$supplier=$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply supplier once.']);
|
||||
$costGroup=$captureCorrection();$approveCorrection($costGroup);$command('prepare','correct-post',$postCorrectionBody($costGroup));$supplierGroup=$command('prepare','correct-prepare',['journal_id'=>$supplier['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Existing technical supplier reversal60/replacement60 compatibility.']);$command('validate','correct-validate',['correction_id'=>$supplierGroup['correction_id'],'revision'=>$supplierGroup['revision'],'decision'=>'approved','reason'=>'Independent supplier reversal60/replacement60 conference; separate cost275->295 group.']);$command('prepare','correct-post',['correction_id'=>$supplierGroup['correction_id'],'revision'=>$supplierGroup['revision'],'reason'=>'Apply existing supplier60/60 compatibility pair once, no financial settlement.']);
|
||||
$costBook=$ledger->listing($enter('read'),['cost_order_id'=>$g['order_id']]);$supplierBook=$ledger->listing($enter('read'),['obligation_id'=>$o['obligation_id']]);$workspace=$ledger->costs($enter('read'),$g['order_id']);$report=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);
|
||||
if(count($costBook['journals'])!==3||count($costBook['events'])!==9||$costBook['totals']['debit_minor']!==84500||count($supplierBook['journals'])!==3||count($supplierBook['events'])!==9||$supplierBook['totals']['debit_minor']!==18000||count($workspace['corrections'])!==1||$workspace['pagination']['corrections']['total']!==1||$report['totals']['debit_minor']!==102500||DB::table('gl_journals')->count()!==6||DB::table('gl_lines')->count()!==12)throw new LogicException('Cost/supplier corrections mixed filters/groups or totals');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Joint current cost/supplier correction conference.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent joint resolved source review.']);$command('prepare','period-close',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Close both corrected source families in one QA period.']);
|
||||
if(Payables::canonical(app(App\Modules\Accounting\CostLedgerSource::class)->source($enter('read'),$g['order_id']))!==Payables::canonical($costSource)||DB::table('dc_outbox')->orderBy('event_id')->get()->toJson()!==$outbox||$finance->engine($enter('read'))->positionForSource($local)['balance_minor']!==6000||hash('sha256',Payables::canonical((array)DB::table('gl_journals')->where('id',$cg['original_journal_id'])->first()))!==$cg['original_journal_sha256'])throw new LogicException('Authoritative cost/outbox/supplier balance or original changed');DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');DB::select('SELECT gl_cost_pair_assert(?)',[$costGroup['correction_id']]);
|
||||
file_put_contents($output,json_encode(['passed'=>true,'coinciding_order_and_supplier_obligation_ids'=>true,'cost_typed_journals'=>3,'cost_typed_events'=>9,'supplier_typed_journals'=>3,'supplier_typed_events'=>9,'focused_cost_groups'=>1,'both_sql_pair_domains_validated'=>true,'journals'=>6,'lines'=>12,'applied_groups'=>2,'gross_minor'=>102500,'current_cost_minor'=>29500,'supplier_balance_minor'=>6000,'source_original_outbox_preserved'=>true,'joint_qa_close'=>true,'controlled_supplier_receipt_not_live_import'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost and supplier corrections coexist: coinciding source IDs isolated, six journals/twelve lines, gross1025, one joint close; both SQL pair domains preserved.\n";
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import vm from 'node:vm';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned correction team fixture required');const root=path.join(state.root,'private/ledger-'+tag),out=path.join(root,'cost-correction-demo-http.json'),demo=read(path.join(root,'cost-demo.json')),g=read(path.join(root,'cost-fixture.json'));if(fs.existsSync(out))throw Error('Completed readiness preserved');let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};let prepareHtml;
|
||||
for(const role of ['prepare','validate','read']){const jar=new Map();let context='';const get=async(url)=>{const r=await fetch(new URL(url,demo.url),{headers:{Accept:'application/json','X-SGF-Context':context,Cookie:[...jar].map(([k,v])=>k+'='+v).join('; ')},redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data};};const guide=await get('/qa/cost-demo');ok(guide.status===200&&guide.text.includes('275→295'),'Actual correction guide');const auth=await get('/qa/cost-enter/'+role);ok(auth.status===200,'Declared role');context=auth.data.context;const data=await get('/api/general-ledger/costs?order_id='+g.order_id);ok(data.status===200&&data.data.cost.source.principal_minor===29500&&data.data.cost.original_journal.debit_minor===27500&&data.data.corrections.length===0&&data.data.book.journals.length===1&&data.data.period_closures.length===0,'Original275/current295, no correction or close consumed');const page=await get('/general-ledger/costs?order_id='+g.order_id);ok(page.status===200&&page.text.includes('20,00 MZN'),'Explicit delta');ok(role==='prepare'?page.text.includes('data-action="correct-prepare"'):!page.text.includes('data-action='),'Initial role-specific controls');if(role==='prepare')prepareHtml=page.text;const home=await get('/');ok(home.status===200&&/\/build\/assets\//.test(home.text),'Private existing application build');}
|
||||
const script=[...prepareHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)].at(-1)?.[1];if(!script)throw Error('Rendered script missing');const form={dataset:{action:'correct-post'},fields:{correction_id:'9',revision:'b'.repeat(64),reason:'Individual complete-pair application reason.'},button:{},querySelector(){return this.button;}},error={textContent:''},calls=[],mock={fail:false};const sandbox={document:{querySelectorAll:()=>[form],querySelector:()=>error},FormData:class{constructor(f){return new Map(Object.entries(f.fields));}},crypto:{randomUUID:crypto.randomUUID},location:{reload(){}},fetch:async(url,options)=>{calls.push({url,options,body:JSON.parse(options.body)});return{ok:!mock.fail,json:async()=>mock.fail?{message:'Expected stale group refusal.'}:{result_id:1}};}};vm.createContext(sandbox);vm.runInContext(script,sandbox);const submit=()=>form.onsubmit({preventDefault(){}});await submit();ok(calls[0].body.correction_id===9&&calls[0].body.revision==='b'.repeat(64),'Group ID typed, exact revision hash stays string');await submit();ok(calls[1].options.headers['Idempotency-Key']===calls[0].options.headers['Idempotency-Key'],'Stable unchanged retry');form.fields.reason='Corrected pair reason.';await submit();ok(calls[2].options.headers['Idempotency-Key']!==calls[0].options.headers['Idempotency-Key'],'Changed content new key');mock.fail=true;await submit();ok(error.textContent.includes('Expected stale group refusal.')&&form.button.disabled===false,'Refusal retained for deliberate conference/retry');mock.fail=false;form.dataset.action='correct-prepare';form.fields={journal_id:'7',journal_on:'2026-10-29',period_key:'QA-2026-10',reason:'Explicit original ID capture.'};await submit();ok(calls.at(-1).body.journal_id===7&&calls.at(-1).body.period_key==='QA-2026-10','Original journal ID numeric, date/period explicit');
|
||||
fs.writeFileSync(out,JSON.stringify({passed:true,checks,three_accounting_roles:true,original_minor:27500,actual_successor_minor:29500,initial_journals:1,initial_groups:0,initial_closures:0,no_business_commands:true,rendered_forms_mock_checks:5,actual_browser:false,visual_review:false,private_existing_build:true},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,no_business_commands:true,forms:5}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned correction fixture required');const root=path.join(state.root,'private/ledger-'+tag),g=read(path.join(root,'cost-fixture.json')),cg=read(path.join(root,'cost-correction-fixture.json')),out=path.join(root,'cost-correction-http.json');if(fs.existsSync(out))throw Error('Completed correction HTTP proof preserved');
|
||||
await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(8047,'127.0.0.1',()=>s.close(resolve));});const log=fs.openSync(path.join(root,'cost-correction-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-cost-http-router.php')],{windowsHide:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:path.join(root,'settings.json')}});
|
||||
let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};const client=()=>{const jar=new Map();let auth;const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/cost-enter/'+role);ok(r.status===200,'Owned '+role+' entry');auth=r.data;ok(jar.has('sgf_cost_'+tag)&&!jar.has('XSRF-TOKEN'),'Separate session cookie and shared CSRF preserved');},module:async module=>{const old=auth.context,r=await request('/api/context',{...g.scope,module_id:module});ok(r.status===200,'Explicit module transition');auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
|
||||
|
||||
const api='/api/general-ledger/costs?order_id='+g.order_id,html='/general-ledger/costs?order_id='+g.order_id;const command=async(c,a,b,key)=>{const r=await c.request('/api/general-ledger/'+a,b,key);ok(r.status===200,a+' '+r.status+' '+(r.data?.message??''));return r.data;};
|
||||
try{
|
||||
const p=client(),v=client(),r=client(),tech=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await r.request(api)).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}ok(ready,'Unauthenticated correction read refused');await p.enter('prepare');await v.enter('validate');await r.enter('read');await tech.enter('technician');ok((await tech.request(api)).status===403,'Technical rights do not grant accounting');
|
||||
let w=await r.request(api,undefined,undefined,{'X-Cost-QA-Proof':'read'});ok(w.status===200&&w.data.cost.source.principal_minor===29500&&w.data.cost.original_journal.debit_minor===27500&&w.data.cost.accounting_state==='requires_correction'&&w.data.corrections.length===0&&w.data.book.journals.length===1,'Actual original275/current295 requires explicit correction');const initialSource=JSON.stringify(w.data.cost.facts);const mode=read(path.join(root,'cost-http-read.json'));ok(mode.transaction.isolation==='repeatable read'&&mode.transaction.read_only==='on'&&mode.writer_locks===0,'Source, group and book reads coherent without writer locks');
|
||||
let view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="correct-prepare"')&&view.text.includes('20,00 MZN')&&!view.text.includes('data-action="effect-prepare"'),'Individual pair capture with explicit difference20');fs.writeFileSync(path.join(root,'correction-preparer.html'),view.text);view=await r.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Reader cannot decide');
|
||||
const body={journal_id:cg.original_journal_id,journal_on:'2026-10-29',period_key:'QA-2026-10',reason:'Explicit original275/current295 pair, preserving original source evidence.'};ok((await p.request('/api/general-ledger/correct-prepare',body,undefined,{'X-CSRF-TOKEN':''})).status===419,'Real CSRF');ok((await r.request('/api/general-ledger/correct-prepare',body)).status===403,'No implicit correction from view');ok((await p.request('/api/general-ledger/correct-prepare',{...body,journal_id:999999})).status===409,'Foreign original refused');ok((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Unresolved changed cost blocks closing.'})).status===409,'Changed cost cannot close before correction');
|
||||
for(const q of ['order_id=999999','order_id=0','order_id%5Bx%5D=1','order_id='+g.order_id+'&tenant_id=9'])ok([403,422].includes((await r.request('/api/general-ledger/costs?'+q)).status),'Invalid/foreign source query refused');
|
||||
const old=await p.module('finance');ok((await p.request(api)).status===409,'Wrong module refuses correction workspace');await p.module('accounting');ok((await p.request('/api/general-ledger/correct-prepare',body,undefined,{'X-SGF-Context':old})).status===409,'Stale generation refuses correction');
|
||||
const prepareKey=crypto.randomUUID(),proposal=await command(p,'correct-prepare',body,prepareKey);ok((await command(p,'correct-prepare',body,prepareKey)).replayed,'Capture retry receipt');w=await r.request(api);const group=w.data.corrections[0],part=w.data.book.proposals.find(x=>x.id===group.reverse_proposal_id);ok(JSON.parse(group.reverse_snapshot).impact.debit_minor===27500&&JSON.parse(group.replacement_snapshot).impact.debit_minor===29500,'Group has exact asymmetric impacts');view=await v.request(html);ok(view.status===200&&view.text.includes('data-action="correct-validate"')&&!view.text.includes('data-action="validate"'),'Reviewer sees only joint decision, no individual part');fs.writeFileSync(path.join(root,'correction-reviewer.html'),view.text);view=await v.request('/general-ledger');ok(view.status===200&&!view.text.includes('data-action="validate"'),'Generic diary also hides isolated part decision');
|
||||
const review={correction_id:proposal.correction_id,revision:proposal.revision,decision:'approved',reason:'Independent conference of four lines, original275/latest295/difference20.'};ok((await p.request('/api/general-ledger/correct-validate',review)).status===403,'Preparer has no independent correction review');ok((await v.request('/api/general-ledger/correct-validate',{...review,revision:'0'.repeat(64)})).status===409,'Old group revision refused');ok((await v.request('/api/general-ledger/validate',{proposal_id:part.id,revision:part.revision,decision:'approved',reason:'Isolated part review forbidden.'})).status===409,'Public individual review refused');
|
||||
const post={correction_id:proposal.correction_id,revision:proposal.revision,reason:'Apply reviewed four-line cost pair atomically once.'};ok((await p.request('/api/general-ledger/correct-post',post)).status===409,'Pair cannot apply before distinct approval');await command(v,'correct-validate',review);view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="correct-post"')&&!view.text.includes('data-action="post"'),'Only joint application exposed');fs.writeFileSync(path.join(root,'correction-post.html'),view.text);ok((await p.request('/api/general-ledger/post',{proposal_id:part.id,revision:part.revision,reason:'Isolated balanced reversal still forbidden.'})).status===409,'Public individual application refused');
|
||||
const key=crypto.randomUUID(),result=await command(p,'correct-post',post,key);ok((await command(p,'correct-post',post,key)).replayed,'Pair retry receipt');ok((await command(p,'correct-post',post)).result_id===result.result_id,'Other key recovers same pair');ok((await p.request('/api/general-ledger/correct-prepare',body)).status===409,'No second correction chain');w=await r.request(api);ok(w.data.book.journals.length===3&&w.data.book.lines.length===6&&w.data.correction_results.length===1&&w.data.cost.accounting_state==='current'&&JSON.stringify(w.data.cost.facts)===initialSource,'Three journals/six lines resolve current cost without mutating source');ok(w.data.book.events.length===9,'All proposal/review/post history retained');
|
||||
const dates='from_on=2026-10-01&to_on=2026-10-31';let report=await r.request('/api/general-ledger/trial-balance?'+dates);ok(report.status===200&&report.data.totals.debit_minor===84500&&report.data.totals.credit_minor===84500,'Gross trial balance845 each side');report=await r.request('/api/general-ledger/account-ledger?'+dates+'&account_id='+g.accounts['QA-CUSTO-DIRECTO']);ok(report.status===200&&report.data.closing_net_minor===29500&&report.data.rows.length===3,'Account ledger exact295 after reversal and replacement');view=await r.request('/general-ledger/account-ledger?'+dates+'&account_id='+g.accounts['QA-CUSTO-DIRECTO']);ok(view.status===200&&view.text.includes('/general-ledger/costs?order_id='+g.order_id),'Report traces corrected cost source');
|
||||
const period=await command(p,'period-prepare',{period_key:'QA-2026-10',reason:'Capture explicit correction resolution and current source.'});await command(v,'period-validate',{check_id:period.check_id,revision:period.revision,decision:'approved',reason:'Independent corrected source/book period conference.'});const close={check_id:period.check_id,revision:period.revision,reason:'Close corrected cost QA period without reopening.'},closeKey=crypto.randomUUID(),closed=await command(p,'period-close',close,closeKey);ok((await command(p,'period-close',close,closeKey)).replayed,'Close retry receipt');ok((await command(p,'period-close',close)).closure_id===closed.closure_id,'Other key single close');view=await p.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Closed view exposes no commands');
|
||||
fs.writeFileSync(out,JSON.stringify({passed:true,checks,actual_http:true,csrf_and_context:true,indivisible_four_line_impact:true,isolated_public_parts_refused:true,original_minor:27500,replacement_minor:29500,delta_minor:2000,journals:3,lines:6,applied_groups:1,gross_minor:84500,balance_minor:29500,source_snapshot_unchanged:true,one_reviewed_close:true,read_only_workspace:true,writer_locks:0,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,journals:3,lines:6}));
|
||||
}finally{server.kill();fs.closeSync(log);}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
if(config('cost_ledger.correction_enabled')!==true||!DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'))throw new LogicException('Explicit new correction capability required');
|
||||
if($phase==='ledger-cost-correction-ready'){
|
||||
$output=$s['root'].'/cost-correction-fixture.json';if(file_exists($output)||DB::table('gl_proposals')->exists())throw new LogicException('Fresh correction fixture required');Illuminate\Support\Facades\Date::setTestNow('2026-10-29 12:00:00');
|
||||
$p=$command('prepare','effect-prepare',$body);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent original cost275 review.']);$j=$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Original reviewed cost275, immutable correction baseline.']);$original=(array)DB::table('gl_journals')->where('id',$j['journal_id'])->first();$oldCalc=(array)DB::table('dc_calculations')->where('id',$g['calculation_id'])->first();
|
||||
$u=$enter('prepare');$rate=$dc->rate($g['order_id'],$u->id,['entry_id'=>$g['material_id'],'entry_version'=>1,'identifier'=>'QA-COST-MATERIAL','version'=>2,'previous_id'=>$g['rate_ids']['material'],'currency'=>'MZN','unit'=>'UN','amount'=>'60.00','evidence'=>'Explicit authorised correction hypothesis2UNx60=120, labour175 retained.'],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'rate','id'=>$rate['id'],'version'=>2,'decision'=>'approved','reason'=>'Independent exact successor rate60 review.'],(string)Str::uuid());$u=$enter('prepare');$calc=$dc->calculate($g['order_id'],$u->id,['version'=>2,'previous_id'=>$g['calculation_id'],'currency'=>'MZN','lines'=>[['entry_id'=>$g['material_id'],'entry_version'=>1,'rate_id'=>$rate['id'],'rate_version'=>2],['entry_id'=>$g['time_id'],'entry_version'=>1,'rate_id'=>$g['rate_ids']['time'],'rate_version'=>1]]],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>2,'decision'=>'approved','reason'=>'Independent selected cost120+175=295, no inventory valuation.'],(string)Str::uuid());
|
||||
$source=app(App\Modules\Accounting\CostLedgerSource::class)->source($enter('read'),$g['order_id']);if($source['principal_minor']!==29500||$ledger->costs($enter('read'),$g['order_id'])['cost']['accounting_state']!=='requires_correction')throw new LogicException('Actual successor295 must require explicit correction');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'original_journal_id'=>$j['journal_id'],'original_journal_sha256'=>hash('sha256',Payables::canonical($original)),'original_calculation_sha256'=>hash('sha256',Payables::canonical($oldCalc)),'replacement_rate_id'=>$rate['id'],'replacement_calculation_id'=>$calc['id'],'original_minor'=>27500,'replacement_minor'=>29500,'difference_minor'=>2000,'source_fingerprint'=>$sourceFingerprint(),'no_correction_groups_yet'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "New correction fixture: actual original275 posted; actual successor rate60/calculation295 independently approved; no correction group yet.\n";exit;
|
||||
}
|
||||
$cg=json_decode(file_get_contents($s['root'].'/cost-correction-fixture.json'),true,64,JSON_THROW_ON_ERROR);$correctionBody=['journal_id'=>$cg['original_journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Explicit indivisible reversal275 and replacement295, selected known direct cost.'];$captureCorrection=fn()=>$command('prepare','correct-prepare',$correctionBody);$approveCorrection=fn($p)=>$command('validate','correct-validate',['correction_id'=>$p['correction_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent review of original275, latest295, delta20 and four pair lines.']);$postCorrectionBody=fn($p)=>['correction_id'=>$p['correction_id'],'revision'=>$p['revision'],'reason'=>'Apply reviewed cost correction pair atomically once.'];
|
||||
$originalsIntact=function()use($cg,$g,$sourceFingerprint){return $sourceFingerprint()===$cg['source_fingerprint']&&hash('sha256',Payables::canonical((array)DB::table('gl_journals')->where('id',$cg['original_journal_id'])->first()))===$cg['original_journal_sha256']&&hash('sha256',Payables::canonical((array)DB::table('dc_calculations')->where('id',$g['calculation_id'])->first()))===$cg['original_calculation_sha256'];};
|
||||
if($phase==='ledger-cost-correction-cases'){require __DIR__.'/ledger-cost-correction-cases.php';exit;}
|
||||
if($phase==='ledger-cost-correction-ui-postcheck'){
|
||||
$output=$s['root'].'/cost-correction-ui-postcheck.json';if(file_exists($output))throw new LogicException('Completed UI postcheck preserved');$u=$enter('prepare');$data=$ledger->costs($u,$g['order_id']);$page=view('accounting.cost-ledger',['data'=>$data])->render();$generic=view('accounting.general-ledger',['data'=>$ledger->listing($u)])->render();$legacy=Tempo\Finance\GeneralLedger\CycleWorkspace::render($ledger->cycle($u),['host'=>'Synthetic read probe','csrf'=>'qa','context'=>null,'scope'=>$scope,'financial_url'=>'/supplier-payments','return_url'=>'/general-ledger','page_url'=>'/general-ledger/cycle']);
|
||||
if(!str_contains($page,'Captura superada')||str_contains($page,'data-action="post"')||str_contains($generic,'data-action="post"')||str_contains($legacy,'data-action="post"'))throw new LogicException('An interface exposed isolated stale correction parts');file_put_contents($output,json_encode(['passed'=>true,'superseded_group_visible_without_action'=>true,'cost_generic_and_legacy_cycle_hide_isolated_part_application'=>true,'read_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "All three interfaces hide isolated parts, including stale reviewed/unposted captures; historical group visible.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-correction-defaults'){
|
||||
$output=$s['root'].'/cost-correction-defaults.json';if(file_exists($output))throw new LogicException('Completed defaults preserved');$v=getenv('SGF_COST_CORRECTION_QA');$a=$_ENV['SGF_COST_CORRECTION_QA']??null;$b=$_SERVER['SGF_COST_CORRECTION_QA']??null;putenv('SGF_COST_CORRECTION_QA');unset($_ENV['SGF_COST_CORRECTION_QA'],$_SERVER['SGF_COST_CORRECTION_QA']);try{$default=(require $c['root'].'/config/cost_ledger.php')['correction_enabled'];}finally{if($v!==false)putenv('SGF_COST_CORRECTION_QA='.$v);if($a!==null)$_ENV['SGF_COST_CORRECTION_QA']=$a;if($b!==null)$_SERVER['SGF_COST_CORRECTION_QA']=$b;}
|
||||
if($default!==false||config('cost_ledger.correction_enabled')!==true)throw new LogicException('Correction default must be off, only explicit new QA composition on');file_put_contents($output,json_encode(['passed'=>true,'default_without_environment'=>false,'explicit_new_qa_runtime'=>true,'real_environment_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost correction defaults off, explicit new QA runtime only.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-correction-demo-preservation'){
|
||||
$output=$s['root'].'/cost-correction-demo-preservation.json';if(file_exists($output)||!$originalsIntact()||DB::table('gl_corrections')->exists()||DB::table('gl_correction_results')->exists()||DB::table('gl_journals')->count()!==1||DB::table('gl_period_closures')->exists()||DB::table('permission_grants')->where('permission','like','general_ledger.%')->count()!==12)throw new LogicException('Team correction fixture unexpectedly changed; no reset');file_put_contents($output,json_encode(['passed'=>true,'original275_present'=>true,'actual_validated_successor295'=>true,'original_source_outbox_rights_exact'=>true,'initial_journals'=>1,'initial_correction_groups'=>0,'initial_closures'=>0,'ledger_grants'=>12,'distinct_ledger_rights'=>10,'no_business_writes'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Team correction fixture preserved: original275/current295, twelve QA grants, no correction group or close.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-correction-security'){require __DIR__.'/ledger-cost-correction-security.php';exit;}
|
||||
if($phase==='ledger-cost-correction-http-final'){
|
||||
$output=$s['root'].'/cost-correction-http-final.json';if(file_exists($output))throw new LogicException('Completed final preserved');$data=$ledger->costs($enter('read'),$g['order_id']);$html=view('accounting.cost-ledger',['data'=>$data])->render();if(!$originalsIntact()||DB::table('gl_journals')->count()!==3||DB::table('gl_lines')->count()!==6||DB::table('gl_correction_results')->count()!==1||DB::table('gl_period_closures')->count()!==1||str_contains($html,'data-action='))throw new LogicException('Final correction/source/closed UI state differs');DB::select('SELECT gl_cost_pair_assert(id) FROM gl_corrections');file_put_contents($output,json_encode(['passed'=>true,'original_journal_calculation_source_outbox_rights_unchanged'=>true,'journals'=>3,'lines'=>6,'results'=>1,'closures'=>1,'closed_ui_no_commands'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Correction HTTP final: original/source/outbox/rights exact, one pair/close and closed view without commands.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-correction-coexistence'){require __DIR__.'/ledger-cost-correction-coexistence.php';exit;}
|
||||
if(in_array($phase,['ledger-cost-correction-race','ledger-cost-correction-worker'],true)){require __DIR__.'/ledger-cost-correction-race.php';exit;}
|
||||
if($phase==='ledger-cost-correction-workflow'){
|
||||
$output=$s['root'].'/cost-correction-workflow.json';if(file_exists($output)||DB::table('gl_corrections')->exists())throw new LogicException('Fresh correction workflow only');$p=$captureCorrection();$approveCorrection($p);$post=$postCorrectionBody($p);$key=(string)Str::uuid();$r=$command('prepare','correct-post',$post,$key);$replay=$command('prepare','correct-post',$post,$key);$other=$command('prepare','correct-post',$post);
|
||||
$check=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Conference of resolved original and indivisible current cost correction.']);$command('validate','period-validate',['check_id'=>$check['check_id'],'revision'=>$check['revision'],'decision'=>'approved','reason'=>'Independent correction and period reconciliation.']);$command('prepare','period-close',['check_id'=>$check['check_id'],'revision'=>$check['revision'],'reason'=>'Close corrected cost QA period without reopening.']);$report=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);$account=$ledger->report($enter('read'),'account-ledger',['from_on'=>'2026-10-01','to_on'=>'2026-10-31','account_id'=>$g['accounts']['QA-CUSTO-DIRECTO']]);
|
||||
if(!$replay['replayed']||$other['result_id']!==$r['result_id']||!$originalsIntact()||DB::table('gl_journals')->count()!==3||DB::table('gl_lines')->count()!==6||DB::table('gl_correction_results')->count()!==1||$report['totals']['debit_minor']!==84500||$account['closing_net_minor']!==29500||$ledger->costs($enter('read'),$g['order_id'])['cost']['accounting_state']!=='current')throw new LogicException('Cost correction outcome, period or preservation differs');DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');DB::select('SELECT gl_cost_pair_assert(?)',[$r['correction_id']]);file_put_contents($output,json_encode(['passed'=>true,'journals'=>3,'lines'=>6,'correction_groups_applied'=>1,'gross_debit_minor'=>84500,'gross_credit_minor'=>84500,'cost_balance_minor'=>29500,'net_difference_minor'=>2000,'original_cost_calculation_source_outbox_rights_unchanged'=>true,'same_and_other_key_single_pair'=>true,'period_closed'=>true,'reports_reconciled'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Actual correction275->295: three journals/six lines, gross845, balance295, one indivisible result and reviewed QA close; originals preserved.\n";exit;
|
||||
}
|
||||
throw new LogicException('Unknown correction proof phase');
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
if($phase==='ledger-cost-correction-worker'){
|
||||
$input=realpath($argv[3]??'');if(!$input||!str_starts_with($input,realpath($s['root']).DIRECTORY_SEPARATOR))throw new LogicException('Owned correction worker required');$w=json_decode(file_get_contents($input),true,64,JSON_THROW_ON_ERROR);if(!preg_match('/^cost_(post|close|stale|read)_[ab]_[a-f0-9]{12}$/D',$w['label']))throw new LogicException('Worker label refused');DB::select('SELECT set_config(?,?,false)',['application_name',$w['label']]);DB::statement("SET lock_timeout='30s'");
|
||||
try{if($w['action']==='read'){$d=$ledger->costs($enter('read'),$g['order_id']);$r=['status'=>200,'amount_minor'=>$d['cost']['source']['principal_minor'],'journals'=>count($d['book']['journals']),'results'=>count($d['correction_results'])];}else $r=['status'=>200,'result'=>$command('prepare',$w['action'],$w['body'],$w['key'])];}catch(DomainException $e){$r=['status'=>409,'message'=>$e->getMessage()];}file_put_contents($input.'.result',json_encode($r,JSON_THROW_ON_ERROR));exit;
|
||||
}
|
||||
$output=$s['root'].'/cost-correction-race.json';if(file_exists($output)||DB::table('gl_corrections')->exists())throw new LogicException('Fresh correction race book required');$prefixOutbox=DB::table('dc_outbox')->orderBy('event_id')->get()->toJson();$secondCalc=hash('sha256',Payables::canonical((array)DB::table('dc_calculations')->where('id',$cg['replacement_calculation_id'])->first()));
|
||||
$p=$captureCorrection();$approveCorrection($p);
|
||||
$change=function()use($dc,$enter,$g,$cg){$u=$enter('prepare');$r=$dc->rate($g['order_id'],$u->id,['entry_id'=>$g['material_id'],'entry_version'=>1,'identifier'=>'QA-COST-MATERIAL','version'=>3,'previous_id'=>$cg['replacement_rate_id'],'currency'=>'MZN','unit'=>'UN','amount'=>'60.00','evidence'=>'Concurrent version-only rate change, selected amount remains295.'],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'rate','id'=>$r['id'],'version'=>3,'decision'=>'approved','reason'=>'Independent concurrent rate version conference.'],(string)Str::uuid());$u=$enter('prepare');$calc=$dc->calculate($g['order_id'],$u->id,['version'=>3,'previous_id'=>$cg['replacement_calculation_id'],'currency'=>'MZN','lines'=>[['entry_id'=>$g['material_id'],'entry_version'=>1,'rate_id'=>$r['id'],'rate_version'=>3],['entry_id'=>$g['time_id'],'entry_version'=>1,'rate_id'=>$g['rate_ids']['time'],'rate_version'=>1]]],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>3,'decision'=>'approved','reason'=>'Independent current295 with new source version.'],(string)Str::uuid());return $calc['id'];};
|
||||
$race=function($name,$action,$body,$number=2,$change=null,$commit=false,$reader=false)use($s,$file,$scope){
|
||||
$workers=[];$inputs=[];$readInput=null;$observed=[];DB::beginTransaction();try{
|
||||
$parent=(int)DB::selectOne('SELECT pg_backend_pid() pid')->pid;DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();$changed=$change?$change():null;
|
||||
$launch=function($label,$workerAction,$body)use(&$workers,$s,$file){$input=$s['root'].'/'.$label.'-'.bin2hex(random_bytes(4)).'.json';file_put_contents($input,json_encode(['label'=>$label,'action'=>$workerAction,'body'=>$body,'key'=>(string)Str::uuid()]));$log=$input.'.log';$p=proc_open([PHP_BINARY,__DIR__.'/payable-history-sgf-proof.php',$file,'ledger-cost-correction-worker',$input],[0=>['file','NUL','r'],1=>['file',$log,'a'],2=>['file',$log,'a']],$pipes,dirname(__DIR__),null,['bypass_shell'=>true]);if(!is_resource($p))throw new LogicException('Worker launch failed');$workers[]=$p;return $input;};
|
||||
foreach(array_slice(['a','b'],0,$number) as $n)$inputs[$n]=$launch('cost_'.$name.'_'.$n.'_'.$s['tag'],$action,$body);
|
||||
if($reader)$readInput=$launch('cost_read_a_'.$s['tag'],'read',[]);
|
||||
config(['database.connections.cost_monitor'=>DB::connection()->getConfig()]);$monitor=DB::connection('cost_monitor');$blocked=[];$until=microtime(true)+15;
|
||||
do{$monitor->select('SELECT pg_stat_clear_snapshot()');foreach($inputs as $n=>$input){$r=$monitor->selectOne('SELECT pid,wait_event_type,wait_event,query,pg_blocking_pids(pid)::text blockers FROM pg_stat_activity WHERE datname=current_database() AND application_name=?',['cost_'.$name.'_'.$n.'_'.$s['tag']]);if($r)$observed[$n]=['pid'=>(int)$r->pid,'wait'=>$r->wait_event_type,'event'=>$r->wait_event,'company'=>str_contains($r->query,'"companies"')&&str_contains(strtolower($r->query),'for update'),'blockers'=>json_decode(str_replace(['{','}'],['[',']'],$r->blockers))];}
|
||||
foreach($observed as $n=>$r){$reach=in_array($parent,$r['blockers'],true);foreach($observed as $other)if(in_array($other['pid'],$r['blockers'],true)&&in_array($parent,$other['blockers'],true))$reach=true;if($reach&&$r['wait']==='Lock'&&$r['company'])$blocked[$n]=['chain_reaches_parent'=>true,'wait_event'=>$r['event']];}
|
||||
if(count($blocked)===$number&&(!$readInput||file_exists($readInput.'.result')))break;usleep(20000);
|
||||
}while(microtime(true)<$until);
|
||||
if(count($blocked)!==$number||($readInput&&!file_exists($readInput.'.result')))throw new LogicException('Actual blocked writers and independent reader required');
|
||||
$readResult=$readInput?json_decode(file_get_contents($readInput.'.result'),true,64,JSON_THROW_ON_ERROR):null;
|
||||
$commit?DB::commit():DB::rollBack();$results=[];foreach($inputs as $n=>$input){$until=microtime(true)+20;while(!file_exists($input.'.result')&µtime(true)<$until)usleep(20000);if(!file_exists($input.'.result'))throw new LogicException('Worker did not finish after release');$results[$n]=json_decode(file_get_contents($input.'.result'),true,64,JSON_THROW_ON_ERROR);}
|
||||
return ['waiting'=>$blocked,'read_before_release'=>$readResult,'results'=>$results,'changed_effect_id'=>$commit?$changed:null];
|
||||
}finally{while(DB::transactionLevel()>0)DB::rollBack();foreach($workers as $p){if(proc_get_status($p)['running'])proc_terminate($p);proc_close($p);}DB::purge('cost_monitor');}
|
||||
};
|
||||
|
||||
|
||||
$stale=$race('stale','correct-post',$postCorrectionBody($p),1,$change,true,true);
|
||||
if($stale['results']['a']['status']!==409||$stale['read_before_release']['amount_minor']!==29500||$stale['read_before_release']['journals']!==1||$stale['read_before_release']['results']!==0||DB::table('gl_journals')->count()!==1||DB::table('gl_correction_results')->exists())throw new LogicException('Stale correction/read coherence after committed version change failed');
|
||||
$p=$captureCorrection();$approveCorrection($p);$applied=$race('post','correct-post',$postCorrectionBody($p));if($applied['results']['a']['status']!==200||$applied['results']['b']['status']!==200||$applied['results']['a']['result']['result_id']!==$applied['results']['b']['result']['result_id']||DB::table('gl_journals')->count()!==3||DB::table('gl_lines')->count()!==6||DB::table('gl_correction_results')->count()!==1)throw new LogicException('Concurrent pair did not recover a single result');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Fresh corrected period after exact version conference.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent refreshed corrected period.']);$closed=$race('close','period-close',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Close corrected cost QA period once.']);if($closed['results']['a']['status']!==200||$closed['results']['b']['status']!==200||$closed['results']['a']['result']['closure_id']!==$closed['results']['b']['result']['closure_id']||DB::table('gl_period_closures')->count()!==1)throw new LogicException('Concurrent corrected period duplicated');
|
||||
if(hash('sha256',Payables::canonical((array)DB::table('gl_journals')->where('id',$cg['original_journal_id'])->first()))!==$cg['original_journal_sha256']||hash('sha256',Payables::canonical((array)DB::table('dc_calculations')->where('id',$g['calculation_id'])->first()))!==$cg['original_calculation_sha256']||hash('sha256',Payables::canonical((array)DB::table('dc_calculations')->where('id',$cg['replacement_calculation_id'])->first()))!==$secondCalc||DB::table('dc_outbox')->whereIn('event_id',array_column(json_decode($prefixOutbox,true),'event_id'))->orderBy('event_id')->get()->toJson()!==$prefixOutbox||DB::table('dc_outbox')->where('state','<>','captured_no_accounting')->exists())throw new LogicException('Original journal/calculations/outbox changed');
|
||||
$report=$ledger->report($enter('read'),'account-ledger',['from_on'=>'2026-10-01','to_on'=>'2026-10-31','account_id'=>$g['accounts']['QA-CUSTO-DIRECTO']]);if($report['closing_net_minor']!==29500)throw new LogicException('Concurrent corrected report does not reconcile');file_put_contents($output,json_encode(['passed'=>true,'workers'=>6,'stale_group_after_committed_version'=>$stale,'fresh_pair'=>$applied,'fresh_close'=>$closed,'reader_finished_before_release'=>true,'prepared_groups'=>2,'applied_groups'=>1,'journals'=>3,'lines'=>6,'closures'=>1,'cost_balance_minor'=>29500,'latest_cost_version'=>3,'original_journal_calculations_outbox_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Six correction workers: read before release, stale409 after committed same-value version, one fresh pair and one close; original journal/calculations/outbox unchanged.\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
$output=$s['root'].'/cost-correction-security.json';if(file_exists($output)||DB::table('gl_corrections')->exists())throw new LogicException('Fresh correction security proof required');$checks=0;$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};
|
||||
foreach(['unit','company','tenant'] as $kind){DB::beginTransaction();try{$u=$enter('prepare');$company=$kind==='unit'?DB::table('companies')->where('id',$scope['company_id'])->first():($kind==='company'?DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id','<>',$scope['company_id'])->first():DB::table('companies')->where('tenant_id','<>',$scope['tenant_id'])->first());$unit=DB::table('units')->insertGetId(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'name'=>'Rollback-only correction foreign '.$kind,'created_at'=>now(),'updated_at'=>now()]);if(!DB::table('company_memberships')->where(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id])->exists())DB::table('company_memberships')->insert(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id]);foreach(['company.view'=>null,'unit.view'=>$unit,'workshop.view'=>$unit,'direct_costs.view'=>$unit,'general_ledger.view'=>$unit,'general_ledger.correct_prepare'=>$unit] as $right=>$v)Tests\Support\IdentityFixture::grant($u->id,$company->tenant_id,$company->id,$right,$v);app(CompanyContext::class)->selectFor($u->id,$company->tenant_id,$company->id,$unit);$caught=false;try{$ledger->engine($u)->command('correct-prepare',$correctionBody,(string)Illuminate\Support\Str::uuid());}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException){$caught=true;}$ok($caught,'Eligible foreign '.$kind.' cannot correct original');}finally{while(DB::transactionLevel())DB::rollBack();$enter('prepare');}}
|
||||
foreach(['workshop.view','direct_costs.view','general_ledger.view','general_ledger.correct_prepare'] as $right){DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission',$right)->delete();$caught=false;try{$captureCorrection();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught,'Revoked '.$right.' refuses correction before capture');}finally{while(DB::transactionLevel())DB::rollBack();$enter('prepare');}}
|
||||
$ok($originalsIntact()&&!DB::table('gl_corrections')->exists(),'Security probes preserve all original/source/outbox/rights state');file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'eligible_foreign_tenant_company_unit_refused'=>true,'four_live_rights_revalidated'=>true,'rollback_only'=>true,'source_outbox_rights_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Correction security: $checks checks, eligible foreign contexts/live rights refused, originals preserved.\n";
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import vm from 'node:vm';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned team cost fixture required');const root=path.join(state.root,'private/ledger-'+tag),out=path.join(root,'cost-demo-http.json'),demo=read(path.join(root,'cost-demo.json')),g=read(path.join(root,'cost-fixture.json'));if(fs.existsSync(out))throw Error('Completed readiness preserved');let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};let prepareHtml;
|
||||
for(const role of ['prepare','validate','read']){const jar=new Map();let context='';const get=async(url)=>{const r=await fetch(new URL(url,demo.url),{headers:{Accept:'application/json','X-SGF-Context':context,Cookie:[...jar].map(([k,v])=>k+'='+v).join('; ')},redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data};};ok((await get('/qa/cost-demo')).status===200,'Team guide');const auth=await get('/qa/cost-enter/'+role);ok(auth.status===200,'Declared role');context=auth.data.context;const data=await get('/api/general-ledger/costs?order_id='+g.order_id);ok(data.status===200&&data.data.cost.source.principal_minor===27500&&data.data.book.journals.length===0&&data.data.book.proposals.length===0&&data.data.period_closures.length===0,'Actual validated cost starts without accounting decisions');const page=await get('/general-ledger/costs?order_id='+g.order_id);ok(page.status===200&&page.text.includes('275,00 MZN'),'Actual shared entry HTML');ok(role==='prepare'?page.text.includes('data-action="effect-prepare"'):!page.text.includes('data-action='),'Role-specific initial forms');if(role==='prepare')prepareHtml=page.text;const home=await get('/');ok(home.status===200&&/\/build\/assets\//.test(home.text),'Private existing build serves app navigation');}
|
||||
const script=[...prepareHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)].at(-1)?.[1];if(!script)throw Error('Rendered cost script missing');const form={dataset:{action:'post'},fields:{proposal_id:'7',revision:'a'.repeat(64),reason:'Individual exact accounting reason.'},button:{},querySelector(){return this.button;}},error={textContent:''},calls=[],mock={fail:false};const sandbox={document:{querySelectorAll:()=>[form],querySelector:()=>error},FormData:class{constructor(f){return new Map(Object.entries(f.fields));}},crypto:{randomUUID:crypto.randomUUID},location:{reload(){}},fetch:async(url,options)=>{calls.push({url,options,body:JSON.parse(options.body)});return{ok:!mock.fail,json:async()=>mock.fail?{message:'Expected stale revision refusal.'}:{journal_id:1}};}};vm.createContext(sandbox);vm.runInContext(script,sandbox);const submit=()=>form.onsubmit({preventDefault(){}});await submit();ok(calls[0].body.proposal_id===7&&calls[0].body.revision==='a'.repeat(64),'IDs numeric, revision remains exact hash');await submit();ok(calls[0].options.headers['Idempotency-Key']===calls[1].options.headers['Idempotency-Key'],'Unchanged retry keeps same key');form.fields.reason='Corrected individual reason.';await submit();ok(calls[2].options.headers['Idempotency-Key']!==calls[0].options.headers['Idempotency-Key'],'Changed body gets new key');mock.fail=true;await submit();ok(error.textContent.includes('Expected stale revision refusal.')&&form.button.disabled===false,'Failure preserves error and permits deliberate retry');
|
||||
fs.writeFileSync(out,JSON.stringify({passed:true,checks,three_declared_accounting_roles:true,actual_validated_cost_minor:27500,initial_proposals:0,initial_journals:0,initial_closures:0,no_business_commands:true,private_existing_build:true,rendered_forms_mock_checks:4,actual_browser:false,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,no_business_commands:true,form_checks:4}));
|
||||
@@ -0,0 +1,6 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned cost fixture required');const root=path.join(state.root,'private/ledger-'+tag),settings=path.join(root,'settings.json'),owned=read(settings),record=path.join(root,'cost-demo.json');if(owned.tag!==tag||owned.cluster!=='sgf_modular_regression_'+state.id||!fs.existsSync(path.join(root,'cost-fixture.json')))throw Error('Cost fixture mismatch');
|
||||
if(fs.existsSync(record)){const d=read(record),r=await fetch(d.url,{signal:AbortSignal.timeout(5000)});if(!r.ok||!(await r.text()).includes('data-cost-demo="'+tag+'"'))throw Error('Saved demo stopped; inspect without reset');console.log(JSON.stringify({...d,already_running:true}));process.exit(0);}
|
||||
const assets=path.join(owned.sgf.storage,'public/build');if(!fs.existsSync(assets))fs.cpSync(path.join(state.root,'private/ledger-d93394698835/storage/public/build'),assets,{recursive:true,errorOnExist:true});
|
||||
let port;for(let p=8054;p<=8063&&!port;p++){try{await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(p,'127.0.0.1',()=>s.close(resolve));});port=p;}catch{}}if(!port)throw Error('No free loopback port');const log=fs.openSync(path.join(root,'cost-demo-server.log'),'a'),child=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:'+port,path.resolve('scripts/ledger-cost-http-router.php')],{cwd:process.cwd(),windowsHide:true,detached:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:settings}});child.unref();fs.closeSync(log);const url='http://127.0.0.1:'+port+'/qa/cost-demo';let ready=false;for(let n=0;n<50&&!ready;n++){try{const r=await fetch(url,{signal:AbortSignal.timeout(5000)});ready=r.ok&&(await r.text()).includes('data-cost-demo="'+tag+'"');}catch{}if(!ready)await new Promise(r=>setTimeout(r,100));}if(!ready){child.kill();throw Error('Own cost demo failed readiness');}
|
||||
const result={tag,pid:child.pid,url,cookie:'sgf_cost_'+tag,loopback_only:true,separate_team_database:true,old_demos_not_reset:true};fs.writeFileSync(record,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result));
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
auth()->forgetGuards();app(CompanyContext::class)->clear();
|
||||
$app->bind(Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,static fn($container)=>new class($container,$container->make(Illuminate\Contracts\Encryption\Encrypter::class)) extends Illuminate\Foundation\Http\Middleware\PreventRequestForgery {protected $addHttpCookie=false;});
|
||||
$app->usePublicPath($c['storage'].'/public');app(Illuminate\Foundation\Vite::class)->useHotFile($c['storage'].'/no-hot');
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/cost-demo',function()use($s,$g){return response()->view(config('industrial_ledger.correction_enabled')===true?'accounting.industrial-correction-demo-entry':(config('industrial_ledger.synthetic_enabled')===true?'accounting.industrial-ledger-demo-entry':(config('general_ledger.composition_enabled')===true?'accounting.ledger-composition-demo-entry':(config('cost_ledger.correction_enabled')===true?'accounting.cost-correction-demo-entry':'accounting.cost-demo-entry'))),['tag'=>$s['tag'],'order'=>$g['order_id']])->header('Cache-Control','no-store');});
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/cost-enter/{role}',function(Illuminate\Http\Request $r,string $role)use($g){abort_unless(isset($g['actors'][$role]),404);$module=$r->query('module','accounting');abort_unless(in_array($module,config('general_ledger.composition_enabled')===true?['accounting','finance','workshop','commercial']:['accounting','finance','workshop'],true),422);$u=App\Models\User::findOrFail($g['actors'][$role]);auth()->login($u);$r->session()->regenerate();$token=bin2hex(random_bytes(24));$r->session()->put('auth_version',$u->auth_version);$r->session()->put('company_context',$g['scope']+['token'=>$token,'module_id'=>$module]);$r->session()->put('cost_demo',true);if(config('general_ledger.composition_enabled')===true&&$r->query('open')==='workspace')return redirect('/general-ledger/workspace');if($r->query('open')==='cost')return redirect('/general-ledger/costs?order_id='.$g['order_id']);return response()->json(['csrf'=>csrf_token(),'context'=>$token,'order_id'=>$g['order_id']])->header('Cache-Control','no-store');});
|
||||
$sql=[];$mode=null;DB::listen(function($e)use(&$sql,&$mode){$sql[]=$e->sql;if($mode===null&&str_contains($e->sql,'"gl_journals"')){$pdo=$e->connection->getPdo();$mode=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
|
||||
$app->handleRequest(Illuminate\Http\Request::capture());if(($_SERVER['HTTP_X_COST_QA_PROOF']??'')==='read')file_put_contents($s['root'].'/cost-http-read.json',json_encode(['transaction'=>$mode,'writer_locks'=>count(array_filter($sql,fn($q)=>str_contains(strtolower($q),'for update')))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));exit;
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
$path=parse_url($_SERVER['REQUEST_URI']??'',PHP_URL_PATH);
|
||||
if(str_starts_with($path??'','/build/')){
|
||||
$state=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true);$settings=realpath(getenv('LEDGER_QA_SETTINGS'));$private=realpath($state['root'].'/private');
|
||||
if(!$settings||!$private||!str_starts_with($settings,$private.DIRECTORY_SEPARATOR)){http_response_code(404);exit;}$s=json_decode(file_get_contents($settings),true);$base=realpath($s['sgf']['storage'].'/public/build');$file=realpath($s['sgf']['storage'].'/public'.$path);
|
||||
if(!$base||!$file||!str_starts_with($file,$base.DIRECTORY_SEPARATOR)||!in_array(pathinfo($file,PATHINFO_EXTENSION),['js','css','woff2'],true)){http_response_code(404);exit;}header('Content-Type: '.(['js'=>'application/javascript','css'=>'text/css','woff2'=>'font/woff2'][pathinfo($file,PATHINFO_EXTENSION)]));readfile($file);exit;
|
||||
}
|
||||
$argv=[__FILE__,getenv('LEDGER_QA_SETTINGS'),'ledger-cost-http'];require __DIR__.'/payable-history-sgf-proof.php';
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned cost fixture required');const root=path.join(state.root,'private/ledger-'+tag),g=read(path.join(root,'cost-fixture.json')),out=path.join(root,'cost-http.json');if(fs.existsSync(out)||!fs.existsSync(path.join(root,'cost-http-baseline.json')))throw Error('Fresh HTTP proof with private baseline required');
|
||||
await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(8047,'127.0.0.1',()=>s.close(resolve));});const log=fs.openSync(path.join(root,'cost-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-cost-http-router.php')],{windowsHide:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:path.join(root,'settings.json')}});
|
||||
let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};const client=()=>{const jar=new Map();let auth;const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/cost-enter/'+role);ok(r.status===200,'Owned '+role+' entry');auth=r.data;ok(jar.has('sgf_cost_'+tag)&&!jar.has('XSRF-TOKEN'),'Separate session cookie and shared CSRF preserved');},module:async module=>{const old=auth.context,r=await request('/api/context',{...g.scope,module_id:module});ok(r.status===200,'Explicit module transition');auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
|
||||
const api='/api/general-ledger/costs?order_id='+g.order_id,html='/general-ledger/costs?order_id='+g.order_id;const command=async(c,a,b,key)=>{const r=await c.request('/api/general-ledger/'+a,b,key);ok(r.status===200,a+' '+r.status+' '+(r.data?.message??''));return r.data;};
|
||||
try{
|
||||
const p=client(),v=client(),r=client(),tech=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await r.request(api)).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}ok(ready,'Unauthenticated cost read refused');await p.enter('prepare');await v.enter('validate');await r.enter('read');await tech.enter('technician');ok((await tech.request(api)).status===403,'Technical recording rights do not grant accounting view');
|
||||
let w=await r.request(api,undefined,undefined,{'X-Cost-QA-Proof':'read'});ok(w.status===200&&w.data.cost.source.principal_minor===27500&&w.data.book.journals.length===0,'Actual selected cost275 not automatically posted');const mode=read(path.join(root,'cost-http-read.json'));ok(mode.transaction.isolation==='repeatable read'&&mode.transaction.read_only==='on'&&mode.writer_locks===0,'Source, book and period read coherently without writer locks');
|
||||
let view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="effect-prepare"')&&view.text.includes('275,00 MZN'),'Preparer sees exact impact and individual capture');fs.writeFileSync(path.join(root,'cost-preparer.html'),view.text);view=await r.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Reader has no decision forms');
|
||||
for(const query of ['order_id=999999','order_id=0','order_id%5Bx%5D=1','order_id='+g.order_id+'&company_id=9'])ok([403,422].includes((await r.request('/api/general-ledger/costs?'+query)).status),'Invalid/cross-context order refused');
|
||||
const body={effect_type:'workshop.direct_cost.validated',source_id:g.order_id,journal_on:'2026-10-29',period_key:'QA-2026-10',rule_key:'QA-DIRECT-COST',reason:'Actual selected complete275, technical and cost approvals preserved.'};
|
||||
ok((await p.request('/api/general-ledger/effect-prepare',body,undefined,{'X-CSRF-TOKEN':''})).status===419,'Real CSRF validation');ok((await r.request('/api/general-ledger/effect-prepare',body)).status===403,'Reader cannot prepare');ok((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Unposted selected cost blocks completeness.'})).status===409,'No closing before selected cost posting');
|
||||
const old=await p.module('finance');ok((await p.request(api)).status===409,'Wrong module refuses cost workspace');await p.module('accounting');ok((await p.request('/api/general-ledger/effect-prepare',body,undefined,{'X-SGF-Context':old})).status===409,'Stale context generation refused');
|
||||
const key=crypto.randomUUID(),proposal=await command(p,'effect-prepare',body,key);ok((await command(p,'effect-prepare',body,key)).replayed,'Capture retry recovers original receipt');view=await v.request(html);ok(view.status===200&&view.text.includes('data-action="validate"')&&view.text.includes('QA-CUSTO-DIRECTO')&&view.text.includes('QA-COMPENSACAO-ENSAIO'),'Independent reviewer sees both artificial lines');fs.writeFileSync(path.join(root,'cost-reviewer.html'),view.text);
|
||||
const review={proposal_id:proposal.proposal_id,revision:proposal.revision,decision:'approved',reason:'Independent exact selected cost and source version conference.'};ok((await p.request('/api/general-ledger/validate',review)).status===403,'Preparer lacks independent review authority');ok((await v.request('/api/general-ledger/validate',{...review,revision:'0'.repeat(64)})).status===409,'Old capture revision refused');await command(v,'validate',review);
|
||||
const post={proposal_id:proposal.proposal_id,revision:proposal.revision,reason:'Apply reviewed selected cost once without changing original evidence.'},postKey=crypto.randomUUID(),j=await command(p,'post',post,postKey);ok((await command(p,'post',post,postKey)).replayed,'Posting retry receipt');ok((await command(p,'post',post)).journal_id===j.journal_id,'Other key returns same source journal');ok((await p.request('/api/general-ledger/effect-prepare',body)).status===409,'No second recognition of same selected perimeter');
|
||||
w=await r.request(api);ok(w.status===200&&w.data.book.journals.length===1&&w.data.book.lines.length===2&&w.data.book.events.length===3,'Focused book and three decision/application events');view=await r.request('/general-ledger');ok(view.status===200&&view.text.includes('/general-ledger/costs?order_id='+g.order_id),'Generic diary recognises cost source');
|
||||
const dates='from_on=2026-10-01&to_on=2026-10-31';let report=await r.request('/api/general-ledger/trial-balance?'+dates);ok(report.status===200&&report.data.totals.debit_minor===27500&&report.data.totals.credit_minor===27500,'Trial balance reconciles same writer');const account=g.accounts['QA-CUSTO-DIRECTO'];report=await r.request('/api/general-ledger/account-ledger?'+dates+'&account_id='+account);ok(report.status===200&&report.data.closing_net_minor===27500&&report.data.rows[0].cost_order_id===String(g.order_id),'Account ledger retains typed cost origin');view=await r.request('/general-ledger/account-ledger?'+dates+'&account_id='+account);ok(view.status===200&&view.text.includes('/general-ledger/costs?order_id='+g.order_id),'Account ledger links back to actual cost conference');
|
||||
const period=await command(p,'period-prepare',{period_key:'QA-2026-10',reason:'Capture all composed current cost facts and exact journal.'});await command(v,'period-validate',{check_id:period.check_id,revision:period.revision,decision:'approved',reason:'Independent complete cost period evidence review.'});const close={check_id:period.check_id,revision:period.revision,reason:'Explicit synthetic cost period close.'},closeKey=crypto.randomUUID(),closed=await command(p,'period-close',close,closeKey);ok((await command(p,'period-close',close,closeKey)).replayed,'Close request retry is idempotent');ok((await command(p,'period-close',close)).closure_id===closed.closure_id,'Other key does not duplicate closure');view=await p.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Closed period hides new decision forms');
|
||||
fs.writeFileSync(out,JSON.stringify({passed:true,checks,actual_http:true,actual_csrf:true,principal_minor:27500,journals:1,lines:2,source_events:3,report_reconciled:true,typed_cost_origin_link:true,one_reviewed_qa_close:true,repeated_keys_and_source_no_duplicates:true,read_only_workspace:true,writer_locks:0,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,journals:1,lines:2}));
|
||||
}finally{server.kill();fs.closeSync(log);}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Modules\Workshop\DirectCosts;
|
||||
use Tempo\Finance\GeneralLedger\{LedgerSchema,CycleSchema,CostSchema,CostCorrectionSchema,SalesSchema};
|
||||
use Tempo\Finance\Payables\{Payables,PayablesSchema,HistorySchema,SettlementSchema,ReversalSchema};
|
||||
config(['cost_ledger.synthetic_enabled'=>true,'general_ledger.synthetic_enabled'=>true,'general_ledger.cycle_enabled'=>true]);
|
||||
if(in_array($phase,['ledger-cost-setup','ledger-cost-correction-setup','ledger-cost-composition-setup','ledger-cost-industrial-setup','ledger-cost-industrial-correction-setup'],true)){
|
||||
$industrialCorrectionSetup=$phase==='ledger-cost-industrial-correction-setup';$industrialSetup=$phase==='ledger-cost-industrial-setup'||$industrialCorrectionSetup;$compositionSetup=$phase==='ledger-cost-composition-setup'||$industrialSetup;$correctionSetup=$phase==='ledger-cost-correction-setup'||$compositionSetup;
|
||||
if(DB::getSchemaBuilder()->hasTable('users'))throw new LogicException('New dedicated cost database required');$migrations=glob($c['root'].'/database/migrations/*.php');sort($migrations);
|
||||
foreach($migrations as $migration){$name=basename($migration);if($name==='0001_01_01_000000_create_users_table.php'||(str_starts_with($name,'2026_')&&$name<=($industrialSetup?'2026_10_27_100000_create_industrial_cost_simulation.php':'2026_10_27_090000_create_workshop_direct_costs.php')))(require $migration)->up();}
|
||||
foreach(($industrialSetup?['sgf_wo_environment','sgf_dc_environment','sgf_ic_environment']:['sgf_wo_environment','sgf_dc_environment']) as $fn)DB::unprepared("CREATE OR REPLACE FUNCTION $fn() RETURNS void LANGUAGE plpgsql AS \$\$ BEGIN IF current_database() IS DISTINCT FROM '".$c['database']."' OR current_user IS DISTINCT FROM '".$c['role']."' OR inet_server_port() IS DISTINCT FROM 55439 OR current_setting('cluster_name') IS DISTINCT FROM '".$s['cluster']."' THEN RAISE EXCEPTION 'SGF_COST_OWNED_QA_ONLY'; END IF; END \$\$");
|
||||
PayablesSchema::install();HistorySchema::install();SettlementSchema::install();$app->instance('env','payable-testing');ReversalSchema::install();$app->instance('env','testing');LedgerSchema::install(DB::connection());CycleSchema::install(DB::connection());CostSchema::install(DB::connection());
|
||||
if($industrialSetup){Tempo\Finance\GeneralLedger\IndustrialSchema::install(DB::connection());$s['industrial_ledger']=true;file_put_contents($file,json_encode($s,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));}
|
||||
if($correctionSetup){CostCorrectionSchema::install(DB::connection());$s['cost_correction']=true;file_put_contents($file,json_encode($s,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));}
|
||||
if($industrialCorrectionSetup){Tempo\Finance\GeneralLedger\IndustrialCorrectionSchema::install(DB::connection());$s['industrial_correction']=true;file_put_contents($file,json_encode($s,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));}
|
||||
if($compositionSetup){SalesSchema::install(DB::connection());$s['ledger_composition']=true;file_put_contents($file,json_encode($s,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));DB::unprepared("CREATE OR REPLACE FUNCTION sgf_sr_environment() RETURNS void LANGUAGE plpgsql AS \$\$ BEGIN IF current_database() IS DISTINCT FROM '".$c['database']."' OR current_user IS DISTINCT FROM '".$c['role']."' OR inet_server_port() IS DISTINCT FROM 55439 OR current_setting('cluster_name') IS DISTINCT FROM '".$s['cluster']."' THEN RAISE EXCEPTION 'SGF_SR_OWNED_QA_ONLY'; END IF; END \$\$");}
|
||||
Illuminate\Support\Facades\Date::setTestNow('2026-10-29 12:00:00');$base=Tests\Support\DirectCostFixture::create();$scope=['tenant_id'=>$base['tenant'],'company_id'=>$base['company'],'unit_id'=>$base['unit']];
|
||||
$select=function($u)use($scope){auth()->setUser($u);app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);};$d=app(DirectCosts::class);$rates=[];
|
||||
foreach(['material'=>['UN','50.00'],'time'=>['H','100.00']] as $kind=>[$unit,$amount]){$select($base['manager']);$r=$d->rate($base['order'],$base['manager']->id,['entry_id'=>$base[$kind],'entry_version'=>1,'identifier'=>'QA-COST-'.strtoupper($kind),'version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>$unit,'amount'=>$amount,'evidence'=>'Explicit selected artificial cost rate.'],(string)Str::uuid());$rates[$kind]=$r['id'];$select($base['reader']);$d->decide($base['order'],$base['reader']->id,['kind'=>'rate','id'=>$r['id'],'version'=>1,'decision'=>'approved','reason'=>'Independent exact synthetic rate conference.'],(string)Str::uuid());}
|
||||
$lines=[];foreach($rates as $kind=>$id)$lines[]=['entry_id'=>$base[$kind],'entry_version'=>1,'rate_id'=>$id,'rate_version'=>1];$select($base['manager']);$calc=$d->calculate($base['order'],$base['manager']->id,['version'=>1,'previous_id'=>null,'currency'=>'MZN','lines'=>$lines],(string)Str::uuid());$select($base['reader']);$d->decide($base['order'],$base['reader']->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>1,'decision'=>'approved','reason'=>'Independent composition100+175=275 of selected actual verified origins.'],(string)Str::uuid());
|
||||
$read=App\Models\User::create(['name'=>'Consulta de custo e diário QA','email'=>'cost-ledger-read@example.test','password'=>bin2hex(random_bytes(24))]);DB::table('company_memberships')->insert(['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'user_id'=>$read->id]);$actors=['prepare'=>$base['manager']->id,'validate'=>$base['reader']->id,'read'=>$read->id,'technician'=>$base['technician']->id,'technical_review'=>$base['reviewer']->id];
|
||||
DB::table('permission_grants')->whereIn('user_id',[$base['manager']->id,$base['reader']->id,$base['limited']->id])->delete();
|
||||
$def=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((".substr($def,6).") OR (unit_id IS NOT NULL AND permission IN('general_ledger.view','general_ledger.prepare','general_ledger.validate','general_ledger.post','general_ledger.period_prepare','general_ledger.period_validate','general_ledger.period_close')))");
|
||||
foreach(['prepare','validate','read'] as $role){$actor=$actors[$role];foreach(['company.view'=>null,'unit.view'=>$scope['unit_id'],'workshop.view'=>$scope['unit_id'],'direct_costs.view'=>$scope['unit_id'],'general_ledger.view'=>$scope['unit_id']] as $right=>$unit)Tests\Support\IdentityFixture::grant($actor,$scope['tenant_id'],$scope['company_id'],$right,$unit);foreach(match($role){'prepare'=>['direct_costs.prepare','general_ledger.prepare','general_ledger.post','general_ledger.period_prepare','general_ledger.period_close'],'validate'=>['direct_costs.validate','general_ledger.validate','general_ledger.period_validate'],default=>[]} as $right)Tests\Support\IdentityFixture::grant($actor,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);DB::table('pa_access')->insert($scope+['user_id'=>$actor,'person'=>'cost-qa-person-'.$actor,'action'=>'view']);}
|
||||
if($correctionSetup){$def=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((".substr($def,6).") OR (unit_id IS NOT NULL AND permission IN('general_ledger.correct_prepare','general_ledger.correct_validate','general_ledger.correct_post')))");foreach(['prepare'=>['correct_prepare','correct_post'],'validate'=>['correct_validate']] as $role=>$rights)foreach($rights as $r)Tests\Support\IdentityFixture::grant($actors[$role],$scope['tenant_id'],$scope['company_id'],'general_ledger.'.$r,$scope['unit_id']);}
|
||||
$accounts=[];foreach(['QA-CUSTO-DIRECTO','QA-COMPENSACAO-ENSAIO'] as $code)$accounts[$code]=DB::table('gl_accounts')->insertGetId($scope+['code'=>$code,'label'=>'Conta artificial '.$code,'currency'=>'MZN','is_movement'=>true]);$common=['created_by'=>$actors['prepare'],'reason'=>'Explicit authorised direct cost QA composition.','created_at'=>now()];DB::table('gl_periods')->insert($scope+$common+['period_key'=>'QA-2026-10','version'=>1,'starts_on'=>'2026-10-01','ends_on'=>'2026-10-31','status'=>'open']);DB::table('gl_rules')->insert($scope+$common+['rule_key'=>'QA-DIRECT-COST','version'=>1,'policy'=>'QA-DIRECT-COST-JOURNAL-v1','effect_type'=>'workshop.direct_cost.validated','currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts['QA-CUSTO-DIRECTO'],'credit_account_id'=>$accounts['QA-COMPENSACAO-ENSAIO']]);
|
||||
if($correctionSetup)foreach(['REVERSE'=>'reversal','REPLACE'=>'replacement'] as $name=>$kind)DB::table('gl_rules')->insert($scope+$common+['rule_key'=>'QA-COST-CORRECTION-'.$name,'version'=>1,'policy'=>'QA-DIRECT-COST-JOURNAL-v1','effect_type'=>'workshop.direct_cost.'.$kind,'currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts[$kind==='reversal'?'QA-COMPENSACAO-ENSAIO':'QA-CUSTO-DIRECTO'],'credit_account_id'=>$accounts[$kind==='reversal'?'QA-CUSTO-DIRECTO':'QA-COMPENSACAO-ENSAIO']]);
|
||||
DB::table('gl_cost_sources')->insert($scope+$common+['order_id'=>$base['order'],'material_origin'=>DB::table('wo_entries')->where('id',$base['material'])->value('origin'),'time_origin'=>DB::table('wo_entries')->where('id',$base['time'])->value('origin'),'period_key'=>'QA-2026-10']);
|
||||
$g=['scope'=>$scope,'actors'=>$actors,'order_id'=>$base['order'],'material_id'=>$base['material'],'time_id'=>$base['time'],'rate_ids'=>$rates,'calculation_id'=>$calc['id'],'accounts'=>$accounts];file_put_contents($s['root'].'/cost-fixture.json',json_encode($g,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost QA: actual workshop verification/rates/calculation275 through existing workflows, selected mapping and rights only in new database; no journal yet.\n";exit;
|
||||
}
|
||||
$g=json_decode(file_get_contents($s['root'].'/cost-fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$g['scope'];config(['modules.contracted'=>['workshop','finance','accounting'],'modules.companies.'.$scope['tenant_id'].':'.$scope['company_id']=>['contracted'=>['workshop','finance','accounting'],'enabled'=>['workshop','finance','accounting']]]);
|
||||
$enter=function($role)use($g,$scope){$u=App\Models\User::findOrFail($g['actors'][$role]);auth()->setUser($u);app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);return $u;};$ledger=app(App\Modules\Accounting\GeneralLedger::class);$dc=app(DirectCosts::class);$body=['effect_type'=>'workshop.direct_cost.validated','source_id'=>$g['order_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-DIRECT-COST','reason'=>'Capture selected cost275 from actual independently validated workflow.'];$command=fn($role,$action,$data,$key=null,$fault=null)=>$ledger->engine($enter($role),$fault)->command($action,$data,$key??(string)Str::uuid());
|
||||
$sourceFingerprint=function(){ $h=hash_init('sha256');foreach(['wo_orders','wo_entries','wo_decisions','wo_commands','dc_rates','dc_calculations','dc_decisions','dc_commands','dc_events','dc_outbox','permission_grants','pa_obligations','pa_commands'] as $t){hash_update($h,$t);foreach(DB::table($t)->orderBy($t==='dc_outbox'?'event_id':'id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));}return hash_final($h);};
|
||||
if(str_starts_with($phase,'ledger-cost-correction-')){require __DIR__.'/ledger-cost-correction-proof.php';exit;}
|
||||
if(str_starts_with($phase,'ledger-cost-industrial-')){require __DIR__.'/ledger-industrial-proof.php';exit;}
|
||||
if(str_starts_with($phase,'ledger-cost-composition-')){require __DIR__.'/ledger-composition-proof.php';exit;}
|
||||
if($phase==='ledger-cost-http'){require __DIR__.'/ledger-cost-http-entry.php';exit;}
|
||||
if($phase==='ledger-cost-preservation-after-correction'){
|
||||
$output=$s['root'].'/cost-demo-after-correction.json';if(file_exists($output))throw new LogicException('Completed old demo preservation preserved');$prior=json_decode(file_get_contents($s['root'].'/cost-demo-preservation.json'),true);if(config('cost_ledger.correction_enabled')!==false||DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability')||$sourceFingerprint()!==$prior['source_fingerprint'])throw new LogicException('Old8054 source/outbox/rights or capability changed; never reset');file_put_contents($output,json_encode(['passed'=>true,'old8054_source_outbox_rights_exact'=>true,'correction_flag_off'=>true,'correction_schema_absent'=>true,'journals_observed'=>DB::table('gl_journals')->count(),'no_business_writes'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Old8054 source/outbox/rights remain exact; new correction flag/schema absent, no reset.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-legacy-correction-check'){
|
||||
$output=$s['root'].'/cost-correction-legacy-mode.json';if(file_exists($output))throw new LogicException('Completed legacy mode proof preserved');$u=$enter('read');$original=DB::table('gl_journals')->where('snapshot->source->effect_type','workshop.direct_cost.validated')->first();$snap=json_decode($original->snapshot,true);$provider=app(App\Modules\Accounting\CostLedgerSource::class);$source=$provider->source($u,$g['order_id']);$engine=$ledger->engine($u);$period=(new ReflectionProperty($engine,'additionalPeriod'))->getValue($engine);$result=$period($snap['period']);$expected=$provider->period($u,$snap['period']);$caught=false;try{$provider->correctionSource($u,'workshop.direct_cost.reversal',$original->id);}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=$e->getStatusCode()===403;}
|
||||
if(config('cost_ledger.correction_enabled')!==false||DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability')||Payables::canonical($source)!==Payables::canonical($snap['source'])||$result!==$expected||!$caught||DB::table('permission_grants')->where('permission','like','general_ledger.%')->count()!==9)throw new LogicException('Existing cost-only mode was changed or enabled for correction');file_put_contents($output,json_encode(['passed'=>true,'old_cost_source_matches_original_posted_snapshot'=>true,'single_cost_period_provider_packet_preserved'=>true,'new_correction_flag_default_off'=>true,'new_correction_schema_absent'=>true,'uncomposed_correction_refused403'=>true,'old_ledger_grants_still9'=>true,'no_business_writes'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Existing275 source/period/rights preserved; new correction defaults off and is refused without new schema.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-ui-postcheck'){
|
||||
$output=$s['root'].'/cost-ui-postcheck.json';if(file_exists($output))throw new LogicException('Completed UI postcheck preserved');$data=$ledger->listing($enter('read'),['cost_order_id'=>$g['order_id']]);$html=view('accounting.general-ledger',['data'=>$data])->render();$report=$ledger->report($enter('read'),'account-ledger',['from_on'=>'2026-10-01','to_on'=>'2026-10-31','account_id'=>$g['accounts']['QA-CUSTO-DIRECTO']]);
|
||||
if(count($data['journals'])!==1||count($data['events'])!==3||!str_contains($html,'Ordem #'.$g['order_id'])||str_contains($html,'@else')||!str_contains($html,'/general-ledger/costs?order_id='.$g['order_id'])||$report['rows'][0]['cost_order_id']!==(string)$g['order_id'])throw new LogicException('Generic diary labels/typed filter or report source link differs');file_put_contents($output,json_encode(['passed'=>true,'cost_order_not_mislabelled_as_debt'=>true,'typed_filter_journals'=>1,'typed_filter_events'=>3,'generic_diary_and_account_report_source_links'=>true,'read_only_postcheck'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Generic diary/source link/typed filter postcheck passed, without business writes.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-demo-preservation'){
|
||||
$output=$s['root'].'/cost-demo-preservation.json';if(file_exists($output))throw new LogicException('Completed team proof preserved');$others=DB::table('users')->whereNotIn('id',array_values($g['actors']))->pluck('id')->all();
|
||||
if(DB::table('gl_proposals')->exists()||DB::table('gl_journals')->exists()||DB::table('gl_period_closures')->exists()||DB::table('permission_grants')->whereIn('user_id',$others)->exists()||DB::table('dc_outbox')->where('state','<>','captured_no_accounting')->exists()||app(App\Modules\Accounting\CostLedgerSource::class)->source($enter('read'),$g['order_id'])['principal_minor']!==27500)throw new LogicException('Unexpected team fixture state; no reset permitted');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'new_team_fixture'=>true,'declared_synthetic_actors'=>5,'unlisted_users_with_grants'=>0,'actual_validated_cost_minor'=>27500,'no_accounting_decisions_or_effects'=>true,'outbox_captured_no_accounting'=>true,'source_fingerprint'=>$sourceFingerprint()],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Team fixture preserved: actual cost275, five explicit actors, no accounting decisions/effects, outbox untouched.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-defaults'){
|
||||
$output=$s['root'].'/cost-defaults.json';if(file_exists($output))throw new LogicException('Completed defaults proof preserved');$v=getenv('SGF_COST_LEDGER_QA');$a=$_ENV['SGF_COST_LEDGER_QA']??null;$b=$_SERVER['SGF_COST_LEDGER_QA']??null;putenv('SGF_COST_LEDGER_QA');unset($_ENV['SGF_COST_LEDGER_QA'],$_SERVER['SGF_COST_LEDGER_QA']);try{$default=(require $c['root'].'/config/cost_ledger.php')['synthetic_enabled'];}finally{if($v!==false)putenv('SGF_COST_LEDGER_QA='.$v);if($a!==null)$_ENV['SGF_COST_LEDGER_QA']=$a;if($b!==null)$_SERVER['SGF_COST_LEDGER_QA']=$b;}
|
||||
if($default!==false||config('cost_ledger.synthetic_enabled')!==true||config('sales_ledger.synthetic_enabled')!==false)throw new LogicException('Cost default off; explicit cost-only QA composition required');file_put_contents($output,json_encode(['passed'=>true,'default_without_environment'=>false,'owned_cost_only_qa_runtime'=>true,'sales_not_enabled'=>true,'real_environment_not_changed'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost capability defaults off; explicit isolated composition only, Sales remains off.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-baseline'){
|
||||
$out=$s['root'].'/cost-http-baseline.json';if(file_exists($out)||DB::table('gl_proposals')->exists())throw new LogicException('Fresh baseline only');file_put_contents($out,json_encode(['fingerprint'=>$sourceFingerprint(),'journals'=>0],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "New cost HTTP baseline stored privately without business writes.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-http-final'){
|
||||
$out=$s['root'].'/cost-http-final.json';if(file_exists($out))throw new LogicException('Completed final proof preserved');$baseline=json_decode(file_get_contents($s['root'].'/cost-http-baseline.json'),true);$data=$ledger->costs($enter('read'),$g['order_id']);$html=view('accounting.cost-ledger',['data'=>$data])->render();
|
||||
if($sourceFingerprint()!==$baseline['fingerprint']||DB::table('gl_journals')->count()!==1||DB::table('gl_lines')->count()!==2||DB::table('gl_period_closures')->count()!==1||str_contains($html,'data-action='))throw new LogicException('Cost HTTP final preservation/closed UI mismatch');$report=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');
|
||||
file_put_contents($out,json_encode(['passed'=>true,'source_outbox_rights_fingerprint_unchanged'=>true,'journals'=>1,'lines'=>2,'closures'=>1,'closed_ui_has_no_commands'=>true,'debit_minor'=>$report['totals']['debit_minor'],'credit_minor'=>$report['totals']['credit_minor']],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost HTTP final verified: one journal/two lines, one close, originals/outbox/rights unchanged.\n";exit;
|
||||
}
|
||||
if($phase==='ledger-cost-receipt-cases'){require __DIR__.'/ledger-receipt-proof.php';exit;}
|
||||
if($phase==='ledger-cost-cases'){require __DIR__.'/ledger-cost-cases.php';exit;}
|
||||
if($phase==='ledger-cost-coexistence'){require __DIR__.'/ledger-cost-coexistence.php';exit;}
|
||||
if($phase==='ledger-cost-security'){require __DIR__.'/ledger-cost-security.php';exit;}
|
||||
if(in_array($phase,['ledger-cost-race','ledger-cost-worker'],true)){require __DIR__.'/ledger-cost-race.php';exit;}
|
||||
if($phase==='ledger-cost-workflow'){
|
||||
$output=$s['root'].'/cost-workflow.json';if(file_exists($output)||DB::table('gl_proposals')->exists())throw new LogicException('Fresh workflow required');$before=$sourceFingerprint();$p=$command('prepare','effect-prepare',$body);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent cost source, versions and impact review.']);$post=['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply selected cost once without financial/technical mutation.'];$key=(string)Str::uuid();$j=$command('prepare','post',$post,$key);$repeat=$command('prepare','post',$post,$key);$again=$command('prepare','post',$post);
|
||||
$check=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Conference of selected current cost and posted evidence.']);$command('validate','period-validate',['check_id'=>$check['check_id'],'revision'=>$check['revision'],'decision'=>'approved','reason'=>'Distinct complete QA period review.']);$closed=$command('prepare','period-close',['check_id'=>$check['check_id'],'revision'=>$check['revision'],'reason'=>'Close selected cost QA period without reopening.']);
|
||||
$report=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);$account=$ledger->report($enter('read'),'account-ledger',['from_on'=>'2026-10-01','to_on'=>'2026-10-31','account_id'=>$g['accounts']['QA-CUSTO-DIRECTO']]);
|
||||
if(!$repeat['replayed']||$again['journal_id']!==$j['journal_id']||DB::table('gl_journals')->count()!==1||DB::table('gl_lines')->count()!==2||$report['totals']['debit_minor']!==27500||$account['closing_net_minor']!==27500||$sourceFingerprint()!==$before)throw new LogicException('Cost journal/report/repetition/preservation mismatch');DB::select('SELECT gl_assert_balanced(?)',[$j['journal_id']]);file_put_contents($output,json_encode(['passed'=>true,'journal_id'=>$j['journal_id'],'journals'=>1,'lines'=>2,'debit_minor'=>27500,'credit_minor'=>27500,'source_outbox_rights_fingerprint_unchanged'=>true,'same_and_other_key_no_duplication'=>true,'period_closed'=>true,'trial_balance_and_account_ledger_reconciled'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Actual cost275 -> one balanced journal/two lines -> trial balance/account ledger and reviewed QA close; originals/outbox unchanged.\n";exit;
|
||||
}
|
||||
throw new LogicException('Unknown cost phase');
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
if($phase==='ledger-cost-worker'){
|
||||
$input=realpath($argv[3]??'');if(!$input||!str_starts_with($input,realpath($s['root']).DIRECTORY_SEPARATOR))throw new LogicException('Owned worker required');$w=json_decode(file_get_contents($input),true,64,JSON_THROW_ON_ERROR);if(!preg_match('/^cost_(post|close|stale|read)_[ab]_[a-f0-9]{12}$/D',$w['label']))throw new LogicException('Worker label refused');DB::select('SELECT set_config(?,?,false)',['application_name',$w['label']]);DB::statement("SET lock_timeout='30s'");
|
||||
try{if($w['action']==='read'){$data=$ledger->costs($enter('read'),$g['order_id']);$result=['status'=>200,'amount_minor'=>$data['cost']['source']['principal_minor'],'journals'=>count($data['book']['journals'])];}else $result=['status'=>200,'result'=>$command('prepare',$w['action'],$w['body'],$w['key'])];}catch(DomainException $e){$result=['status'=>409,'message'=>$e->getMessage()];}file_put_contents($input.'.result',json_encode($result,JSON_THROW_ON_ERROR));exit;
|
||||
}
|
||||
$output=$s['root'].'/cost-race.json';if(file_exists($output)||DB::table('gl_proposals')->exists())throw new LogicException('Fresh separate cost race fixture required');
|
||||
$originalCalc=hash('sha256',Payables::canonical((array)DB::table('dc_calculations')->where('id',$g['calculation_id'])->first()));$technical=function(){$h=hash_init('sha256');foreach(['wo_orders','wo_entries','wo_decisions'] as $t)foreach(DB::table($t)->orderBy('id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));return hash_final($h);};$technicalBefore=$technical();$oldOutbox=DB::table('dc_outbox')->orderBy('event_id')->get()->toJson();
|
||||
$p=$command('prepare','effect-prepare',$body);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent original cost review before race.']);
|
||||
$change=function()use($dc,$enter,$g){$u=$enter('prepare');$r=$dc->rate($g['order_id'],$u->id,['entry_id'=>$g['material_id'],'entry_version'=>1,'identifier'=>'QA-COST-MATERIAL','version'=>2,'previous_id'=>$g['rate_ids']['material'],'currency'=>'MZN','unit'=>'UN','amount'=>'50.00','evidence'=>'Concurrent successor has same value but another actual reviewed version.'],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'rate','id'=>$r['id'],'version'=>2,'decision'=>'approved','reason'=>'Independent concurrent successor rate approval.'],(string)Str::uuid());return $r['id'];};
|
||||
|
||||
$race=function($name,$action,$body,$number=2,$change=null,$commit=false,$reader=false)use($s,$file,$scope){
|
||||
$workers=[];$inputs=[];$readInput=null;$observed=[];DB::beginTransaction();try{
|
||||
$parent=(int)DB::selectOne('SELECT pg_backend_pid() pid')->pid;DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();$changed=$change?$change():null;
|
||||
$launch=function($label,$workerAction,$body)use(&$workers,$s,$file){$input=$s['root'].'/'.$label.'-'.bin2hex(random_bytes(4)).'.json';file_put_contents($input,json_encode(['label'=>$label,'action'=>$workerAction,'body'=>$body,'key'=>(string)Str::uuid()]));$log=$input.'.log';$p=proc_open([PHP_BINARY,__DIR__.'/payable-history-sgf-proof.php',$file,'ledger-cost-worker',$input],[0=>['file','NUL','r'],1=>['file',$log,'a'],2=>['file',$log,'a']],$pipes,dirname(__DIR__),null,['bypass_shell'=>true]);if(!is_resource($p))throw new LogicException('Worker launch failed');$workers[]=$p;return $input;};
|
||||
foreach(array_slice(['a','b'],0,$number) as $n)$inputs[$n]=$launch('cost_'.$name.'_'.$n.'_'.$s['tag'],$action,$body);
|
||||
if($reader)$readInput=$launch('cost_read_a_'.$s['tag'],'read',[]);
|
||||
config(['database.connections.cost_monitor'=>DB::connection()->getConfig()]);$monitor=DB::connection('cost_monitor');$blocked=[];$until=microtime(true)+15;
|
||||
do{$monitor->select('SELECT pg_stat_clear_snapshot()');foreach($inputs as $n=>$input){$r=$monitor->selectOne('SELECT pid,wait_event_type,wait_event,query,pg_blocking_pids(pid)::text blockers FROM pg_stat_activity WHERE datname=current_database() AND application_name=?',['cost_'.$name.'_'.$n.'_'.$s['tag']]);if($r)$observed[$n]=['pid'=>(int)$r->pid,'wait'=>$r->wait_event_type,'event'=>$r->wait_event,'company'=>str_contains($r->query,'"companies"')&&str_contains(strtolower($r->query),'for update'),'blockers'=>json_decode(str_replace(['{','}'],['[',']'],$r->blockers))];}
|
||||
foreach($observed as $n=>$r){$reach=in_array($parent,$r['blockers'],true);foreach($observed as $other)if(in_array($other['pid'],$r['blockers'],true)&&in_array($parent,$other['blockers'],true))$reach=true;if($reach&&$r['wait']==='Lock'&&$r['company'])$blocked[$n]=['chain_reaches_parent'=>true,'wait_event'=>$r['event']];}
|
||||
if(count($blocked)===$number&&(!$readInput||file_exists($readInput.'.result')))break;usleep(20000);
|
||||
}while(microtime(true)<$until);
|
||||
if(count($blocked)!==$number||($readInput&&!file_exists($readInput.'.result')))throw new LogicException('Actual blocked writers and independent reader required');
|
||||
$readResult=$readInput?json_decode(file_get_contents($readInput.'.result'),true,64,JSON_THROW_ON_ERROR):null;
|
||||
$commit?DB::commit():DB::rollBack();$results=[];foreach($inputs as $n=>$input){$until=microtime(true)+20;while(!file_exists($input.'.result')&µtime(true)<$until)usleep(20000);if(!file_exists($input.'.result'))throw new LogicException('Worker did not finish after release');$results[$n]=json_decode(file_get_contents($input.'.result'),true,64,JSON_THROW_ON_ERROR);}
|
||||
return ['waiting'=>$blocked,'read_before_release'=>$readResult,'results'=>$results,'changed_effect_id'=>$commit?$changed:null];
|
||||
}finally{while(DB::transactionLevel()>0)DB::rollBack();foreach($workers as $p){if(proc_get_status($p)['running'])proc_terminate($p);proc_close($p);}DB::purge('cost_monitor');}
|
||||
};
|
||||
|
||||
$stale=$race('stale','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Must revalidate committed cost source version.'],1,$change,true,true);
|
||||
if($stale['read_before_release']['amount_minor']!==27500||$stale['read_before_release']['journals']!==0||$stale['results']['a']['status']!==409||DB::table('gl_journals')->exists())throw new LogicException('Reader isolation or stale cost version refusal failed');
|
||||
$newRate=$stale['changed_effect_id'];$u=$enter('prepare');$calc=$dc->calculate($g['order_id'],$u->id,['version'=>2,'previous_id'=>$g['calculation_id'],'currency'=>'MZN','lines'=>[['entry_id'=>$g['material_id'],'entry_version'=>1,'rate_id'=>$newRate,'rate_version'=>2],['entry_id'=>$g['time_id'],'entry_version'=>1,'rate_id'=>$g['rate_ids']['time'],'rate_version'=>1]]],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>2,'decision'=>'approved','reason'=>'Independent refreshed calculation275 after exact source version changed.'],(string)Str::uuid());
|
||||
$p=$command('prepare','effect-prepare',$body);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent refreshed accounting capture.']);$posting=$race('post','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply refreshed selected cost once.']);
|
||||
if($posting['results']['a']['status']!==200||$posting['results']['b']['status']!==200||$posting['results']['a']['result']['journal_id']!==$posting['results']['b']['result']['journal_id']||DB::table('gl_journals')->count()!==1||DB::table('gl_lines')->count()!==2)throw new LogicException('Concurrent cost posting duplicated or did not recover');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Confer current selected cost after source refresh and one posting.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent period conference.']);$closing=$race('close','period-close',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Close refreshed selected cost QA once.']);
|
||||
if($closing['results']['a']['status']!==200||$closing['results']['b']['status']!==200||$closing['results']['a']['result']['closure_id']!==$closing['results']['b']['result']['closure_id']||DB::table('gl_period_closures')->count()!==1||DB::table('gl_periods')->count()!==2)throw new LogicException('Concurrent period close duplicated');
|
||||
if($technical()!==$technicalBefore||hash('sha256',Payables::canonical((array)DB::table('dc_calculations')->where('id',$g['calculation_id'])->first()))!==$originalCalc||DB::table('dc_outbox')->whereIn('event_id',array_column(json_decode($oldOutbox,true),'event_id'))->orderBy('event_id')->get()->toJson()!==$oldOutbox||DB::table('dc_outbox')->where('state','<>','captured_no_accounting')->exists())throw new LogicException('Original technical/cost/outbox state changed');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'workers'=>6,'stale_post_after_committed_rate_version'=>$stale,'fresh_posting'=>$posting,'fresh_period_close'=>$closing,'read_finished_before_writer_release'=>true,'same_cost_amount_minor'=>27500,'new_calculation_version'=>2,'journals'=>1,'lines'=>2,'closures'=>1,'technical_original_calculation_and_original_outbox_unchanged'=>true,'all_outbox_still_captured_no_accounting'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Six cost workers: reader before release, stale409 after committed source version, refreshed post and close each once; originals/outbox preserved.\n";
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
$output=$s['root'].'/cost-security.json';if(file_exists($output))throw new LogicException('Completed security proof preserved');$before=$sourceFingerprint();$checks=0;$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};
|
||||
foreach(['unit','company','tenant'] as $kind){DB::beginTransaction();try{
|
||||
$u=$enter('read');$company=$kind==='unit'?DB::table('companies')->where('id',$scope['company_id'])->first():($kind==='company'?DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id','<>',$scope['company_id'])->first():DB::table('companies')->where('tenant_id','<>',$scope['tenant_id'])->first());$unit=DB::table('units')->insertGetId(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'name'=>'Rollback-only eligible foreign '.$kind,'created_at'=>now(),'updated_at'=>now()]);if($kind!=='unit')DB::table('company_memberships')->insert(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id]);foreach(['company.view'=>null,'unit.view'=>$unit,'workshop.view'=>$unit,'direct_costs.view'=>$unit,'general_ledger.view'=>$unit] as $right=>$v)Tests\Support\IdentityFixture::grant($u->id,$company->tenant_id,$company->id,$right,$v);app(CompanyContext::class)->selectFor($u->id,$company->tenant_id,$company->id,$unit);$caught=false;try{$ledger->costs($u,$g['order_id']);}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException){$caught=true;}$ok($caught,'Eligible foreign '.$kind.' must not read original cost/order');
|
||||
}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}}
|
||||
foreach(['workshop.view','direct_costs.view','general_ledger.view'] as $right){DB::beginTransaction();try{$u=$enter('read');DB::table('permission_grants')->where($scope)->where('user_id',$u->id)->where('permission',$right)->delete();$caught=false;try{$ledger->costs($u,$g['order_id']);}catch(Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught,'Revoked '.$right.' refused before returning evidence');}finally{DB::rollBack();$enter('read');}}
|
||||
DB::beginTransaction();try{
|
||||
$old=DB::table('wo_entries')->where('id',$g['time_id'])->first();$d=json_decode($old->data,true,32,JSON_THROW_ON_ERROR);unset($d['policy']);$d=array_replace($d,['version'=>2,'previous_id'=>$old->id,'correction_reason'=>'Rollback-only unverified successor must suspend cost eligibility.','revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) v',[$g['order_id']])->v]);$u=$enter('technician');$new=app(App\Modules\Workshop\WorkshopWorkflow::class)->record($g['order_id'],$u->id,$d,(string)Str::uuid());$ok(!DB::table('wo_decisions')->where('entry_id',$new['id'])->exists(),'Actual successor workshop entry remains unverified');$u=$enter('read');$w=$ledger->costs($u,$g['order_id']);$ok($w['cost']['source']===null&&$w['cost']['pending']!==null&&!$w['cost']['facts']['conference']['current'],'Unverified successor leaves source visibly pending, never inherits old verification');$caught=false;try{app(App\Modules\Accounting\CostLedgerSource::class)->source($u,$g['order_id']);}catch(DomainException){$caught=true;}$ok($caught,'Posting source cannot use unverified/outdated origins even for the same amount');
|
||||
}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}
|
||||
$ok($sourceFingerprint()===$before,'Rights, technical entries, costs and outbox restored exactly');file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'eligible_foreign_unit_company_tenant_refused'=>true,'three_live_view_rights_rechecked'=>true,'actual_unverified_successor_refused'=>true,'no_inherited_verification'=>true,'rollback_only'=>true,'source_outbox_rights_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost security: $checks checks, foreign eligible contexts and revoked rights refused; actual unverified successor leaves pending source; rollback preserves originals.\n";
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$out=$s['root'].'/'.$phase.'.json';if(file_exists($out)||!($g['cycle_enabled']??false))throw new LogicException('Fresh cycle proof required');
|
||||
$finance=$cycleFinance??app(SupplierPayables::class);
|
||||
$checks=0;$ok=function(bool $v,string $why)use(&$checks){$checks++;if(!$v)throw new LogicException($why);};
|
||||
$refuse=function(Closure $fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=true;}$ok($caught,'Expected current-state/authority refusal');};
|
||||
$fin=fn(string $role,string $action,array $body)=>$finance->engine($enter($role))->settlements()->command($action,$body,(string)Str::uuid());
|
||||
$post=function(array $p,string $reason='Apply reviewed synthetic cycle effect.')use($command){$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent exact cycle effect review.']);return $command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>$reason]);};
|
||||
$effect=fn(string $type,int $id,string $rule)=>$command('prepare','effect-prepare',['effect_type'=>$type,'source_id'=>$id,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>$rule,'reason'=>'Capture confirmed financial fact without changing debt.']);
|
||||
$baseline=$financialFingerprint();$rollback=in_array($phase,['ledger-cycle-cases','native-cycle-cases'],true);if($rollback)DB::beginTransaction();
|
||||
try{
|
||||
$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Cannot close unaccounted recognised debt.']));
|
||||
$recognition=$post($command('prepare','prepare',$g['body']));
|
||||
$sourceRevision=DB::table('pa_obligations')->where('id',$g['obligation_id'])->value('source_revision');
|
||||
$payment=$fin('prepare','payment-prepare',['obligation_id'=>$g['obligation_id'],'source_revision'=>$sourceRevision,'amount'=>'20.00','currency'=>'MZN','paid_on'=>'2026-10-29','fact_reference'=>'SIM-CYCLE-PAYMENT-20','evidence_reference'=>'SIM-CYCLE-EVIDENCE-20','reason'=>'Explicit synthetic payment fact; no execution.']);
|
||||
$fin('validate','payment-decide',['payment_id'=>$payment['payment_id'],'decision'=>'confirmed','evidence_reference'=>'SIM-CYCLE-CONFIRMED-20','reason'=>'Independent synthetic payment evidence.']);
|
||||
$plan=$fin('prepare','allocation-prepare',['payment_id'=>$payment['payment_id'],'reference'=>'SIM-CYCLE-ALLOCATION-20','allocations'=>[['obligation_id'=>$g['obligation_id'],'source_revision'=>$sourceRevision,'amount'=>'20.00']],'reason'=>'Explicit partial allocation of twenty.']);
|
||||
$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Pending financial allocation prevents closing.']));
|
||||
$fin('validate','allocation-decide',['proposal_id'=>$plan['proposal_id'],'decision'=>'confirmed','reason'=>'Independent partial allocation confirmation.']);
|
||||
$allocation=(int)DB::table('ps_allocations')->where('obligation_id',$g['obligation_id'])->value('id');
|
||||
$position=$finance->engine($enter('read'))->positionForSource($g['local_source_id']);$ok($position['principal_minor']===6000&&$position['balance_minor']===4000,'Partial allocation preserves principal and balance40');
|
||||
$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Unaccounted confirmed allocation prevents closing.']));
|
||||
$p=$effect('allocation.confirmed',$allocation,'QA-ALLOCATION');
|
||||
$refuse(fn()=>$command('read','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Reader cannot post financial effect.']));
|
||||
$refuse(fn()=>$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>str_repeat('0',64),'decision'=>'approved','reason'=>'Old revision must refuse.']));
|
||||
$allocated=$post($p);$j=DB::table('gl_journals')->where('id',$allocated['journal_id'])->first();$ok((int)$j->debit_minor===2000&&json_decode($j->snapshot,true)['source']['principal_minor']===6000,'Journal amount20 is distinct from principal60');
|
||||
$r=$fin('prepare','reversal-prepare',['allocation_id'=>$allocation,'source_revision'=>$sourceRevision,'reason'=>'Reverse the full synthetic allocation.']);
|
||||
$fin('validate','reversal-confirm',['proposal_id'=>$r['proposal_id'],'reason'=>'Independent confirmation of integral reversal.']);
|
||||
$reversal=(int)DB::table('ps_reversals')->where('allocation_id',$allocation)->value('id');
|
||||
$reversed=$post($effect('allocation.reversed',$reversal,'QA-REVERSAL'));
|
||||
$position=$finance->engine($enter('read'))->positionForSource($g['local_source_id']);$ok($position['balance_minor']===6000,'Confirmed reversal restores balance60 without changing principal');
|
||||
$correction=$command('prepare','correct-prepare',['journal_id'=>$recognition['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Technical correction with unchanged financial debt.']);
|
||||
$refuse(fn()=>$command('prepare','correct-post',['correction_id'=>$correction['correction_id'],'revision'=>$correction['revision'],'reason'=>'Cannot apply unreviewed correction.']));
|
||||
$command('validate','correct-validate',['correction_id'=>$correction['correction_id'],'revision'=>$correction['revision'],'decision'=>'approved','reason'=>'Independent review of both corrective journal impacts.']);
|
||||
$body=['correction_id'=>$correction['correction_id'],'revision'=>$correction['revision'],'reason'=>'Atomically apply full reversal and replacement.'];$key=(string)Str::uuid();
|
||||
if($phase==='ledger-cycle-race-prepare'){
|
||||
file_put_contents($s['root'].'/ledger-cycle-race-prepared.json',json_encode(['passed'=>true,'checks'=>$checks,'correction_body'=>$body,'recognition_journal_id'=>$recognition['journal_id'],'allocation_id'=>$allocation,'reversal_id'=>$reversal],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Prepared independent corrective pair for real concurrent workers.\n";exit;
|
||||
}
|
||||
if($rollback){
|
||||
$n=DB::table('gl_journals')->count();$fault=function($at){if($at==='after_correction_first')throw new RuntimeException('QA pair fault');};
|
||||
try{$command('prepare','correct-post',$body,$key,$fault);throw new LogicException('Fault required');}catch(RuntimeException $e){$ok($e->getMessage()==='QA pair fault','Fault reached first corrective journal');}
|
||||
$ok(DB::table('gl_journals')->count()===$n&&!DB::table('gl_correction_results')->exists(),'First journal rolled back with whole corrective pair');
|
||||
}
|
||||
$corrected=$command('prepare','correct-post',$body,$key);$repeat=$command('prepare','correct-post',$body,$key);$other=$command('prepare','correct-post',$body);
|
||||
$ok($repeat['replayed']&&$other['result_id']===$corrected['result_id']&&DB::table('gl_correction_results')->count()===1,'Same/different keys preserve one correction pair');
|
||||
$original=DB::table('gl_journals')->where('id',$recognition['journal_id'])->first();$ok($original&&DB::table('gl_journals')->count()===5&&DB::table('gl_lines')->count()===10,'Original plus allocation/reversal and atomic corrective pair retained');
|
||||
$period=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Capture complete reconciled artificial period.']);
|
||||
$refuse(fn()=>$command('read','period-close',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'reason'=>'Reader cannot close.']));
|
||||
if($rollback){
|
||||
DB::beginTransaction();try{
|
||||
$rule=(array)DB::table('gl_rules')->where('rule_key','QA-ALLOCATION')->first();unset($rule['id']);$rule['version']++;DB::table('gl_rules')->insert($rule);
|
||||
$refuse(fn()=>$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Changed evidence requires a fresh close conference.']));
|
||||
}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{
|
||||
if($native??false)DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'general_ledger.period_validate'])->delete();else DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['validate'],'permission'=>'general_ledger.period_validate'])->delete();
|
||||
$refuse(fn()=>$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Revoked period right must refuse.']));
|
||||
}finally{DB::rollBack();}
|
||||
}
|
||||
$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Independent complete period evidence and totals review.']);
|
||||
$close=['check_id'=>$period['check_id'],'revision'=>$period['revision'],'reason'=>'Close artificial period with preserved evidence.'];$closeKey=(string)Str::uuid();
|
||||
if($rollback){
|
||||
try{$command('prepare','period-close',$close,$closeKey,function($at){if($at==='after_period_version')throw new RuntimeException('QA close fault');});throw new LogicException('Fault required');}catch(RuntimeException $e){$ok($e->getMessage()==='QA close fault','Close fault reached version append');}
|
||||
$ok(DB::table('gl_periods')->count()===1&&!DB::table('gl_period_closures')->exists(),'Failed close leaves open period intact');
|
||||
}
|
||||
$closed=$command('prepare','period-close',$close,$closeKey);$again=$command('prepare','period-close',$close,$closeKey);$semantic=$command('prepare','period-close',$close);
|
||||
$ok($closed['state']==='closed'&&$again['replayed']&&$semantic['closure_id']===$closed['closure_id'],'Closed period recovers receipt without reopening');
|
||||
$refuse(fn()=>$effect('allocation.confirmed',$allocation,'QA-ALLOCATION'));
|
||||
$refuse(fn()=>$command('prepare','correct-prepare',['journal_id'=>$recognition['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'No second correction to same original.']));
|
||||
DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');
|
||||
$totals=$ledger->listing($enter('read'))['totals'];$ok($totals===['debit_minor'=>22000,'credit_minor'=>22000,'net_minor'=>0],'Five journals reconcile gross debits and credits220');
|
||||
$result=['passed'=>true,'checks'=>$checks,'rollback_only'=>$rollback,'principal_minor'=>6000,'allocated_balance_minor'=>4000,'final_balance_minor'=>6000,'debit_minor'=>22000,'credit_minor'=>22000,'journals'=>5,'lines'=>10,'correction_pairs'=>1,'period_closed'=>true,'obligation_id'=>$g['obligation_id'],'recognition_journal_id'=>$recognition['journal_id'],'allocation_id'=>$allocation,'reversal_id'=>$reversal,'correction_id'=>$correction['correction_id'],'period_check_id'=>$period['check_id']];
|
||||
}finally{if($rollback)while(DB::transactionLevel()>0)DB::rollBack();}
|
||||
if($rollback){$ok($financialFingerprint()===$baseline&&DB::table('gl_journals')->count()===0,'Adversarial cycle fully rolled back');$result['checks']=$checks;}
|
||||
file_put_contents($out,json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cycle complete: $checks checks, ".($rollback?'rollback-only':'persisted QA workflow').".\n";
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),mode=process.argv[2],tag=process.argv[3],native=mode==='native';if(state.port!==55439||!['native','sgf'].includes(mode)||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned demo required');
|
||||
const root=path.join(state.root,'private/ledger-'+(native?'native-':'')+tag),demo=read(path.join(root,'ledger-demo.json')),f=read(path.join(root,'fixture.json')),g=read(path.join(root,'ledger-fixture.json')),output=path.join(root,'ledger-cycle-demo-http.json');if(fs.existsSync(output)||!g.cycle_enabled)throw Error('Unfinished cycle readiness required');const base=new URL(demo.url).origin;let checks=0;const check=(v,s)=>{checks++;assert(v,s);};let r=await fetch(demo.url);check(r.status===200&&(await r.text()).includes('data-ledger-demo="'+tag+'"'),'Persistent cycle landing ready');
|
||||
for(const role of ['prepare','validate','read']){
|
||||
const jar=new Map();let auth;const req=async(url,body)=>{const r=await fetch(base+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth.csrf}:{})},body:body?JSON.stringify(body):undefined,redirect:'manual'});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],at=p.indexOf('=');jar.set(p.slice(0,at),p.slice(at+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text};};
|
||||
r=await req('/qa/ledger-enter/'+role);check(r.status===200,'Own synthetic '+role);auth=r.data;check(jar.has(demo.cookie)&&!jar.has('XSRF-TOKEN'),'Isolated cookie');
|
||||
r=await req('/api/general-ledger/cycle');check(r.status===200&&r.data.book.journals.length===0&&r.data.book.proposals.length===0&&r.data.allocations.length===0&&r.data.reversals.length===0&&r.data.period_closures.length===0&&r.data.periods.length===1,'Initial cycle has no effects or close');check(r.data.obligations.length===1&&r.data.obligations[0].principal_minor===6000,'Exactly one recognised obligation60');
|
||||
r=await req('/general-ledger/cycle');check(r.status===200&&r.text.includes('ciclo contabilístico QA')&&r.text.includes('Conferência e fecho do período QA'),'Common workspace rendered');check(role==='prepare'?r.text.includes('data-action='):!r.text.includes('data-action='),'Role-specific forms');
|
||||
if(!native){r=await req('/api/context',{...f.scope,module_id:'purchasing'});check(r.status===200,'Existing explicit financial context selection');auth={csrf:r.data.csrf,context:r.data.context.token};}
|
||||
r=await req('/supplier-payments');check(r.status===200&&r.text.includes('ledger_cycle_url'),'Return link from finance to accounting composed');
|
||||
r=await req('/api/supplier-payables?'+(native?'invoice=':'source=')+g.local_source_id);check(r.status===200&&r.data.obligations.length===1&&r.data.obligations[0].balance_minor===6000,'Financial principal and balance untouched');
|
||||
if(native)check(r.data.documents[0].issued_on==='2026-10-29','Explicit synthetic fixture date belongs to QA period');
|
||||
}
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,checks,composition:mode,url:demo.url,tag,three_roles:true,no_business_commands:true,initial_principal_minor:6000,initial_balance_minor:6000,initial_journals:0,initial_payment_effects:0,financial_return_link:true,own_cookie:true,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,composition:mode,url:demo.url}));
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
use Tempo\Finance\GeneralLedger\CycleWorkspace;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$output=$s['root'].'/ledger-cycle-final.json';if(file_exists($output))throw new LogicException('Completed final proof preserved');$checks=0;
|
||||
$ok=function(bool $v,string $why)use(&$checks){$checks++;if(!$v)throw new LogicException($why);};$before=$financialFingerprint();$native=$native??false;$reader=$enter('read');$sql=[];$mode=null;$capture=true;
|
||||
DB::listen(function($e)use(&$sql,&$mode,&$capture){if(!$capture)return;$sql[]=$e->sql;if(!$mode&&str_contains($e->sql,'"gl_journals"')){$pdo=$e->connection->getPdo();$mode=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
|
||||
$workspace=$native?$ledger->workspace($reader):$ledger->cycle($reader);$capture=false;
|
||||
$ok($mode===['isolation'=>'repeatable read','read_only'=>'on']&&!array_filter($sql,fn($q)=>str_contains(strtolower($q),'for update')),'Whole workspace shares coherent read snapshot without writer locks');
|
||||
$ok(count($workspace['book']['journals'])===5&&count($workspace['book']['lines'])===10&&count($workspace['period_closures'])===1,'Retained complete cycle counts');
|
||||
$ok($workspace['book']['totals']===['debit_minor'=>22000,'credit_minor'=>22000,'net_minor'=>0],'Gross period totals220 reconciled');
|
||||
foreach(['validate','post'] as $action){$part=DB::table('gl_proposals')->where('snapshot->source->effect_type','journal.reversal')->first();$body=['proposal_id'=>$part->id,'revision'=>$part->revision,'reason'=>'Individual route must refuse corrective pair.'];if($action==='validate')$body['decision']='approved';$caught=false;try{$command($action==='post'?'prepare':'validate',$action,$body);}catch(DomainException $e){$caught=str_contains($e->getMessage(),'par de correcção');}$ok($caught,'Public individual route refuses corrective part with domain refusal');}
|
||||
if(!$native){$flow=$ledger->flow($enter('read'),$g['local_source_id']);$ok(json_decode($flow['journal']->snapshot,true)['source']['effect_type']==='supplier_payable.recognised','Original recognition flow is not replaced by corrective/settlement effects');}
|
||||
$events=DB::table('gl_events')->count();DB::beginTransaction();try{
|
||||
for($i=0;$i<60;$i++)DB::table('gl_events')->insert($scope+['actor_id'=>$f['actors']['prepare'],'person'=>'explicit-qa-history-probe','kind'=>'qa.pagination','entity_id'=>1,'reason'=>'Rollback-only long history test.','previous'=>'{}','subsequent'=>'{}','created_at'=>now()]);
|
||||
$data=$native?$ledger->workspace($enter('read'),2):$ledger->cycle($enter('read'),2);
|
||||
$ok(count($data['book']['events'])===25&&$data['book']['evidence_pagination']['events']['last_page']>=3,'History remains bounded with further pages');
|
||||
$html=CycleWorkspace::render($data,['host'=>'QA','csrf'=>'qa','return_url'=>'/','financial_url'=>'/supplier-payments','page_url'=>'/general-ledger/cycle']);
|
||||
$ok(str_contains($html,'?page=3'),'Navigation reaches evidence pages after last journal page');
|
||||
}finally{DB::rollBack();}
|
||||
$ok($before===$financialFingerprint()&&DB::table('gl_events')->count()===$events,'Read and paging probes preserve original financial/ledger records');
|
||||
if($native)$ok(fingerprint()===$g['native_originals_sha256'],'Native original invoice/items/external payments/stock/budget fingerprint preserved');
|
||||
$ok(($native?(require $c['root'].'/config/general-ledger.php'):(require $c['root'].'/config/general_ledger.php'))['cycle_enabled']===false,'Destination cycle flag remains off');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'composition'=>$native?'native':'sgf','coherent_read'=>$mode,'writer_locks'=>0,'financial_sha256'=>$before,'financial_unchanged'=>true,'bounded_evidence_navigation'=>true,'individual_corrective_route_409'=>true,'native_originals_preserved'=>$native?true:null,'default_cycle_enabled'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Final cycle preservation/read/navigation contract: $checks checks.\n";
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import crypto from 'node:crypto';import assert from 'node:assert/strict';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),mode=process.argv[2],tag=process.argv[3];
|
||||
if(state.port!==55439||!['sgf','native'].includes(mode)||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned cycle composition required');
|
||||
const native=mode==='native',root=path.join(state.root,'private/ledger-'+(native?'native-':'')+tag),settings=path.join(root,'settings.json'),owned=read(settings),f=read(path.join(root,'fixture.json')),g=read(path.join(root,'ledger-fixture.json')),output=path.join(root,'ledger-cycle-http.json');if(!g.cycle_enabled||fs.existsSync(output))throw Error('Unfinished cycle HTTP proof required');
|
||||
await new Promise((resolve,reject)=>{const p=net.createServer();p.once('error',reject);p.listen(8047,'127.0.0.1',()=>p.close(resolve));});
|
||||
const log=fs.openSync(path.join(root,'cycle-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve(native?'scripts/ledger-cycle-native-http-router.php':'scripts/ledger-http-router.php')],{cwd:process.cwd(),env:{...process.env,LEDGER_QA_SETTINGS:settings},windowsHide:true,stdio:['ignore',log,log]});
|
||||
let checks=0;const check=(v,s)=>{checks++;assert(v,s);};const client=()=>{let auth;const jar=new Map();const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(12000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/ledger-enter/'+role);check(r.status===200,'Fixture identity '+role);auth=r.data;check(jar.has((native?'sgc':'sgf')+'_ledger_'+tag)&&!jar.has('XSRF-TOKEN'),'Isolated session cookie');},module:async module=>{if(native)return;const old=auth.context,r=await request('/api/context',{...f.scope,module_id:module});check(r.status===200,'Explicit existing context switch '+module);auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
|
||||
const ledger=async(c,action,body,key)=>{const r=await c.request('/api/general-ledger/'+action,body,key);check(r.status===200,'Ledger '+action+' succeeds: '+r.status+' '+(r.data?.message??''));return r.data;};
|
||||
const finance=async(c,action,body)=>{const r=await c.request('/api/supplier-payments/'+action,body);check(r.status===200,'Financial '+action+' succeeds: '+r.status+' '+(r.data?.message??''));return r.data;};
|
||||
try{
|
||||
const p=client(),v=client(),r=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await p.request('/api/general-ledger/cycle')).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}check(ready,'Unauthenticated cycle refused');await p.enter('prepare');await v.enter('validate');await r.enter('read');
|
||||
let data=await r.request('/api/general-ledger/cycle');check(data.status===200&&data.data.book.journals.length===0&&data.data.allocations.length===0,'Starts with no accounting or allocation effect');
|
||||
let html=await p.request('/general-ledger/cycle');check(html.status===200&&html.text.includes('data-action="correct-prepare"')&&html.text.includes('data-action="period-prepare"'),'Common functional cycle interface rendered');
|
||||
html=await r.request('/general-ledger/cycle');check(html.status===200&&!html.text.includes('data-action='),'Reader has no command forms');
|
||||
for(const q of ['page=0','tenant_id=99','page%5Bx%5D=1'])check((await r.request('/api/general-ledger/cycle?'+q)).status===422,'Unknown/invalid page query refused');
|
||||
check((await r.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Reader cannot close period.'})).status===403,'Reader cannot prepare close');
|
||||
check((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Missing token must refuse.'},undefined,{'X-CSRF-TOKEN':''})).status===419,'Actual CSRF validation retained');
|
||||
check((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Missing recognition journal.'})).status===409,'Unaccounted recognition prevents closing');
|
||||
const prepared=await ledger(p,'prepare',g.body);await ledger(v,'validate',{proposal_id:prepared.proposal_id,revision:prepared.revision,decision:'approved',reason:'Independent original recognition review.'});const original=await ledger(p,'post',{proposal_id:prepared.proposal_id,revision:prepared.revision,reason:'Apply original recognition once.'});
|
||||
await p.module('purchasing');await v.module('purchasing');const focus='/api/supplier-payables?'+(native?'invoice=':'source=')+g.local_source_id;data=await p.request(focus);check(data.status===200&&data.data.obligations.length===1,'Financial source accessible in own module');const sourceRevision=data.data.obligations[0].source_revision;
|
||||
const payment=await finance(p,'payment-prepare',{obligation_id:g.obligation_id,source_revision:sourceRevision,amount:'20.00',currency:'MZN',paid_on:'2026-10-29',fact_reference:'SIM-HTTP-CYCLE-20',evidence_reference:'SIM-HTTP-EVIDENCE-20',reason:'Synthetic evidence only; no money execution.'});await finance(v,'payment-decide',{payment_id:payment.payment_id,decision:'confirmed',evidence_reference:'SIM-HTTP-CONFIRMED-20',reason:'Independent synthetic payment fact confirmation.'});
|
||||
const plan=await finance(p,'allocation-prepare',{payment_id:payment.payment_id,reference:'SIM-HTTP-PLAN-20',allocations:[{obligation_id:g.obligation_id,source_revision:sourceRevision,amount:'20.00'}],reason:'Explicit partial allocation twenty.'});await finance(v,'allocation-decide',{proposal_id:plan.proposal_id,decision:'confirmed',reason:'Independent exact partial allocation review.'});
|
||||
data=await p.request(focus);check(data.data.obligations[0].principal_minor===6000&&data.data.obligations[0].balance_minor===4000,'Financial partial balance40 principal60');await p.module('accounting');await v.module('accounting');
|
||||
data=await p.request('/api/general-ledger/cycle');const allocation=data.data.allocations[0];check(allocation.amount_minor===2000,'Confirmed allocation is available as source');
|
||||
const effectBody=(kind,id,rule)=>({effect_type:kind,source_id:id,journal_on:'2026-10-29',period_key:'QA-2026-10',rule_key:rule,reason:'Capture exact confirmed synthetic financial fact.'});
|
||||
check((await p.request('/api/general-ledger/effect-prepare',effectBody('allocation.confirmed',999999,'QA-ALLOCATION'))).status===409,'Foreign or unavailable financial effect refused');
|
||||
check((await p.request('/api/general-ledger/effect-prepare',effectBody('journal.reversal',original.journal_id,'QA-CORRECTION-REVERSE'))).status===422,'Corrective effect cannot bypass grouped route');
|
||||
const a=await ledger(p,'effect-prepare',effectBody('allocation.confirmed',allocation.id,'QA-ALLOCATION'));await ledger(v,'validate',{proposal_id:a.proposal_id,revision:a.revision,decision:'approved',reason:'Independent review of twenty allocation impact.'});const allocationPost={proposal_id:a.proposal_id,revision:a.revision,reason:'Apply allocation accounting without second deduction.'},postKey=crypto.randomUUID();await ledger(p,'post',allocationPost,postKey);
|
||||
await p.module('purchasing');await v.module('purchasing');const reverse=await finance(p,'reversal-prepare',{allocation_id:allocation.id,source_revision:sourceRevision,reason:'Synthetic full allocation reversal.'});await finance(v,'reversal-confirm',{proposal_id:reverse.proposal_id,reason:'Independent integral reversal review.'});data=await p.request(focus);check(data.data.obligations[0].balance_minor===6000,'Financial reversal restores60');await p.module('accounting');await v.module('accounting');
|
||||
data=await p.request('/api/general-ledger/cycle');const reversal=data.data.reversals[0];const rev=await ledger(p,'effect-prepare',effectBody('allocation.reversed',reversal.id,'QA-REVERSAL'));await ledger(v,'validate',{proposal_id:rev.proposal_id,revision:rev.revision,decision:'approved',reason:'Independent reversal accounting review.'});await ledger(p,'post',{proposal_id:rev.proposal_id,revision:rev.revision,reason:'Apply reversal accounting once.'});
|
||||
const correction=await ledger(p,'correct-prepare',{journal_id:original.journal_id,journal_on:'2026-10-29',period_key:'QA-2026-10',reason:'Technical correction preserves original and debt.'});
|
||||
html=await v.request('/general-ledger/cycle');check(html.status===200&&html.text.includes('journal.reversal')&&html.text.includes('journal.replacement')&&html.text.includes('Impacto exacto dos dois'),'Both exact corrective impacts presented before decision');
|
||||
check((await p.request('/api/general-ledger/correct-post',{correction_id:correction.correction_id,revision:correction.revision,reason:'Unreviewed pair must refuse.'})).status===409,'Corrective pair needs independent review');
|
||||
await ledger(v,'correct-validate',{correction_id:correction.correction_id,revision:correction.revision,decision:'approved',reason:'Independent review of both exact snapshots.'});const correctBody={correction_id:correction.correction_id,revision:correction.revision,reason:'Apply reviewed pair in one transaction.'},correctKey=crypto.randomUUID();const corrected=await ledger(p,'correct-post',correctBody,correctKey);check((await ledger(p,'correct-post',correctBody,correctKey)).replayed,'Corrective same-key replay');check((await ledger(p,'correct-post',correctBody)).result_id===corrected.result_id,'Corrective different-key same effect');
|
||||
const period=await ledger(p,'period-prepare',{period_key:'QA-2026-10',reason:'Capture reconciled cycle evidence for close.'});check((await v.request('/api/general-ledger/period-validate',{check_id:period.check_id,revision:'0'.repeat(64),decision:'approved',reason:'Obsolete revision must refuse.'})).status===409,'Stale close revision refused');await ledger(v,'period-validate',{check_id:period.check_id,revision:period.revision,decision:'approved',reason:'Independent period evidence review.'});const close={check_id:period.check_id,revision:period.revision,reason:'Close complete synthetic period once.'},closeKey=crypto.randomUUID();const closed=await ledger(p,'period-close',close,closeKey);check((await ledger(p,'period-close',close,closeKey)).replayed,'Close same-key replay');check((await ledger(p,'period-close',close)).closure_id===closed.closure_id,'Close different-key no extra version');
|
||||
check((await ledger(p,'post',allocationPost,postKey)).replayed,'Old posting receipt recovers after period closed');check((await p.request('/api/general-ledger/prepare',g.body)).status===409,'Closed period refuses new capture');
|
||||
data=await r.request('/api/general-ledger/cycle');check(data.status===200&&data.data.book.journals.length===5&&data.data.book.lines.length===10&&data.data.correction_results.length===1&&data.data.period_closures.length===1&&data.data.periods.length===2&&data.data.book.totals.debit_minor===22000&&data.data.book.totals.credit_minor===22000,'Final five journals and one period closure reconciled');
|
||||
html=await r.request('/general-ledger/cycle');check(html.status===200&&html.text.includes('Par aplicado')&&html.text.includes('Fecho #')&&html.text.includes('closed')&&!html.text.includes('data-action='),'Reader sees preserved original, pair, closure and no commands');check(data.headers.get('cache-control').includes('no-store'),'Private responses');
|
||||
await p.module('purchasing');data=await p.request(focus);check(data.data.obligations[0].principal_minor===6000&&data.data.obligations[0].balance_minor===6000,'Ledger correction and close never mutate financial debt');
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,checks,composition:mode,tag,actual_http:true,financial_commands:true,all_ledger_actions:true,principal_minor:6000,partial_balance_minor:4000,final_balance_minor:6000,journals:5,lines:10,debit_minor:22000,credit_minor:22000,correction_pairs:1,period_closures:1,real_csrf:true,no_browser:true,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,composition:mode,tag}));
|
||||
}finally{if(server.exitCode===null){server.kill();await new Promise(r=>server.once('exit',r));}fs.closeSync(log);}
|
||||
@@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned native QA tag required');
|
||||
const root=path.join(state.root,'private/ledger-native-'+tag),settings=path.join(root,'settings.json'),owned=read(settings),record=path.join(root,'ledger-demo.json');if(owned.tag!==tag||owned.cluster!=='sgf_modular_regression_'+state.id)throw Error('Wrong cluster');
|
||||
if(fs.existsSync(record)){const old=read(record),r=await fetch(old.url,{signal:AbortSignal.timeout(2000)});if(!r.ok||!(await r.text()).includes('data-ledger-demo="'+tag+'"'))throw Error('Saved demo not ready; inspect without reset');console.log(JSON.stringify({...old,already_running:true}));process.exit(0);}
|
||||
let port;for(let p=8050;p<=8059&&!port;p++){try{await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(p,'127.0.0.1',()=>s.close(resolve));});port=p;}catch{}}if(!port)throw Error('No dedicated loopback port');
|
||||
const log=fs.openSync(path.join(root,'ledger-demo-server.log'),'a'),child=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:'+port,path.resolve('scripts/ledger-cycle-native-http-router.php')],{cwd:process.cwd(),env:{...process.env,LEDGER_QA_SETTINGS:settings},windowsHide:true,detached:true,stdio:['ignore',log,log]});child.unref();fs.closeSync(log);const url='http://127.0.0.1:'+port+'/qa/ledger-demo';let ready=false;for(let n=0;n<50&&!ready;n++){try{const r=await fetch(url,{signal:AbortSignal.timeout(2000)});ready=r.ok&&(await r.text()).includes('data-ledger-demo="'+tag+'"');}catch{}if(!ready)await new Promise(r=>setTimeout(r,100));}if(!ready){child.kill();throw Error('Own native demo failed');}
|
||||
const result={tag,pid:child.pid,url,loopback_only:true,cookie:'sgc_ledger_'+tag,new_isolated_team_fixture:true,independent_financial_book:true,existing_demos_untouched:true};fs.writeFileSync(record,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result));
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
$argv=[__FILE__,getenv('LEDGER_QA_SETTINGS'),'native-cycle-http'];require __DIR__.'/payable-history-sgf-proof.php';
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
use App\Models\User;
|
||||
use App\Services\{GeneralLedger,SupplierPayables,InvoiceWorkflow};
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\GeneralLedger\{LedgerSchema,CycleSchema,PayableJournal};
|
||||
use Tempo\Finance\Payables\{Payables,SettlementSchema,ReversalSchema};
|
||||
$ledgerPackage=$bundle['ledger']??dirname(__DIR__).'/packages/general-ledger';
|
||||
config(['general-ledger.synthetic_enabled'=>true,'general-ledger.cycle_enabled'=>true,'general-ledger.path'=>$ledgerPackage]);require_once $ledgerPackage.'/bootstrap.php';
|
||||
if($phase==='native-cycle-setup'){
|
||||
if(DB::getSchemaBuilder()->hasTable('users'))throw new LogicException('Fresh native database required');
|
||||
Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);Illuminate\Support\Facades\Date::setTestNow('2026-10-29 12:00:00');require $c['root'].'/tests/Support/InvoiceFixture.php';$nativeFixture=invoiceFixture();
|
||||
auth()->setUser($nativeFixture['validator']);app(InvoiceWorkflow::class)->validate($nativeFixture['validator'],$nativeFixture['invoice']);
|
||||
$app->instance('env','payable-testing');
|
||||
$scope=['tenant_id'=>(int)$nativeFixture['author']->tenant_id,'company_id'=>0,'unit_id'=>0];
|
||||
$reader=User::factory()->create(['tenant_id'=>$scope['tenant_id'],'purchasing_role'=>App\Enums\PurchasingRole::Finance]);
|
||||
$f=['scope'=>$scope,'actors'=>['prepare'=>$nativeFixture['author']->id,'validate'=>$nativeFixture['validator']->id,'read'=>$reader->id]];
|
||||
foreach(['prepare'=>['view','prepare','payments_view','payment_prepare','allocation_prepare','reversal_prepare'],'validate'=>['view','validate','payments_view','payment_confirm','allocation_confirm','reversal_confirm'],'read'=>['view','payments_view']] as $role=>$actions)foreach($actions as $a)DB::table('pa_access')->insert($scope+['user_id'=>$f['actors'][$role],'action'=>$a,'person'=>'native-cycle-person-'.$f['actors'][$role]]);
|
||||
foreach(['prepare'=>['view','prepare','post','correct_prepare','correct_post','period_prepare','period_close'],'validate'=>['view','validate','correct_validate','period_validate'],'read'=>['view']] as $role=>$actions)foreach($actions as $a)DB::table('pa_access')->insert($scope+['user_id'=>$f['actors'][$role],'action'=>'general_ledger.'.$a,'person'=>'native-cycle-person-'.$f['actors'][$role]]);
|
||||
LedgerSchema::install(DB::connection());CycleSchema::install(DB::connection());SettlementSchema::install();ReversalSchema::install();
|
||||
$accounts=[];foreach(['QA-CONTRAPARTIDA','QA-FORNECEDOR','QA-LIQUIDACAO'] as $code)$accounts[$code]=DB::table('gl_accounts')->insertGetId($scope+['code'=>$code,'label'=>'Artificial QA '.$code,'is_movement'=>true,'currency'=>'MZN']);
|
||||
$common=['created_by'=>$f['actors']['prepare'],'reason'=>'Explicit authorised synthetic cycle configuration.','created_at'=>now()];
|
||||
DB::table('gl_periods')->insert($scope+$common+['period_key'=>'QA-2026-10','version'=>1,'starts_on'=>'2026-10-01','ends_on'=>'2026-10-31','status'=>'open']);
|
||||
foreach(['QA-PAYABLE-JOURNAL'=>['supplier_payable.recognised','QA-CONTRAPARTIDA','QA-FORNECEDOR'],'QA-ALLOCATION'=>['allocation.confirmed','QA-FORNECEDOR','QA-LIQUIDACAO'],'QA-REVERSAL'=>['allocation.reversed','QA-LIQUIDACAO','QA-FORNECEDOR'],'QA-CORRECTION-REVERSE'=>['journal.reversal','QA-FORNECEDOR','QA-CONTRAPARTIDA'],'QA-CORRECTION-REPLACE'=>['journal.replacement','QA-CONTRAPARTIDA','QA-FORNECEDOR']] as $key=>[$type,$d,$cr])DB::table('gl_rules')->insert($scope+$common+['rule_key'=>$key,'version'=>1,'policy'=>PayableJournal::POLICY,'effect_type'=>$type,'currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts[$d],'credit_account_id'=>$accounts[$cr]]);
|
||||
$finance=app(SupplierPayables::class);auth()->setUser($nativeFixture['author']);$p=$finance->engine($nativeFixture['author'])->command('prepare',$nativeFixture['invoice']->id,'Explicit native synthetic payable capture.',(string)Str::uuid());auth()->setUser($nativeFixture['validator']);$o=$finance->engine($nativeFixture['validator'])->command('validate',$p['proposal_id'],'Independent native synthetic recognition.',(string)Str::uuid());
|
||||
$g=['cycle_enabled'=>true,'obligation_id'=>$o['obligation_id'],'local_source_id'=>$nativeFixture['invoice']->id,'body'=>['obligation_id'=>$o['obligation_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-PAYABLE-JOURNAL','reason'=>'Individual native synthetic journal preparation.'],'native_originals_sha256'=>fingerprint()];
|
||||
file_put_contents($s['root'].'/fixture.json',json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($s['root'].'/ledger-fixture.json',json_encode($g,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "New native book: actual invoice workflow60, one financial obligation, no ledger or payment effects.\n";exit;
|
||||
}
|
||||
$app->instance('env','payable-testing');$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$g=json_decode(file_get_contents($s['root'].'/ledger-fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$f['scope'];
|
||||
$enter=function($role)use($f){$u=User::findOrFail($f['actors'][$role]);auth()->setUser($u);return $u;};$ledger=app(GeneralLedger::class);$cycleFinance=app(SupplierPayables::class);
|
||||
$command=fn($role,$action,$body,$key=null,$fault=null)=>$ledger->engine($enter($role),$fault)->command($action,$body,$key??(string)Str::uuid());
|
||||
$financialFingerprint=function(){ $h=hash_init('sha256');foreach(['pa_sources','pa_proposals','pa_obligations','pa_commands','pa_events','pa_access','invoices','invoice_items','external_payments','budget_entries','budget_consumptions','stock_movements'] as $t){hash_update($h,$t);foreach(DB::table($t)->orderBy('id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));}return hash_final($h);};
|
||||
if(in_array($phase,['native-cycle-reports-seed','native-cycle-reports-cases'],true)){require __DIR__.'/ledger-reports-proof.php';exit;}
|
||||
if($phase==='native-cycle-reports-isolation'){require __DIR__.'/ledger-reports-isolation.php';exit;}
|
||||
if($phase==='native-cycle-http'){
|
||||
require __DIR__.'/ledger-reports-observer.php';
|
||||
auth()->forgetGuards();
|
||||
$app->bind(Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,static fn($container)=>new class($container,$container->make(Illuminate\Contracts\Encryption\Encrypter::class)) extends Illuminate\Foundation\Http\Middleware\PreventRequestForgery {protected $addHttpCookie=false;});
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/ledger-enter/{role}',function(Illuminate\Http\Request $r,string $role)use($f,$g){abort_unless(isset($f['actors'][$role]),404);auth()->login(User::findOrFail($f['actors'][$role]));$r->session()->regenerate();$r->session()->put('ledger_demo',true);if($r->query('open')==='cycle')return redirect('/general-ledger/cycle');return response()->json(['csrf'=>csrf_token(),'obligation_id'=>$g['obligation_id'],'source_id'=>$g['local_source_id']])->header('Cache-Control','no-store');});
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/ledger-demo',function()use($s,$g){$tag=htmlspecialchars($s['tag'],ENT_QUOTES,'UTF-8');return response('<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ciclo SGC QA</title><main style="font:18px system-ui;max-width:950px;margin:40px auto;padding:24px" data-ledger-demo="'.$tag.'"><h1>SGC autónomo — ciclo contabilístico QA</h1><p>Uma factura efectivamente conferida pelo workflow nativo, obrigação #'.$g['obligation_id'].' de60MZN e núcleo contabilístico comum. Livro independente dos ensaios SGF. Sem execução monetária ou ligação a SGF remoto.</p><p><a href="/qa/ledger-enter/prepare?open=cycle">Preparar / aplicar</a> · <a href="/qa/ledger-enter/validate?open=cycle">Rever</a> · <a href="/qa/ledger-enter/read?open=cycle">Consultar</a></p><ol><li>Reconhecimento contabilístico60 com revisão distinta.</li><li>Facto de pagamento20, confirmação, afectação20 e confirmação financeiras; saldo40.</li><li>Conferir/contabilizar a afectação. Reverter financeiramente, saldo60, e contabilizar a reversão.</li><li>Preparar, rever e aplicar par técnico de estorno60/substituição60.</li><li>Conferir e fechar o períodoQA-2026-10 com outra pessoa.</li></ol><p>Contas/regras artificiais, data29/10/2026; sem reset, reabertura, utilização real ou aceitação visual presumida.</p></main></html>')->header('Cache-Control','no-store');});
|
||||
$app->handleRequest(Illuminate\Http\Request::capture());exit;
|
||||
}
|
||||
if(in_array($phase,['native-cycle-cases','native-cycle-workflow'],true)){require __DIR__.'/ledger-cycle-cases.php';if(fingerprint()!==$g['native_originals_sha256'])throw new LogicException('Native original source/stock/budget changed');exit;}
|
||||
if($phase==='native-cycle-final'){require __DIR__.'/ledger-cycle-final.php';exit;}
|
||||
if($phase==='native-cycle-report-reconcile'){require __DIR__.'/ledger-reports-reconcile.php';exit;}
|
||||
if($phase==='native-cycle-cost-refusal'){
|
||||
$output=$s['root'].'/cost-capability-refusal.json';if(file_exists($output))throw new LogicException('Completed refusal preserved');$before=DB::table('gl_proposals')->count();$caught=false;
|
||||
try{$command('prepare','effect-prepare',['effect_type'=>'workshop.direct_cost.validated','source_id'=>1,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-DIRECT-COST','reason'=>'Uncomposed workshop origin must refuse.']);}catch(DomainException $e){$caught=str_contains($e->getMessage(),'não composta');}
|
||||
if(!$caught||DB::table('gl_proposals')->count()!==$before||DB::getSchemaBuilder()->hasTable('gl_cost_sources'))throw new LogicException('Native must not require or fabricate workshop source');file_put_contents($output,json_encode(['passed'=>true,'uncomposed_cost_refused'=>true,'cost_schema_not_required'=>true,'no_proposal_or_effect_created'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "SGC explicitly refuses uncomposed cost, without schema or effects.\n";exit;
|
||||
}
|
||||
if(in_array($phase,['native-cycle-reports-old-package','native-cycle-reports-old-package-postcheck'],true)){
|
||||
$output=$s['root'].'/'.(str_ends_with($phase,'postcheck')?'ledger-reports-old-package-postcheck.json':'ledger-reports-old-package.json');if(file_exists($output)||($s['common_bundle_sha256']??null)!=='6a507ea7d708a12a8e526e4c610696d8db84079d18aede470237b65505630e7e')throw new LogicException('Explicit frozen previous package required');$count=DB::table('gl_journals')->count();$caught=false;
|
||||
try{$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=$e->getStatusCode()===503;}
|
||||
if(!$caught||DB::table('gl_journals')->count()!==$count)throw new LogicException('Missing packaged report capability must refuse without writes');file_put_contents($output,json_encode(['passed'=>true,'frozen_previous_package'=>true,'missing_capability_status'=>503,'journals_unchanged'=>true,'no_package_replacement'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Previous frozen package explicitly refuses unavailable report with503, no replacement or business write.\n";exit;
|
||||
}
|
||||
if($phase==='native-cycle-reports-package'){
|
||||
$output=$s['root'].'/ledger-reports-package.json';if(file_exists($output)||!isset($s['common_bundle'])||DB::table('gl_proposals')->exists())throw new LogicException('Fresh pinned packaged report proof required');
|
||||
$before=$financialFingerprint();$p=$command('prepare','prepare',$g['body']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent packaged source review.']);$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply one packaged report source.']);
|
||||
$data=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);$code=(new ReflectionClass(Tempo\Finance\GeneralLedger\PayableJournal::class))->getFileName();$ui=(new ReflectionClass(Tempo\Finance\GeneralLedger\ReportsWorkspace::class))->getFileName();
|
||||
if(!str_starts_with(realpath($code),realpath($s['common_bundle']).DIRECTORY_SEPARATOR)||!str_starts_with(realpath($ui),realpath($s['common_bundle']).DIRECTORY_SEPARATOR)||count($data['rows'])!==2||$data['totals']['debit_minor']!==6000||$financialFingerprint()!==$before||DB::getSchemaBuilder()->hasTable('gl_sales_capability'))throw new LogicException('Packaged report parity or isolation failed');
|
||||
$html=Tempo\Finance\GeneralLedger\ReportsWorkspace::render($data,['host'=>'SGC packaged QA','return_url'=>'/general-ledger/cycle']);if(!str_contains($html,'QA-FORNECEDOR'))throw new LogicException('Packaged report view missing');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>6,'pinned_manifest_sha256'=>$s['common_bundle_sha256'],'runtime_from_package'=>true,'ui_from_package'=>true,'journals'=>1,'lines'=>2,'debit_minor'=>6000,'sales_schema_not_installed'=>true,'financial_authority_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Pinned native report package: six checks, source and UI loaded from artifact, no Sales composition or extra financial effect.\n";exit;
|
||||
}
|
||||
if($phase==='native-cycle-sales-refusal'){
|
||||
$output=$s['root'].'/sales-capability-refusal.json';if(file_exists($output))throw new LogicException('Completed capability refusal preserved');$caught=false;
|
||||
try{$command('prepare','effect-prepare',['effect_type'=>'sales.invoice','source_id'=>1,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-SALES','reason'=>'Standalone host must not gain Sales by package update.']);}catch(DomainException $e){$caught=str_contains($e->getMessage(),'não composta');}
|
||||
if(!$caught||DB::table('gl_journals')->exists()||DB::table('gl_proposals')->exists()||DB::getSchemaBuilder()->hasTable('si_invoices'))throw new LogicException('Native Sales source must remain unavailable with no effect');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'sales_not_composed'=>true,'no_journal_or_proposal'=>true,'no_sgf_sales_table_dependency'=>true],JSON_PRETTY_PRINT));echo "Native composition refuses Sales explicitly without journal or SGF Sales dependency.\n";exit;
|
||||
}
|
||||
if($phase==='native-cycle-bundle'){
|
||||
$output=$s['root'].'/finance-bundle-proof.json';if(file_exists($output)||!isset($s['common_bundle']))throw new LogicException('Fresh artifact proof required');$root=realpath($s['common_bundle']);$checks=0;
|
||||
foreach([Payables::class,PayableJournal::class,Tempo\Finance\GeneralLedger\LedgerCycle::class,Tempo\Finance\GeneralLedger\CycleWorkspace::class,Tempo\Finance\Composition\Bundle::class] as $class){$path=realpath((new ReflectionClass($class))->getFileName());if(!str_starts_with($path,$root.DIRECTORY_SEPARATOR))throw new LogicException('Runtime escaped standalone artifact');$checks++;}
|
||||
$before=fingerprint();$p=$command('prepare','prepare',$g['body']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent recognition using verified local artifact.']);$body=['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply standalone package recognition once.'];$key=(string)Str::uuid();$j=$command('prepare','post',$body,$key);$again=$command('prepare','post',$body,$key);
|
||||
if(!$again['replayed']||$j['journal_id']!==$again['journal_id']||DB::table('gl_journals')->count()!==1||DB::table('gl_lines')->count()!==2||fingerprint()!==$before)throw new LogicException('Standalone recognition/replay/source preservation differs');$checks+=3;
|
||||
$data=$ledger->workspace($enter('read'));if($data['book']['totals']['debit_minor']!==6000||$data['book']['totals']['credit_minor']!==6000)throw new LogicException('Standalone workspace totals differ');$checks++;
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'runtime_classes_from_artifact'=>5,'one_journal_two_lines'=>true,'source_stock_budget_preserved'=>true,'manifest_sha256'=>$s['common_bundle_sha256'],'no_sgf_core_source_loaded'=>true,'no_remote_sgf'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Verified standalone artifact: $checks checks, one recognition and replay without source changes.\n";exit;
|
||||
}
|
||||
if($phase==='native-cycle-actor-check'){
|
||||
$output=$s['root'].'/ledger-cycle-actor-check.json';if(file_exists($output))throw new LogicException('Completed actor proof preserved');$before=$financialFingerprint();$checks=0;$controller=app(App\Http\Controllers\GeneralLedgerController::class);
|
||||
foreach(['page','index','command','payment-link'] as $method){$r=Illuminate\Http\Request::create('/general-ledger/cycle','GET');$r->setUserResolver(fn()=>new App\Models\CandidateAccount);$caught=false;try{
|
||||
if($method==='command')$controller->command($r,'prepare',$ledger);elseif($method==='payment-link')(new ReflectionMethod(App\Http\Controllers\SupplierPaymentsController::class,'ledgerSettings'))->invoke(app(App\Http\Controllers\SupplierPaymentsController::class),$r);else $controller->$method($r,$ledger);
|
||||
}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=$e->getStatusCode()===403;}if(!$caught)throw new LogicException('Candidate account must be refused before ledger calls');$checks++;}
|
||||
$reader=$enter('read');$r=Illuminate\Http\Request::create('/api/general-ledger/cycle','GET');$r->setUserResolver(fn()=>$reader);$response=$controller->index($r,$ledger);if($response->getStatusCode()!==200||count($response->getData(true)['book']['journals'])!==5||$financialFingerprint()!==$before)throw new LogicException('Typed controller read or preservation differs');$checks++;
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'candidate_account_refused'=>true,'typed_internal_user_read'=>true,'financial_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Internal/candidate controller boundary: $checks checks.\n";exit;
|
||||
}
|
||||
throw new LogicException('Unknown native cycle phase');
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');if(state.port!==55439)throw Error('Owned cluster required');
|
||||
const packaged=process.argv[2]==='new-bundle',resume=['new','new-bundle'].includes(process.argv[2])?undefined:process.argv[2];if(resume&&!/^[a-f0-9]{12}$/.test(resume))throw Error('Invalid tag');
|
||||
const tag=resume??crypto.randomBytes(6).toString('hex'),root=path.join(state.root,'private/ledger-native-'+tag),settings=path.join(root,'settings.json');
|
||||
if(!resume){
|
||||
const admin=fs.readFileSync(path.join(state.root,'private/admin-password.txt'),'utf8').trim(),pg=input=>{const r=spawnSync('C:/Program Files/PostgreSQL/17/bin/psql.exe',['-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-Atq','-v','ON_ERROR_STOP=1'],{input,encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:admin}});if(r.status!==0)throw Error('Native cycle provisioning failed');return r.stdout.trim();};if(pg("SELECT current_setting('cluster_name')")!=='sgf_modular_regression_'+state.id)throw Error('Wrong actual cluster');
|
||||
const role='payable_history_native_'+tag,database='compras_receipts_test_'+tag,password=crypto.randomBytes(32).toString('hex');pg(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; CREATE DATABASE ${database} OWNER ${role}; REVOKE ALL ON DATABASE ${database} FROM PUBLIC;`);
|
||||
const storage=path.join(root,'storage');for(const d of ['framework/cache/data','framework/views','framework/sessions','logs','cache'])fs.mkdirSync(path.join(storage,d),{recursive:true});
|
||||
const artifact=packaged?read(path.join(state.root,'finance-bundle-latest.json')):null;
|
||||
fs.writeFileSync(settings,JSON.stringify({tag,root,cluster:'sgf_modular_regression_'+state.id,source_instance:'qa-native-ledger-'+tag,app_key:'base64:'+crypto.randomBytes(32).toString('base64'),...(artifact?{common_bundle:artifact.root,common_bundle_sha256:artifact.manifest_sha256}:{}),native:{root:path.resolve('../compras'),database,role,password,storage}},null,2));
|
||||
}
|
||||
const phases=process.argv[3]?.split(',')??(resume?[]:['native-cycle-setup']);for(const phase of phases){if(!['native-cycle-setup','native-cycle-cases','native-cycle-workflow'].includes(phase))throw Error('Phase refused');const output=path.join(root,phase==='native-cycle-setup'?'ledger-fixture.json':phase+'.json');if(fs.existsSync(output))throw Error('Completed proof preserved');const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/payable-history-sgf-proof.php',settings,phase],{encoding:'utf8',windowsHide:true,maxBuffer:1024*1024});fs.writeFileSync(path.join(root,phase+'-'+Date.now()+'.log'),(r.stdout??'')+(r.stderr??''));if(r.status!==0||!fs.existsSync(output))throw Error('Native phase failed; inspect private diagnostics: '+root);process.stdout.write(r.stdout);}
|
||||
console.log(JSON.stringify({tag,root,native_only:true}));
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
use Illuminate\Support\Str;
|
||||
if($phase==='ledger-cycle-worker'){
|
||||
$input=realpath($argv[3]??'');if(!$input||!str_starts_with($input,realpath($s['root']).DIRECTORY_SEPARATOR))throw new LogicException('Private worker input required');$w=json_decode(file_get_contents($input),true,64,JSON_THROW_ON_ERROR);
|
||||
if(!preg_match('/^cycle_(correct|close|stale)_[ab]_[a-f0-9]{12}$/D',$w['label'])||!in_array($w['action'],['correct-post','period-close'],true))throw new LogicException('Worker request refused');
|
||||
DB::select('SELECT set_config(?,?,false)',['application_name',$w['label']]);DB::statement("SET lock_timeout='30s'");
|
||||
try{$r=$command('prepare',$w['action'],$w['body'],$w['key']);$result=['status'=>200,'result'=>$r];}catch(DomainException $e){$result=['status'=>409,'message'=>$e->getMessage()];}
|
||||
file_put_contents($input.'.result',json_encode($result,JSON_THROW_ON_ERROR));exit;
|
||||
}
|
||||
$output=$s['root'].'/ledger-cycle-race.json';if(file_exists($output))throw new LogicException('Completed race preserved');$prepared=json_decode(file_get_contents($s['root'].'/ledger-cycle-race-prepared.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
$race=function(string $name,string $action,array $body,int $number=2,?Closure $change=null)use($s,$file){
|
||||
$workers=[];$inputs=[];$observed=[];DB::beginTransaction();
|
||||
try{
|
||||
$parent=(int)DB::selectOne('SELECT pg_backend_pid() pid')->pid;DB::table('companies')->where('id',1)->lockForUpdate()->firstOrFail();if($change)$change();
|
||||
foreach(array_slice(['a','b'],0,$number) as $n){$input=$s['root'].'/cycle-'.$name.'-'.$n.'-'.bin2hex(random_bytes(4)).'.json';file_put_contents($input,json_encode(['label'=>'cycle_'.$name.'_'.$n.'_'.$s['tag'],'action'=>$action,'body'=>$body,'key'=>(string)Str::uuid()]));$log=$input.'.log';$p=proc_open([PHP_BINARY,__DIR__.'/payable-history-sgf-proof.php',$file,'ledger-cycle-worker',$input],[0=>['file','NUL','r'],1=>['file',$log,'a'],2=>['file',$log,'a']],$pipes,dirname(__DIR__),null,['bypass_shell'=>true]);if(!is_resource($p))throw new LogicException('Worker launch failed');$workers[$n]=$p;$inputs[$n]=$input;}
|
||||
config(['database.connections.cycle_monitor'=>DB::connection()->getConfig()]);$monitor=DB::connection('cycle_monitor');$blocked=[];$until=microtime(true)+15;
|
||||
do{
|
||||
$monitor->select('SELECT pg_stat_clear_snapshot()');foreach($inputs as $n=>$input){$r=$monitor->selectOne('SELECT pid,wait_event_type,wait_event,query,pg_blocking_pids(pid)::text blockers FROM pg_stat_activity WHERE datname=current_database() AND application_name=?',['cycle_'.$name.'_'.$n.'_'.$s['tag']]);if($r)$observed[$n]=['pid'=>(int)$r->pid,'wait'=>$r->wait_event_type,'event'=>$r->wait_event,'company'=>str_contains($r->query,'"companies"')&&str_contains(strtolower($r->query),'for update'),'blockers'=>json_decode(str_replace(['{','}'],['[',']'],$r->blockers))];}
|
||||
foreach($observed as $n=>$r){$reach=in_array($parent,$r['blockers'],true);foreach($observed as $other)if(in_array($other['pid'],$r['blockers'],true)&&in_array($parent,$other['blockers'],true))$reach=true;if($r['wait']==='Lock'&&$r['company']&&$reach)$blocked[$n]=['chain_reaches_parent'=>true,'event'=>$r['event']];}
|
||||
if(count($blocked)===$number)break;usleep(20000);
|
||||
}while(microtime(true)<$until);
|
||||
if(count($blocked)!==$number)throw new LogicException('Actual company lock waits required');
|
||||
$change?DB::commit():DB::rollBack();$results=[];foreach($inputs as $n=>$input){$until=microtime(true)+20;while(!file_exists($input.'.result')&µtime(true)<$until)usleep(20000);if(!file_exists($input.'.result'))throw new LogicException('Worker did not complete after release');$results[$n]=json_decode(file_get_contents($input.'.result'),true,64,JSON_THROW_ON_ERROR);}
|
||||
return ['blocked'=>$blocked,'results'=>$results];
|
||||
}finally{while(DB::transactionLevel()>0)DB::rollBack();foreach($workers as $p){if(proc_get_status($p)['running'])proc_terminate($p);proc_close($p);}DB::purge('cycle_monitor');}
|
||||
};
|
||||
$correct=$race('correct','correct-post',$prepared['correction_body']);
|
||||
if($correct['results']['a']['status']!==200||$correct['results']['b']['status']!==200||$correct['results']['a']['result']['result_id']!==$correct['results']['b']['result']['result_id']||DB::table('gl_correction_results')->count()!==1||DB::table('gl_journals')->count()!==5)throw new LogicException('Concurrent corrective pair duplicated');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Capture before concurrent evidence change.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent pre-change close review.']);
|
||||
$stale=$race('stale','period-close',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Must revalidate after writer commits changed rule.'],1,function(){ $r=(array)DB::table('gl_rules')->where('rule_key','QA-ALLOCATION')->first();unset($r['id']);$r['version']++;$r['reason']='Explicit synthetic concurrent rule version.';DB::table('gl_rules')->insert($r); });
|
||||
if($stale['results']['a']['status']!==409||DB::table('gl_period_closures')->exists())throw new LogicException('Changed evidence must refuse closing after lock release');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Fresh conference after actual concurrent change.']);$command('validate','period-validate',['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent review of updated period evidence.']);
|
||||
$closeBody=['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Apply updated artificial period close once.'];$closed=$race('close','period-close',$closeBody);
|
||||
if($closed['results']['a']['status']!==200||$closed['results']['b']['status']!==200||$closed['results']['a']['result']['closure_id']!==$closed['results']['b']['result']['closure_id']||DB::table('gl_period_closures')->count()!==1||DB::table('gl_periods')->count()!==2)throw new LogicException('Concurrent close duplicated version');
|
||||
DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'corrective_pair'=>$correct,'stale_after_committed_change'=>$stale,'period_close'=>$closed,'journals'=>5,'lines'=>10,'correction_results'=>1,'closures'=>1,'period_versions'=>2,'old_check_id'=>$stale['results']['a']['status'],'final_check_id'=>$p['check_id'],'final_revision'=>$p['revision']],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Five real workers: corrective pair once, stale close409 after committed change, fresh concurrent close once.\n";
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\GeneralLedger\CycleSchema;
|
||||
$output=$s['root'].'/ledger-cycle-security.json';if(file_exists($output))throw new LogicException('Completed security proof preserved');$checks=0;
|
||||
$ok=function($v,$why)use(&$checks){$checks++;if(!$v)throw new LogicException($why);};
|
||||
$refuse=function(Closure $fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException){$caught=true;}$ok($caught,'Independent current authority required');};
|
||||
$before=$financialFingerprint();DB::beginTransaction();try{
|
||||
$p=$command('prepare','prepare',$g['body']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent recognition for security probes.']);$j=$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Preserved original within rollback security scenario.']);
|
||||
$c=$command('prepare','correct-prepare',['journal_id'=>$j['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Explicit correction security snapshot.']);$review=['correction_id'=>$c['correction_id'],'revision'=>$c['revision'],'decision'=>'approved','reason'=>'Independent exact correction security decision.'];
|
||||
DB::beginTransaction();try{DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.correct_validate','created_at'=>now(),'updated_at'=>now()]);$refuse(fn()=>$command('prepare','correct-validate',$review));}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>'sgf-history-person-'.$f['actors']['prepare']]);$refuse(fn()=>$command('validate','correct-validate',$review));}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['validate'],'permission'=>'general_ledger.correct_validate'])->delete();$refuse(fn()=>$command('validate','correct-validate',$review));}finally{DB::rollBack();}
|
||||
$group=DB::table('gl_corrections')->where('id',$c['correction_id'])->first();$first=DB::table('gl_proposals')->where('id',$group->reverse_proposal_id)->first();
|
||||
DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['validate'],'permission'=>'general_ledger.correct_validate'])->delete();$refuse(fn()=>$command('validate','validate',['proposal_id'=>$first->id,'revision'=>$first->revision,'decision'=>'approved','reason'=>'Individual route cannot bypass corrective right.']));}finally{DB::rollBack();}
|
||||
$command('validate','correct-validate',$review);
|
||||
DB::beginTransaction();try{
|
||||
(new ReflectionMethod(Tempo\Finance\GeneralLedger\PayableJournal::class,'post'))->invoke($ledger->engine($enter('prepare')),['proposal_id'=>$first->id,'revision'=>$first->revision],'Orphan reversal must never commit.');
|
||||
$caught=false;try{DB::statement('SET CONSTRAINTS gl_cycle_pair IMMEDIATE');}catch(Illuminate\Database\QueryException $e){$caught=str_contains($e->getMessage(),'GL_CORRECTION_PAIR_REQUIRED');}$ok($caught,'Database refuses standalone corrective journal at transaction boundary');
|
||||
}finally{DB::rollBack();}
|
||||
$ok(DB::table('gl_journals')->count()===1,'Orphan journal rolled back');
|
||||
$body=['correction_id'=>$c['correction_id'],'revision'=>$c['revision'],'reason'=>'Apply atomic pair for rollback-only integrity checks.'];
|
||||
DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.correct_post'])->delete();$refuse(fn()=>$command('prepare','correct-post',$body));}finally{DB::rollBack();}
|
||||
$key=(string)Str::uuid();try{$command('prepare','correct-post',$body,$key,function($at){if($at==='after_receipt')throw new RuntimeException('QA post-receipt fault');});throw new LogicException('Fault required');}catch(RuntimeException $e){$ok($e->getMessage()==='QA post-receipt fault','Recovery fault reached receipt boundary');}
|
||||
$ok(DB::table('gl_journals')->count()===1&&!DB::table('gl_correction_results')->exists(),'Receipt failure rolls back pair, result and audit');
|
||||
$result=$command('prepare','correct-post',$body,$key);$ok($result['state']==='corrected'&&DB::table('gl_journals')->count()===3,'Same-key recovery succeeds once after rollback');
|
||||
$p=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Prepare complete period for security probes.']);$r=['check_id'=>$p['check_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent rollback-only period review.'];
|
||||
DB::beginTransaction();try{DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.period_validate','created_at'=>now(),'updated_at'=>now()]);$refuse(fn()=>$command('prepare','period-validate',$r));}finally{DB::rollBack();}
|
||||
$command('validate','period-validate',$r);$close=['check_id'=>$p['check_id'],'revision'=>$p['revision'],'reason'=>'Close rollback-only artificial period.'];
|
||||
DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.period_close'])->delete();$refuse(fn()=>$command('prepare','period-close',$close));}finally{DB::rollBack();}
|
||||
$command('prepare','period-close',$close);
|
||||
foreach(CycleSchema::TABLES as $table){DB::beginTransaction();try{$caught=false;try{DB::table($table)->where('id',DB::table($table)->value('id'))->update(['reason'=>'Forbidden mutation']);}catch(Illuminate\Database\QueryException $e){$caught=str_contains($e->getMessage(),'GL_APPEND_ONLY');}$ok($caught,'Append-only cycle table '.$table);}finally{DB::rollBack();}}
|
||||
}finally{while(DB::transactionLevel()>0)DB::rollBack();}
|
||||
$ok($financialFingerprint()===$before&&!DB::table('gl_journals')->exists(),'All security probes rolled back without resets');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'self_and_same_person_refused'=>true,'revoked_corrective_and_close_rights_refused'=>true,'individual_route_cannot_bypass_rights'=>true,'orphan_pair_refused_by_database'=>true,'post_receipt_failure_recovered'=>true,'five_new_tables_append_only'=>true,'rollback_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cycle security: $checks checks; all probes reverted.\n";
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use Tempo\Finance\GeneralLedger\CycleSchema;
|
||||
use Tempo\Finance\Payables\{SettlementSchema,ReversalSchema};
|
||||
CycleSchema::install(DB::connection());SettlementSchema::install();ReversalSchema::install();
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((unit_id IS NULL AND permission='company.view') OR (unit_id IS NOT NULL AND permission IN ('unit.view','supplier_payables.view','general_ledger.view','general_ledger.prepare','general_ledger.validate','general_ledger.post','general_ledger.correct_prepare','general_ledger.correct_validate','general_ledger.correct_post','general_ledger.period_prepare','general_ledger.period_validate','general_ledger.period_close')))");
|
||||
foreach(['prepare'=>['correct_prepare','correct_post','period_prepare','period_close'],'validate'=>['correct_validate','period_validate']] as $role=>$actions)foreach($actions as $action)DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors'][$role],'permission'=>'general_ledger.'.$action,'created_at'=>now(),'updated_at'=>now()]);
|
||||
foreach(['prepare'=>['payments_view','payment_prepare','allocation_prepare','reversal_prepare'],'validate'=>['payments_view','payment_confirm','allocation_confirm','reversal_confirm'],'read'=>['payments_view']] as $role=>$actions)foreach($actions as $action)DB::table('pa_access')->insert($scope+['user_id'=>$f['actors'][$role],'action'=>$action,'person'=>'sgf-history-person-'.$f['actors'][$role]]);
|
||||
$cash=DB::table('gl_accounts')->insertGetId($scope+['code'=>'QA-LIQUIDACAO','label'=>'Contrapartida artificial de liquidação; sem execução monetária','is_movement'=>true,'currency'=>'MZN']);
|
||||
foreach(['QA-ALLOCATION'=>['allocation.confirmed',$credit,$cash],'QA-REVERSAL'=>['allocation.reversed',$cash,$credit],'QA-CORRECTION-REVERSE'=>['journal.reversal',$credit,$debit],'QA-CORRECTION-REPLACE'=>['journal.replacement',$debit,$credit]] as $key=>[$type,$d,$cId])DB::table('gl_rules')->insert($scope+$common+['rule_key'=>$key,'version'=>1,'policy'=>Tempo\Finance\GeneralLedger\PayableJournal::POLICY,'effect_type'=>$type,'currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$d,'credit_account_id'=>$cId]);
|
||||
@@ -0,0 +1,12 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned QA tag required');
|
||||
const root=path.join(state.root,'private/ledger-'+tag),demo=read(path.join(root,'ledger-demo.json')),output=path.join(root,'ledger-demo-http.json');if(fs.existsSync(output))throw Error('Completed demo proof preserved');let checks=0;const check=(v,s)=>{checks++;assert(v,s);};
|
||||
const base=new URL(demo.url).origin,landing=await fetch(demo.url);check(landing.status===200&&(await landing.text()).includes('data-ledger-demo="'+tag+'"'),'Persistent dedicated loopback demo ready');
|
||||
for(const role of ['prepare','validate','read']){
|
||||
const jar=new Map();let auth;
|
||||
const request=async url=>{const r=await fetch(base+url,{headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??''},redirect:'manual'});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],at=p.indexOf('=');jar.set(p.slice(0,at),p.slice(at+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data};};
|
||||
const entry=await request('/qa/ledger-enter/'+role);check(entry.status===200,'Team identity '+role);auth=entry.data;check(jar.has(demo.cookie),'Dedicated cookie does not replace existing demo cookies');
|
||||
const api=await request('/api/general-ledger');check(api.status===200&&api.data.journals.length===0&&api.data.proposals.length===0,'Pristine readable workflow '+role);
|
||||
const html=await request('/general-ledger');check(html.status===200&&html.text.includes('Trocar função no ensaio sintético')&&(role==='prepare'?html.text.includes('data-action="prepare"'):!html.text.includes('data-action="prepare"')),'Role-specific preparation interface '+role);
|
||||
}
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,checks,url:demo.url,loopback_only:true,cookie_isolated:true,no_business_commands_executed:true,role_interfaces_verified:true,visual_review:false,browser_interactions_tested:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,url:demo.url,pristine:true}));
|
||||
@@ -0,0 +1,10 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned QA tag required');
|
||||
const root=path.join(state.root,'private/ledger-'+tag),demo=read(path.join(root,'ledger-demo.json')),base=new URL(demo.url).origin,output=path.join(root,'ledger-demo-cookie.json');if(fs.existsSync(output))throw Error('Completed cookie proof preserved');
|
||||
const jar=new Map(),r=await fetch(base+'/qa/ledger-enter/read',{headers:{Accept:'application/json'}}),auth=await r.json();
|
||||
for(const cookie of r.headers.getSetCookie()){const p=cookie.split(';')[0],at=p.indexOf('=');jar.set(p.slice(0,at),p.slice(at+1));}
|
||||
assert(jar.has(demo.cookie)&&!jar.has('XSRF-TOKEN'));
|
||||
const body={proposal_id:1,revision:'0'.repeat(64),reason:'Cookie isolation must retain CSRF validation.'};
|
||||
const send=async token=>fetch(base+'/api/general-ledger/post',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth.context,'X-CSRF-TOKEN':token,'Idempotency-Key':'00000000-0000-4000-8000-000000000001'},body:JSON.stringify(body)});
|
||||
assert.equal((await send('')).status,419);assert.equal((await send(auth.csrf)).status,403);
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,own_session_cookie:true,shared_xsrf_cookie_not_replaced:true,csrf_validation_retained:true,unauthorised_action_refused:true,no_business_effect:true},null,2)+'\n');console.log('Demo cookie isolation, real CSRF and read-only role refusal passed.');
|
||||
@@ -0,0 +1,11 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import {spawn} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned QA tag required');const root=path.join(state.root,'private/ledger-'+tag),settings=path.join(root,'settings.json'),owned=read(settings);if(owned.tag!==tag||owned.cluster!=='sgf_modular_regression_'+state.id)throw Error('Owned settings mismatch');
|
||||
const record=path.join(root,'ledger-demo.json');
|
||||
if(fs.existsSync(record)){const old=read(record);try{const r=await fetch(old.url,{signal:AbortSignal.timeout(2000)});if(r.ok&&(await r.text()).includes('data-ledger-demo="'+tag+'"')){console.log(JSON.stringify({...old,already_running:true}));process.exit(0);}}catch{}throw Error('Saved demo is not running; preserve it and inspect before resuming');}
|
||||
let port;for(let p=8048;p<=8057&&!port;p++){try{await new Promise((resolve,reject)=>{const probe=net.createServer();probe.once('error',reject);probe.listen(p,'127.0.0.1',()=>probe.close(resolve));});port=p;}catch{}}
|
||||
if(!port)throw Error('No free dedicated loopback port');
|
||||
const log=fs.openSync(path.join(root,'ledger-demo-server.log'),'a'),child=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:'+port,path.resolve('scripts/ledger-http-router.php')],{cwd:process.cwd(),env:{...process.env,LEDGER_QA_SETTINGS:settings},windowsHide:true,detached:true,stdio:['ignore',log,log]});child.unref();fs.closeSync(log);
|
||||
const url='http://127.0.0.1:'+port+'/qa/ledger-demo';let ready=false;for(let n=0;n<50&&!ready;n++){try{const r=await fetch(url,{signal:AbortSignal.timeout(2000)});ready=r.ok&&(await r.text()).includes('data-ledger-demo="'+tag+'"');}catch{}if(!ready)await new Promise(r=>setTimeout(r,100));}
|
||||
if(!ready){try{child.kill();}catch{}throw Error('Own demo did not become ready');}
|
||||
const result={tag,pid:child.pid,url,loopback_only:true,cookie:'sgf_ledger_'+tag,new_isolated_team_fixture:true,existing_demos_untouched:true};fs.writeFileSync(record,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result));
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// Additional integration checks only. All mutation probes roll back in this owned QA database.
|
||||
$output=$s['root'].'/ledger-flow-cases.json';
|
||||
if(file_exists($output)||!($g['integrated_flow']??false))throw new LogicException('Dedicated unfinished flow proof required');
|
||||
$http=json_decode(file_get_contents($s['root'].'/ledger-flow-http.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
$checks=0;$check=function(bool $ok,string $why)use(&$checks){$checks++;if(!$ok)throw new LogicException($why);};
|
||||
$reader=$enter('read');$before=$financialFingerprint();$queries=[];$mode=null;
|
||||
DB::listen(function($event)use(&$queries,&$mode){$queries[]=$event->sql;if(str_contains($event->sql,'"gl_journals"')){$pdo=$event->connection->getPdo();$mode=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
|
||||
$data=$ledger->flow($reader,$g['local_source_id']);
|
||||
$check($data['position']['id']===$http['obligation_id']&&$data['journal']->id===$http['journal_id'],'Same scoped financial and accounting origin');
|
||||
$check($mode===['isolation'=>'repeatable read','read_only'=>'on'],'Single coherent read-only snapshot');
|
||||
$check(!array_filter($queries,fn($q)=>str_contains(strtolower($q),'for update')),'No writer locks in flow read');
|
||||
$check(count($data['financial_proposals'])===1&&count($data['financial_events'])<=25,'Bounded source evidence');
|
||||
$reject=function(Closure $probe,int $expected=403)use($check){try{$probe();throw new LogicException('Expected access refusal');}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$check($e->getStatusCode()===$expected,'Explicit access refusal');}};
|
||||
foreach(['general_ledger.view','supplier_payables.view'] as $permission){
|
||||
DB::beginTransaction();try{
|
||||
DB::table('permission_grants')->where($scope)->where(['user_id'=>$reader->id,'permission'=>$permission])->delete();
|
||||
$reject(fn()=>$ledger->flow($reader,$g['local_source_id']));
|
||||
}finally{DB::rollBack();}
|
||||
}
|
||||
DB::beginTransaction();try{
|
||||
DB::table('pa_access')->where($scope)->where(['user_id'=>$reader->id,'action'=>'view'])->delete();
|
||||
$reject(fn()=>$ledger->flow($reader,$g['local_source_id']));
|
||||
}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{
|
||||
$row=(array)DB::table('pa_imported_sources')->where('id',$g['local_source_id'])->first();unset($row['id']);$row['unit_id']+=1000;$other=DB::table('pa_imported_sources')->insertGetId($row);
|
||||
$reject(fn()=>$ledger->flow($reader,$other));
|
||||
}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{
|
||||
$row=(array)DB::table('pa_imported_sources')->where('id',$g['local_source_id'])->first();unset($row['id']);$row['company_id']+=1000;$other=DB::table('pa_imported_sources')->insertGetId($row);
|
||||
$reject(fn()=>$ledger->flow($reader,$other));
|
||||
}finally{DB::rollBack();}
|
||||
DB::beginTransaction();try{
|
||||
$row=(array)DB::table('pa_imported_sources')->where('id',$g['local_source_id'])->first();unset($row['id']);$row['tenant_id']+=1000;$other=DB::table('pa_imported_sources')->insertGetId($row);
|
||||
$reject(fn()=>$ledger->flow($reader,$other));
|
||||
}finally{DB::rollBack();}
|
||||
$data=$ledger->listing($enter('read'),['obligation_id'=>$http['obligation_id']]);
|
||||
$check(count($data['events'])===3&&count($data['proposals'])===1&&count($data['journals'])===1,'Focus retains prepare, independent review and posting evidence');
|
||||
$check($data['totals']===['debit_minor'=>6000,'credit_minor'=>6000,'net_minor'=>0],'Focused accounting totals reconciled');
|
||||
$check($before===$financialFingerprint(),'All authorisation and foreign-scope probes rolled back');
|
||||
$check(DB::table('pa_obligations')->count()===1&&DB::table('gl_journals')->count()===1&&DB::table('gl_lines')->count()===2,'One debt, one journal, two lines after retries');
|
||||
$check(DB::table('gl_events')->where('kind','journal.posted')->count()===1,'One durable posting event');
|
||||
$check(DB::table('permission_grants')->where('permission','like','general_ledger.%')->count()===6,'Only explicitly authorised synthetic grants retained');
|
||||
$check(DB::table('gl_proposals')->value('prepared_by')!==DB::table('gl_reviews')->value('reviewed_by'),'Distinct accounting identities');
|
||||
foreach(DB::table('pa_imported_sources')->get() as $row)$check(hash_equals($row->receipt_hash,hash('sha256',Tempo\Finance\Payables\Payables::canonical(json_decode($row->payload,true)))),'Document receipt unchanged');
|
||||
$check((require $c['root'].'/config/general_ledger.php')['synthetic_enabled']===false,'Destination remains disabled');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'coherent_read'=>$mode,'flow_query_count'=>count($queries),'scope_refusals'=>['tenant','company','unit'],'revoked_rights_refused'=>true,'mutating_probes_rolled_back'=>true,'one_obligation_one_journal'=>true,'six_qa_grants'=>true,'default_enabled'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
echo "Integrated isolation, current permissions, source receipts, read snapshot and single effect verified: $checks checks.\n";
|
||||
@@ -0,0 +1,18 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned QA tag required');
|
||||
const root=path.join(state.root,'private/ledger-'+tag),demo=read(path.join(root,'ledger-demo.json')),g=read(path.join(root,'ledger-fixture.json')),f=read(path.join(root,'fixture.json')),output=path.join(root,'ledger-flow-demo-http.json');
|
||||
if(!g.integrated_flow||fs.existsSync(output))throw Error('Unfinished integrated team proof required');
|
||||
let checks=0;const check=(v,s)=>{checks++;assert(v,s);},base=new URL(demo.url).origin;
|
||||
let r=await fetch(demo.url);check(r.status===200&&(await r.text()).includes('data-ledger-demo="'+tag+'"'),'New integrated landing available');
|
||||
for(const role of ['prepare','validate','read']){
|
||||
const jar=new Map();let auth;
|
||||
const req=async(url,body)=>{const r=await fetch(base+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth.csrf}:{})},body:body?JSON.stringify(body):undefined,redirect:'manual'});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],at=p.indexOf('=');jar.set(p.slice(0,at),p.slice(at+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data,headers:r.headers};};
|
||||
r=await req('/qa/ledger-enter/'+role);check(r.status===200,'Dedicated '+role+' identity');auth=r.data;check(jar.has(demo.cookie)&&!jar.has('XSRF-TOKEN'),'Own cookie without changing shared CSRF cookie');
|
||||
r=await req('/general-ledger/flow?source='+g.local_source_id);check(r.status===200&&r.text.includes('Ainda não reconhecida')&&r.text.includes('Nenhum lançamento aplicado'),'Pristine complete chain for '+role);
|
||||
r=await req('/api/general-ledger');check(r.status===200&&r.data.journals.length===0&&r.data.proposals.length===0,'No accounting effects in team fixture');
|
||||
r=await req('/api/context',{...f.scope,module_id:'purchasing'});check(r.status===200,'Existing module selection works');auth={csrf:r.data.csrf,context:r.data.context.token};
|
||||
r=await req('/api/supplier-payables?source='+g.local_source_id);check(r.status===200&&r.data.obligations.length===0&&r.data.proposals.length===0,'Team starts before financial recognition');
|
||||
r=await req('/supplier-payables?source='+g.local_source_id);check(r.status===200&&r.text.includes('ledger_flow_url'),'Financial handoff reachable');
|
||||
}
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,checks,url:demo.url,tag,financial_obligations:0,financial_proposals:0,ledger_proposals:0,journals:0,roles:3,no_business_commands:true,visual_review:false,browser_automation:false,cookie_isolated:true},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,url:demo.url}));
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$output=$s['root'].'/ledger-flow-focus-cases.json';if(file_exists($output)||!($g['integrated_flow']??false))throw new LogicException('Own unfinished focus proof required');
|
||||
$http=json_decode(file_get_contents($s['root'].'/ledger-flow-http.json'),true,64,JSON_THROW_ON_ERROR);$before=$financialFingerprint();$checks=0;
|
||||
$check=function(bool $ok,string $why)use(&$checks){$checks++;if(!$ok)throw new LogicException($why);};
|
||||
DB::beginTransaction();try{
|
||||
$doc=app(SupplierPayables::class)->document($enter('prepare'),$g['local_source_id']);unset($doc['local_id']);$doc['document']=270002;$doc['number']='QA-SECOND-FOCUS-60';$payload=Payables::canonical($doc);
|
||||
$local=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>$payload,'receipt_hash'=>hash('sha256',$payload)]);
|
||||
$p=app(SupplierPayables::class)->engine($enter('prepare'))->command('prepare',$local,'Explicit second synthetic source for focus isolation.',(string)Str::uuid());
|
||||
$o=app(SupplierPayables::class)->engine($enter('validate'))->command('validate',$p['proposal_id'],'Independent second synthetic recognition.',(string)Str::uuid());
|
||||
$p=$command('prepare','prepare',array_replace($g['body'],['obligation_id'=>$o['obligation_id']]));
|
||||
$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent review of second focus source.']);
|
||||
$j=$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Second isolated journal for read focus only.']);
|
||||
$all=$ledger->listing($enter('read'));
|
||||
$check(count($all['journals'])===2&&$all['totals']['debit_minor']===12000&&count($all['events'])===6,'Unfiltered view includes both effects');
|
||||
foreach([$http['obligation_id']=>$http['journal_id'],$o['obligation_id']=>$j['journal_id']] as $obligation=>$journal){
|
||||
$data=$ledger->listing($enter('read'),['obligation_id'=>$obligation]);
|
||||
$check(count($data['journals'])===1&&$data['journals'][0]->id===$journal&&count($data['lines'])===2,'Only focused journal and lines');
|
||||
$check(count($data['proposals'])===1&&$data['proposals'][0]->obligation_id===$obligation&&count($data['reviews'])===1,'Only focused preparation and independent review');
|
||||
$check(count($data['events'])===3&&$data['totals']['debit_minor']===6000&&$data['totals']['credit_minor']===6000,'All three focused event kinds and scoped totals');
|
||||
$check(count($ledger->listing($enter('read'),['obligation_id'=>$obligation,'page'=>2])['journals'])===0,'Empty focused second page does not repeat first');
|
||||
}
|
||||
$flow=$ledger->flow($enter('read'),$g['local_source_id']);$check($flow['journal']->id===$http['journal_id'],'Original source flow excludes second journal');
|
||||
}finally{DB::rollBack();}
|
||||
$check($financialFingerprint()===$before&&DB::table('gl_journals')->count()===1&&DB::table('gl_events')->count()===3,'Second full effect rolled back without resetting original');
|
||||
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'two_independent_effects_tested'=>true,'all_evidence_kinds_scoped'=>true,'empty_page_tested'=>true,'second_effect_rolled_back'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Focused evidence isolated across two complete effects: $checks checks.\n";
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import {spawn} from 'node:child_process';import assert from 'node:assert/strict';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned QA tag required');
|
||||
const root=path.join(state.root,'private/ledger-'+tag),settings=path.join(root,'settings.json'),owned=read(settings),fixture=read(path.join(root,'fixture.json')),g=read(path.join(root,'ledger-fixture.json')),output=path.join(root,'ledger-flow-http.json');
|
||||
if(!g.integrated_flow||owned.tag!==tag||owned.cluster!=='sgf_modular_regression_'+state.id||fs.existsSync(output))throw Error('Fresh dedicated integrated proof required');
|
||||
await new Promise((resolve,reject)=>{const p=net.createServer();p.once('error',reject);p.listen(8047,'127.0.0.1',()=>p.close(resolve));});
|
||||
const log=fs.openSync(path.join(root,'ledger-flow-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-http-router.php')],{cwd:process.cwd(),env:{...process.env,LEDGER_QA_SETTINGS:settings},windowsHide:true,stdio:['ignore',log,log]});
|
||||
let checks=0;const check=(ok,why)=>{checks++;assert(ok,why);};
|
||||
const client=()=>{let auth;const jar=new Map();const request=async(url,{body,key,headers:extra={}}={})=>{const headers={Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...extra};if(body)Object.assign(headers,{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key??crypto.randomUUID()},extra);const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(10000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],at=p.indexOf('=');jar.set(p.slice(0,at),p.slice(at+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data};};return{request,enter:async role=>{const r=await request('/qa/ledger-enter/'+role);assert.equal(r.status,200);auth=r.data;},switch:async module=>{const old=auth.context,r=await request('/api/context',{body:{...fixture.scope,module_id:module}});check(r.status===200&&r.data.context.module_id===module,'Existing explicit module switch '+module);auth={csrf:r.data.csrf,context:r.data.context.token};check(auth.context!==old,'Context generation rotates');return old;}};};
|
||||
const source=g.local_source_id,flow='/general-ledger/flow?source='+source;
|
||||
try{
|
||||
const prep=client(),review=client(),reader=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await prep.request(flow)).status===401;}catch{}if(!ready)await new Promise(r=>setTimeout(r,100));}check(ready,'Unauthenticated flow refused');
|
||||
await prep.enter('prepare');await review.enter('validate');await reader.enter('read');
|
||||
let r=await prep.request(flow);check(r.status===200&&r.text.includes('Ainda não reconhecida')&&r.text.includes('Sem proposta contabilística'),'Starts before recognition');
|
||||
check((await reader.request(flow)).status===200,'Read-only role sees evidence');
|
||||
for(const query of ['source=999999','source=0','source%5Bx%5D=1','source='+source+'&company_id=999']){r=await prep.request('/general-ledger/flow?'+query);check([403,422].includes(r.status),'Unscoped/injected source refused: '+query);}
|
||||
check((await prep.request('/api/general-ledger?obligation_id=999999')).status===403,'Foreign obligation focus refused');
|
||||
const early={...g.body,obligation_id:999999};check((await prep.request('/api/general-ledger/prepare',{body:early})).status===409,'No posting proposal before recognition');
|
||||
const stale=await prep.switch('purchasing');
|
||||
check((await prep.request('/api/supplier-payables?source='+source,{headers:{'X-SGF-Context':stale}})).status===409,'Old tab generation refused after handoff');
|
||||
check((await prep.request(flow)).status===409,'Active module isolation preserved');
|
||||
r=await prep.request('/supplier-payables?source='+source);check(r.status===200&&r.text.includes('ledger_flow_url')&&r.text.includes('QA-LEDGER-SOURCE-60'),'Financial UI links the same source back to the flow');
|
||||
let listing=await prep.request('/api/supplier-payables?source='+source);check(listing.status===200&&listing.data.obligations.length===0,'No initial obligation');
|
||||
const fp={id:source,reason:'Integrated synthetic source preparation.'},fk=crypto.randomUUID();r=await prep.request('/api/supplier-payables/prepare',{body:fp,key:fk});check(r.status===200&&r.data.proposal_id,'Financial prepare through actual route');const financialProposal=r.data.proposal_id;
|
||||
r=await prep.request('/api/supplier-payables/prepare',{body:fp,key:fk});check(r.status===200&&r.data.replayed,'Financial prepare retry recovers receipt');
|
||||
await reader.switch('purchasing');check((await reader.request('/api/supplier-payables/validate',{body:{id:financialProposal,reason:'Reader cannot recognise.'}})).status===403,'Reader cannot recognise debt');
|
||||
await review.switch('purchasing');r=await review.request('/api/supplier-payables/validate',{body:{id:financialProposal,reason:'Independent financial recognition of exact source.'}});check(r.status===200&&r.data.obligation_id,'Independent recognition through actual route');const obligation=r.data.obligation_id;
|
||||
await prep.switch('accounting');r=await prep.request(flow);check(r.status===200&&r.text.includes('Obrigação #'+obligation)&&r.text.includes('Saldo 60,00 MZN'),'Recognition appears in the same chain');
|
||||
r=await prep.request('/general-ledger?obligation_id='+obligation);check(r.status===200&&r.text.includes('name="obligation_id" type="number" min="1" value="'+obligation+'"'),'Obligation handoff prefills exact source');
|
||||
const body={...g.body,obligation_id:obligation},pk=crypto.randomUUID();r=await prep.request('/api/general-ledger/prepare',{body,key:pk});check(r.status===200&&r.data.state==='prepared','Accounting proposal created from recognised obligation');const proposal=r.data.proposal_id,revision=r.data.revision;
|
||||
r=await prep.request('/api/general-ledger/prepare',{body,key:pk});check(r.status===200&&r.data.replayed&&r.data.proposal_id===proposal,'Accounting prepare retry has no second capture');
|
||||
const post={proposal_id:proposal,revision,reason:'Apply reviewed integrated synthetic impact once.'};check((await prep.request('/api/general-ledger/post',{body:post})).status===409,'Posting before independent review refused');
|
||||
await review.switch('accounting');const approval={proposal_id:proposal,revision,decision:'approved',reason:'Independent review of integrated source and exact debit credit.'};
|
||||
check((await review.request('/api/general-ledger/validate',{body:{...approval,revision:'0'.repeat(64)}})).status===409,'Stale accounting revision refused');
|
||||
r=await review.request('/api/general-ledger/validate',{body:approval});check(r.status===200&&r.data.state==='approved','Independent exact accounting review');
|
||||
const postKey=crypto.randomUUID();r=await prep.request('/api/general-ledger/post',{body:post,key:postKey});check(r.status===200&&r.data.journal_id,'Final actual HTTP posting succeeds');const journal=r.data.journal_id;
|
||||
r=await prep.request('/api/general-ledger/post',{body:post,key:postKey});check(r.status===200&&r.data.replayed&&r.data.journal_id===journal,'Same key recovers same journal');
|
||||
r=await prep.request('/api/general-ledger/post',{body:post});check(r.status===200&&r.data.journal_id===journal,'Different key does not duplicate effect');
|
||||
check((await prep.request('/api/general-ledger/post',{body:{...post,reason:'Different requested posting content.'}})).status===409,'Conflicting second content refused');
|
||||
r=await prep.request('/api/general-ledger?obligation_id='+obligation);check(r.status===200&&r.data.journals.length===1&&r.data.proposals.length===1&&r.data.events.length===3&&r.data.lines.length===2&&r.data.totals.debit_minor===6000&&r.data.totals.credit_minor===6000,'Focused journal evidence and all event kinds reconciled');
|
||||
r=await prep.request(flow);check(r.status===200&&r.text.includes('Lançamento #'+journal)&&r.text.includes('Saldo 60,00 MZN')&&r.text.includes('Débito 60,00 MZN / Crédito 60,00 MZN'),'Complete chain retains financial balance and balanced accounting');
|
||||
await reader.switch('accounting');r=await reader.request('/general-ledger?obligation_id='+obligation);check(r.status===200&&r.text.includes('Conferir percurso e origem')&&!r.text.includes('data-action="post"'),'Reader can trace origin without posting action');
|
||||
await reader.switch('purchasing');r=await reader.request('/api/supplier-payables/report?source='+source);check(r.status===200&&r.data.totals.count===1&&r.data.totals.principal_minor===6000&&r.data.totals.balance_minor===6000,'Financial report still one principal and unchanged balance after journal');
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,checks,tag,source_id:source,financial_proposal_id:financialProposal,obligation_id:obligation,ledger_proposal_id:proposal,journal_id:journal,principal_minor:6000,balance_minor:6000,debit_minor:6000,credit_minor:6000,actual_http_kernel:true,existing_context_selection:true,complete_financial_to_ledger_chain:true,live_purchasing_source:false,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,tag,journal_id:journal}));
|
||||
}finally{if(server.exitCode===null){server.kill();await new Promise(resolve=>server.once('exit',resolve));}fs.closeSync(log);}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
auth()->forgetGuards();app(App\Shared\Context\CompanyContext::class)->clear();
|
||||
// The QA forms use the explicit CSRF header. Keep validation, but do not replace
|
||||
// the shared localhost XSRF-TOKEN cookie belonging to another demonstration.
|
||||
$app->bind(Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,static fn($container)=>new class($container,$container->make(Illuminate\Contracts\Encryption\Encrypter::class)) extends Illuminate\Foundation\Http\Middleware\PreventRequestForgery { protected $addHttpCookie=false; });
|
||||
$app->usePublicPath($c['storage'].'/public');app(Illuminate\Foundation\Vite::class)->useHotFile($c['storage'].'/no-hot');
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/ledger-demo',function()use($s,$g){
|
||||
if($g['cycle_enabled']??false){return response()->view('accounting.ledger-cycle-entry',['tag'=>$s['tag'],'obligation'=>$g['obligation_id']])->header('Cache-Control','no-store');}
|
||||
if($g['integrated_flow']??false){
|
||||
return response()->view('accounting.payable-flow-entry',['tag'=>$s['tag'],'source'=>$g['local_source_id']])->header('Cache-Control','no-store');
|
||||
}
|
||||
$tag=htmlspecialchars($s['tag'],ENT_QUOTES,'UTF-8');$id=(int)$g['obligation_id'];
|
||||
return response('<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ensaio de lançamento SGF</title><main style="font:18px system-ui;max-width:850px;margin:40px auto;padding:24px" data-ledger-demo="'.$tag.'"><h1>Primeiro lançamento contabilístico — QA</h1><p>Exclusivamente sintético. Obrigação #'.$id.', 60 MZN, duas contas artificiais e período QA-2026-10. Não altera a dívida financeira, stock ou orçamento.</p><p><a href="/qa/ledger-enter/prepare?open=ledger">Preparar / aplicar</a> · <a href="/qa/ledger-enter/validate?open=ledger">Rever com outra identidade</a> · <a href="/qa/ledger-enter/read?open=ledger">Consultar</a></p><ol><li>Preparador captura a obrigação e fundamento.</li><li>Revisor confere o impacto e aprova ou recusa a versão exacta.</li><li>Preparador aplica uma vez e consulta o lançamento.</li></ol><p>Data de ensaio 29/10/2026, regra QA-PAYABLE-JOURNAL. O histórico não é apagado para repetir a demonstração. Revisão visual e aceitação ainda por realizar.</p></main></html>')->header('Cache-Control','no-store');
|
||||
});
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/ledger-enter/{role}',function(Illuminate\Http\Request $r,string $role)use($f,$g){
|
||||
abort_unless(in_array($role,['prepare','validate','read'],true),404);$module=$r->query('module','accounting');abort_unless(in_array($module,['accounting','purchasing'],true),422);
|
||||
$u=App\Models\User::findOrFail($f['actors'][$role]);auth()->login($u);$r->session()->regenerate();$token=bin2hex(random_bytes(24));$r->session()->put('auth_version',$u->auth_version);$r->session()->put('company_context',$f['scope']+['token'=>$token,'module_id'=>$module]);
|
||||
$r->session()->put('ledger_demo',true);
|
||||
if($r->query('open')==='cycle')return redirect('/general-ledger/cycle');
|
||||
if($r->query('open')==='flow')return redirect('/general-ledger/flow?source='.$g['local_source_id']);
|
||||
if($r->query('open')==='ledger')return redirect('/general-ledger');
|
||||
return response()->json(['csrf'=>csrf_token(),'context'=>$token,'obligation_id'=>$g['obligation_id']])->header('Cache-Control','no-store');
|
||||
});
|
||||
$sql=[];$mode=[];
|
||||
require __DIR__.'/ledger-reports-observer.php';
|
||||
DB::listen(function($event)use(&$sql,&$mode){$sql[]=$event->sql;if(!$mode&&str_contains($event->sql,'"gl_journals"')){$pdo=$event->connection->getPdo();$mode=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
|
||||
$app->handleRequest(Illuminate\Http\Request::capture());
|
||||
if(($_SERVER['HTTP_X_LEDGER_QA_PROOF']??'')==='read')file_put_contents($s['root'].'/ledger-http-read-mode.json',json_encode(['transaction'=>$mode,'writer_lock_queries'=>count(array_filter($sql,fn($s)=>str_contains(strtolower($s),'for update')))],JSON_PRETTY_PRINT));
|
||||
exit;
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
$settings=getenv('LEDGER_QA_SETTINGS');
|
||||
$path=parse_url($_SERVER['REQUEST_URI']??'',PHP_URL_PATH);
|
||||
if(str_starts_with($path??'','/build/')){
|
||||
$state=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true);$actual=realpath($settings);$private=realpath($state['root'].'/private');
|
||||
if(!$actual||!$private||!str_starts_with($actual,$private.DIRECTORY_SEPARATOR)){http_response_code(404);exit;}
|
||||
$s=json_decode(file_get_contents($actual),true);$base=realpath($s['sgf']['storage'].'/public/build');$file=realpath($s['sgf']['storage'].'/public'.$path);
|
||||
if(!$base||!$file||!str_starts_with($file,$base.DIRECTORY_SEPARATOR)||!in_array(pathinfo($file,PATHINFO_EXTENSION),['js','css','woff2'],true)){http_response_code(404);exit;}
|
||||
header('Content-Type: '.(['js'=>'application/javascript','css'=>'text/css','woff2'=>'font/woff2'][pathinfo($file,PATHINFO_EXTENSION)]));readfile($file);exit;
|
||||
}
|
||||
$argv=[__FILE__,$settings,'ledger-http'];require __DIR__.'/payable-history-sgf-proof.php';
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import {spawn,spawnSync} from 'node:child_process';import assert from 'node:assert/strict';import crypto from 'node:crypto';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned QA tag required');const root=path.join(state.root,'private/ledger-'+tag),settings=path.join(root,'settings.json'),owned=read(settings);if(owned.tag!==tag||owned.cluster!=='sgf_modular_regression_'+state.id)throw Error('Owned cluster mismatch');const output=path.join(root,'ledger-http.json');if(fs.existsSync(output))throw Error('Completed HTTP proof preserved');
|
||||
if(!fs.existsSync(path.join(root,'ledger-http-input.json'))){const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/payable-history-sgf-proof.php',settings,'ledger-http-input'],{encoding:'utf8',windowsHide:true});if(r.status!==0||!fs.existsSync(path.join(root,'ledger-http-input.json')))throw Error('Private recovery preparation failed');}
|
||||
const input=read(path.join(root,'ledger-http-input.json'));
|
||||
await new Promise((resolve,reject)=>{const p=net.createServer();p.once('error',()=>reject(Error('Proof port occupied')));p.listen(8047,'127.0.0.1',()=>p.close(resolve));});
|
||||
const log=fs.openSync(path.join(root,'ledger-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-http-router.php')],{cwd:process.cwd(),env:{...process.env,LEDGER_QA_SETTINGS:settings},windowsHide:true,stdio:['ignore',log,log]});
|
||||
let checks=0;const check=(ok,why)=>{checks++;assert(ok,why);};
|
||||
const client=()=>{let auth;const jar=new Map();const request=async(url,{body,key,headers:extra={}}={})=>{const headers={Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...extra};if(body)Object.assign(headers,{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key??crypto.randomUUID()},extra);const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(8000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],at=p.indexOf('=');jar.set(p.slice(0,at),p.slice(at+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,text,data,headers:r.headers};};return{request,enter:async(role,module='accounting')=>{const r=await request('/qa/ledger-enter/'+role+'?module='+module);if(r.status!==200)throw Error('QA entry failed');auth=r.data;return auth;}};};
|
||||
try{
|
||||
const reader=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await reader.request('/api/general-ledger')).status===401;}catch{}if(!ready)await new Promise(r=>setTimeout(r,100));}check(ready,'Unauthenticated ledger refused');await reader.enter('read');
|
||||
const data=await reader.request('/api/general-ledger?period_key=QA-2026-10',{headers:{'X-Ledger-QA-Proof':'read'}});check(data.status===200&&data.data.journals.length===1&&data.data.lines.length===2&&data.data.totals.debit_minor===6000&&data.data.totals.credit_minor===6000,'Journal and period totals reconciled');
|
||||
const mode=read(path.join(root,'ledger-http-read-mode.json'));check(mode.transaction.isolation==='repeatable read'&&mode.transaction.read_only==='on'&&mode.writer_lock_queries===0,'Ledger reads are coherent and nonlocking');
|
||||
check((await reader.request('/api/general-ledger/post',{body:input.post.body})).status===403,'Read actor cannot post');
|
||||
check((await reader.request('/api/general-ledger/validate',{body:input.validate.body})).status===403,'Read actor cannot validate');
|
||||
check((await reader.request('/api/general-ledger?account_id=9223372036854775807')).status===403,'Foreign or unavailable account refused');
|
||||
check((await reader.request('/api/general-ledger?period_key=NOT-CONFIGURED')).status===403,'Missing period not presented as zero');
|
||||
for(const q of ['company_id=999','page=0','account_id%5Bx%5D=1'])check((await reader.request('/api/general-ledger?'+q)).status===422,'Invalid/injected filters refused');
|
||||
const html=await reader.request('/general-ledger?period_key=QA-2026-10&account_id=');check(html.status===200&&html.text.includes('60,00 MZN')&&html.text.includes('QA-CONTRAPARTIDA')&&html.text.includes('QA-FORNECEDOR'),'HTML presents immutable source/rule and amounts');
|
||||
check(!html.text.includes('data-action="post"')&&!html.text.includes('data-action="validate"'),'Read HTML grants no actions');
|
||||
check(data.headers.get('cache-control').includes('no-store'),'Private response');
|
||||
check((await reader.request('/api/general-ledger',{headers:{'X-SGF-Context':'obsolete'}})).status===409,'Stale context refused');
|
||||
await reader.enter('read','purchasing');check((await reader.request('/api/general-ledger')).status===409,'Wrong active module refused');await reader.enter('read');
|
||||
const preparer=client();await preparer.enter('prepare');const reviewer=client();await reviewer.enter('validate');
|
||||
for(const [action,c] of [['prepare',preparer],['validate',reviewer],['post',preparer]]){const r=await c.request('/api/general-ledger/'+action,input[action]);check(r.status===200&&r.data.replayed,'Actual HTTP recovers original '+action+' receipt');}
|
||||
const other=await preparer.request('/api/general-ledger/post',{body:input.post.body});check(other.status===200&&other.data.journal_id===input.journal_id,'Different key recovers same journal');
|
||||
check((await preparer.request('/api/general-ledger/post',{body:{...input.post.body,reason:'Different effect request content.'}})).status===409,'Conflicting posting content refused');
|
||||
check((await preparer.request('/api/general-ledger/post',{body:input.post.body,headers:{'X-CSRF-TOKEN':''}})).status===419,'Real CSRF middleware enforced');
|
||||
const home=await reader.request('/');check(home.status===200&&home.text.includes('/build/assets/'),'Private compiled app manifest served without replacing demos');
|
||||
const assets=read(path.join(owned.sgf.storage,'public/build/manifest.json')),asset=Object.values(assets).find(a=>a.isEntry)?.file;check(Boolean(asset)&&(await reader.request('/build/'+asset)).status===200,'Private compiled module assets reachable');
|
||||
fs.writeFileSync(output,JSON.stringify({passed:true,checks,actual_http_kernel:true,recovered_all_three_original_command_receipts:true,one_effect_retained:true,read_transaction:mode,private_build_served:true,visual_review:false,browser_interactions_tested:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,real_csrf:true,three_command_routes_recovered:true}));
|
||||
}finally{if(server.exitCode===null){server.kill();await new Promise(resolve=>server.once('exit',resolve));}fs.closeSync(log);}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
$output=$s['root'].'/industrial-cases.json';if(file_exists($output)||DB::table('gl_journals')->where('snapshot->source->effect_type','industry.cost.validated')->exists())throw new LogicException('Fresh industrial regression fixture required');$before=$fingerprint();$checks=0;$cases=[];$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};
|
||||
$refuse=function($fn,$text=null)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException $e){$caught=$text===null||str_contains($e->getMessage(),$text);}$ok($caught,'Expected industrial source/version/access refusal'.($text?' '.$text:''));};
|
||||
$case=function($name,$fn)use(&$checks,&$cases,$enter){$at=$checks;DB::beginTransaction();try{$fn();$cases[]=['name'=>$name,'checks'=>$checks-$at,'passed'=>true];}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}};
|
||||
$capture=fn()=>$command('prepare','effect-prepare',$body);$approve=fn($p)=>$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent exact industrial source and impact review.']);$postBody=fn($p)=>['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Explicit once-only industrial effect.'];
|
||||
$successor=function($amount='50',$approve=true)use($i,$ic,$enter){$r=DB::table('ic_inputs')->find($i['refs']['material_rate']['id']);$data=json_decode($r->data,true);$data['amount']=$amount;$v=$ic->input($i['lot_id'],$enter('prepare')->id,['kind'=>'material_rate','source_ref'=>$r->source_ref,'version'=>2,'previous_id'=>$r->id,'data'=>$data,'evidence'=>'Rollback-only or race industrial successor rate.'],(string)Str::uuid());if($approve)$ic->decide($i['lot_id'],$enter('validate')->id,['type'=>'input','id'=>$v['id'],'version'=>2,'decision'=>'approved','reason'=>'Distinct exact successor industrial rate.'],(string)Str::uuid());return $v;};
|
||||
$case('exact-owner-impact-and-no-automatic-residual',function()use($provider,$enter,$i,$ledger,$ok,$capture){$v=$provider->source($enter('read'),$i['lot_id']);$ok($v['principal_minor']===180000&&$v['good_units']==='10'&&$v['unit_cost_exact']==='180'&&$v['residual_exact']==='300','Actual1800/10/180/residual300');$p=$capture();$snap=json_decode(DB::table('gl_proposals')->where('id',$p['proposal_id'])->value('snapshot'),true);$ok(count($snap['lines'])===2&&array_column($snap['lines'],'account_code')===['QA-CUSTO-INDUSTRIAL','QA-COMPENSACAO-INDUSTRIAL']&&$snap['impact']['debit_minor']===180000,'Two-line exact impact excludes residual');$d=$ledger->industrial($enter('read'),$i['lot_id']);$html=view('accounting.industrial-ledger',['data'=>$d])->render();$ok(str_contains($html,'Residual 300 MZN')&&!str_contains($html,'data-action='),'Reader sees residual and no decisions');$ok($ledger->industrial($enter('read'))['book']['journals']===[],'No auto-selection or unrelated source mislabelling');});
|
||||
$case('incomplete-result-and-current-dependencies',function()use($ic,$i,$enter,$provider,$refuse,$ok,$capture){$refs=$i['refs'];$refs['material_rate']=null;$r=$ic->calculate($i['lot_id'],$enter('prepare')->id,['version'=>2,'previous_id'=>$i['result_id'],'refs'=>$refs,'currency'=>'MZN'],(string)Str::uuid());$ic->decide($i['lot_id'],$enter('validate')->id,['type'=>'result','id'=>$r['id'],'version'=>2,'decision'=>'approved','reason'=>'Approve explicitly partial known800, not a complete industrial total.'],(string)Str::uuid());$c=$ic->conference($i['lot_id'],$enter('read')->id);$ok($c['snapshot']['known_cost_exact']==='800'&&$c['snapshot']['selected_total_exact']===null,'Incomplete result retains800 known, no total');$refuse($capture);});
|
||||
$case('same-amount-new-version-stales-capture',function()use($capture,$approve,$successor,$refuse,$command,$postBody,$ic,$i,$enter,$provider,$ok){$p=$capture();$approve($p);$old=$provider->source($enter('read'),$i['lot_id']);$v=$successor();$refuse(fn()=>$command('prepare','post',$postBody($p)));$refs=$i['refs'];$refs['material_rate']=['id'=>$v['id'],'version'=>2];$r=$ic->calculate($i['lot_id'],$enter('prepare')->id,['version'=>2,'previous_id'=>$i['result_id'],'refs'=>$refs,'currency'=>'MZN'],(string)Str::uuid());$ic->decide($i['lot_id'],$enter('validate')->id,['type'=>'result','id'=>$r['id'],'version'=>2,'decision'=>'approved','reason'=>'Same amount1800, new exact input/result versions.'],(string)Str::uuid());$new=$provider->source($enter('read'),$i['lot_id']);$ok($old['origin_key']===$new['origin_key']&&$old['recognition_revision']!==$new['recognition_revision']&&$new['principal_minor']===180000,'Stable origin, different exact revision, same amount');$refuse(fn()=>$command('prepare','post',$postBody($p)));});
|
||||
$case('live-permissions-person-and-config',function()use($scope,$g,$capture,$approve,$command,$postBody,$refuse,$provider,$enter,$i){foreach(['industrial_costs.view','general_ledger.view','general_ledger.prepare'] as $right){DB::beginTransaction();try{DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission',$right)->delete();$refuse($capture);}finally{DB::rollBack();}}$p=$capture();DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['prepare']]);$refuse(fn()=>$approve($p));config(['industrial_ledger.synthetic_enabled'=>false]);try{$refuse(fn()=>$provider->source($enter('read'),$i['lot_id']));}finally{config(['industrial_ledger.synthetic_enabled'=>true]);}});
|
||||
$case('eligible-foreign-tenant-company-unit',function()use($scope,$g,$provider,$enter,$i,$refuse){foreach(['unit','company','tenant'] as $kind){DB::beginTransaction();try{$u=$enter('read');$company=$kind==='unit'?DB::table('companies')->where('id',$scope['company_id'])->first():($kind==='company'?DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id','<>',$scope['company_id'])->first():DB::table('companies')->where('tenant_id','<>',$scope['tenant_id'])->first());$unit=DB::table('units')->insertGetId(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'name'=>'SQL-ONLY foreign industrial '.$kind,'created_at'=>now(),'updated_at'=>now()]);if(!DB::table('company_memberships')->where(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id])->exists())DB::table('company_memberships')->insert(['tenant_id'=>$company->tenant_id,'company_id'=>$company->id,'user_id'=>$u->id]);foreach(['company.view'=>null,'unit.view'=>$unit,'industrial_costs.view'=>$unit] as $right=>$v)Tests\Support\IdentityFixture::grant($u->id,$company->tenant_id,$company->id,$right,$v);app(CompanyContext::class)->selectFor($u->id,$company->tenant_id,$company->id,$unit);$refuse(fn()=>$provider->source($u,$i['lot_id']));}finally{DB::rollBack();}}});
|
||||
$case('atomic-faults-recovery-and-no-second-recognition',function()use($capture,$approve,$command,$postBody,$ok,$refuse){$p=$capture();$approve($p);$journals=DB::table('gl_journals')->count();foreach(['after_header','after_lines','after_event','after_receipt'] as $step){$caught=false;$commands=DB::table('gl_commands')->count();try{$command('prepare','post',$postBody($p),null,function($name)use($step){if($name===$step)throw new RuntimeException('injected-industrial');});}catch(RuntimeException $e){$caught=$e->getMessage()==='injected-industrial';}$ok($caught&&DB::table('gl_journals')->count()===$journals&&DB::table('gl_commands')->count()===$commands,'Atomic industrial rollback at '.$step);}$key=(string)Str::uuid();$j=$command('prepare','post',$postBody($p),$key);$r=$command('prepare','post',$postBody($p),$key);$other=$command('prepare','post',$postBody($p));$ok($r['replayed']&&$other['journal_id']===$j['journal_id'],'Same/other key no duplicate after fault');$refuse($capture);$refuse(fn()=>$command('prepare','correct-prepare',['journal_id'=>$j['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Industrial correction deliberately not authorised.']));});
|
||||
$case('direct-origin-overlap-and-perimeter-immutability',function()use($i,$g,$provider,$enter,$refuse,$ok){$o=(array)DB::table('wo_orders')->where('id',$g['order_id'])->first();unset($o['id']);$o['reference']='SQL-ONLY-INDUSTRIAL-OVERLAP';$order=DB::table('wo_orders')->insertGetId($o);$m=(array)DB::table('gl_cost_sources')->where('order_id',$g['order_id'])->first();unset($m['id']);$m['order_id']=$order;$m['material_origin']=DB::table('ic_inputs')->where('id',$i['refs']['material']['id'])->value('source_ref');DB::table('gl_cost_sources')->insert($m);$refuse(fn()=>$provider->source($enter('read'),$i['lot_id']),'custo directo');DB::beginTransaction();$caught=false;try{DB::table('gl_industrial_sources')->where('lot_id',$i['lot_id'])->update(['reason'=>'No rewrite']);}catch(Illuminate\Database\QueryException){$caught=true;}finally{DB::rollBack();}$ok($caught,'Industrial mapping append-only');});
|
||||
$case('bounded-industrial-cards-and-hidden-panel',function()use($i,$scope,$g,$enter,$ok){$lot=(array)DB::table('ic_lots')->where('id',$i['lot_id'])->first();$m=(array)DB::table('gl_industrial_sources')->where('lot_id',$i['lot_id'])->first();unset($lot['id'],$m['id']);for($n=0;$n<25;$n++){$lot['reference']='SQL-ONLY-IND-PAGE-'.$n;$id=DB::table('ic_lots')->insertGetId($lot);DB::table('gl_industrial_sources')->insert(array_merge($m,['lot_id'=>$id]));}$w=app(App\Modules\Accounting\LedgerComposition::class);$db=DB::connection();$db->enableQueryLog();$db->flushQueryLog();$a=$w->workspace($enter('read'));$qa=count($db->getQueryLog());$db->flushQueryLog();$b=$w->workspace($enter('read'),['industrial_page'=>2]);$qb=count($db->getQueryLog());$db->disableQueryLog();$ok(count($a['panels']['industrial']['rows'])===25&&count($b['panels']['industrial']['rows'])===1&&$b['panels']['industrial']['pagination']['total']===26,'Bounded industrial pages25+1');$ok($qa===$qb&&$a['panels']['sales']===$b['panels']['sales'],'No per-card queries or other page drift');$ok($b['panels']['industrial']['rows'][0]['result_id']===null,'No result never shown as zero/approved');DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','industrial_costs.view')->delete();$d=$w->workspace($enter('prepare'));$ok($d['panels']['industrial']===['allowed'=>false]&&!str_contains(view('accounting.ledger-composition',['data'=>$d])->render(),'data-action='),'No industrial metadata/count or period forms without source right');});
|
||||
$ok($fingerprint()===$before&&!DB::table('gl_journals')->where('snapshot->source->effect_type','industry.cost.validated')->exists(),'Original industrial evidence/outbox/rights unchanged after rollback-only probes');file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'cases'=>$cases,'rollback_only'=>true,'source_outbox_rights_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo 'Industrial regression: '.count($cases)." cases/$checks checks.\n";
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
$output=$s['root'].'/industrial-correction-cases.json';if(file_exists($output)||DB::table('gl_corrections')->exists())throw new LogicException('Fresh correction regression required');$checks=0;$cases=[];$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};$refuse=function($fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught,'Expected individual/group/source/authority refusal');};
|
||||
$case=function($name,$fn)use(&$cases,&$checks,$enter){$at=$checks;DB::beginTransaction();try{$fn();$cases[]=['name'=>$name,'checks'=>$checks-$at,'passed'=>true];}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}};
|
||||
$revise=function()use($ic,$enter,$i,$ig){$old=DB::table('ic_inputs')->find($ig['replacement_rate_id']);$rate=$ic->input($i['lot_id'],$enter('prepare')->id,['kind'=>'material_rate','source_ref'=>$old->source_ref,'version'=>3,'previous_id'=>$old->id,'data'=>json_decode($old->data,true),'evidence'=>'Version-only industrial51 successor, amount1820 unchanged.'],(string)Str::uuid());$ic->decide($i['lot_id'],$enter('validate')->id,['type'=>'input','id'=>$rate['id'],'version'=>3,'decision'=>'approved','reason'=>'Distinct exact industrial version-only successor.'],(string)Str::uuid());$refs=$ig['replacement_refs'];$refs['material_rate']=['id'=>$rate['id'],'version'=>3];$result=$ic->calculate($i['lot_id'],$enter('prepare')->id,['version'=>3,'previous_id'=>$ig['replacement_result_id'],'refs'=>$refs,'currency'=>'MZN'],(string)Str::uuid());$ic->decide($i['lot_id'],$enter('validate')->id,['type'=>'result','id'=>$result['id'],'version'=>3,'decision'=>'approved','reason'=>'Distinct selected1820 with new exact industrial result version.'],(string)Str::uuid());return $result['id'];};
|
||||
$case('explicit-asymmetric-impact-and-indivisible-application',function()use($captureCorrection,$approveCorrection,$command,$postCorrectionBody,$ledger,$enter,$g,$i,$ok,$refuse){$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Uncorrected current295 blocks completeness.']));$p=$captureCorrection();$c=DB::table('gl_corrections')->where('id',$p['correction_id'])->first();$a=DB::table('gl_proposals')->where('id',$c->reverse_proposal_id)->first();$b=DB::table('gl_proposals')->where('id',$c->replacement_proposal_id)->first();$sa=json_decode($a->snapshot,true);$sb=json_decode($b->snapshot,true);$ok($sa['impact']['debit_minor']===180000&&$sb['impact']['debit_minor']===182000&&$sa['source']['effect_type']==='industry.cost.reversal'&&$sb['source']['effect_type']==='industry.cost.replacement','Four lines explicitly capture275/295 and distinct cost types');$refuse(fn()=>$command('validate','validate',['proposal_id'=>$a->id,'revision'=>$a->revision,'decision'=>'approved','reason'=>'Cannot review isolated pair part.']));$approveCorrection($p);$refuse(fn()=>$command('prepare','post',['proposal_id'=>$a->id,'revision'=>$a->revision,'reason'=>'Cannot post isolated pair part.']));$key=(string)Str::uuid();$r=$command('prepare','correct-post',$postCorrectionBody($p),$key);$same=$command('prepare','correct-post',$postCorrectionBody($p),$key);$other=$command('prepare','correct-post',$postCorrectionBody($p));$ok($same['replayed']&&$other['result_id']===$r['result_id']&&DB::table('gl_journals')->count()===4,'Same/other key and source identity recover one pair');$refuse($captureCorrection);$book=$ledger->listing($enter('read'),['industrial_lot_id'=>$i['lot_id']]);$ok(count($book['journals'])===3&&count($book['events'])===9&&$book['totals']['debit_minor']===542000,'Typed original and both pair parts retain correct history and gross total');});
|
||||
$case('faults-at-pair-boundaries-rollback-and-recovery',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$ok){$p=$captureCorrection();$approveCorrection($p);foreach(['after_header','after_lines','after_event','after_correction_first','after_correction_result','after_receipt'] as $step){$caught=false;$count=DB::table('gl_commands')->count();try{$command('prepare','correct-post',$postCorrectionBody($p),(string)Str::uuid(),function($name)use($step){if($name===$step)throw new RuntimeException('cost-pair-injected-'.$step);});}catch(RuntimeException $e){$caught=str_starts_with($e->getMessage(),'cost-pair-injected-');}$ok($caught&&DB::table('gl_journals')->count()===2&&DB::table('gl_lines')->count()===4&&!DB::table('gl_correction_results')->exists()&&DB::table('gl_commands')->count()===$count,'No partial pair/result/receipt at '.$step);}$r=$command('prepare','correct-post',$postCorrectionBody($p));$ok(DB::table('gl_journals')->count()===4&&DB::table('gl_correction_results')->count()===1,'Recovery completes both parts exactly once');});
|
||||
$case('database-rejects-orphan-cost-reversal',function()use($captureCorrection,$approveCorrection,$ledger,$enter,$ok){$p=$captureCorrection();$approveCorrection($p);$c=DB::table('gl_corrections')->where('id',$p['correction_id'])->first();$part=DB::table('gl_proposals')->where('id',$c->reverse_proposal_id)->first();$engine=$ledger->engine($enter('prepare'));$caught=false;DB::beginTransaction();try{(new ReflectionMethod($engine,'post'))->invoke($engine,['proposal_id'=>$part->id,'revision'=>$part->revision],'SQL adversarial orphan probe, rollback only.');DB::statement('SET CONSTRAINTS ALL IMMEDIATE');}catch(Illuminate\Database\QueryException $e){$caught=str_contains($e->getMessage(),'GL_INDUSTRIAL_CORRECTION_PAIR_REQUIRED');}finally{DB::rollBack();}$ok($caught&&DB::table('gl_journals')->count()===2&&!DB::table('gl_correction_results')->exists(),'Deferred SQL constraint rejects balanced individual orphan');});
|
||||
$case('changed-source-version-requires-new-conference',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$revise,$refuse,$ok){$old=$captureCorrection();$approveCorrection($old);$revise();$refuse(fn()=>$command('prepare','correct-post',$postCorrectionBody($old)));$fresh=$captureCorrection();$ok($fresh['revision']!==$old['revision']&&$fresh['correction_id']!==$old['correction_id'],'New exact version creates another immutable group capture');$refuse(fn()=>$approveCorrection($old));$approveCorrection($fresh);$command('prepare','correct-post',$postCorrectionBody($fresh));$ok(DB::table('gl_correction_results')->count()===1&&DB::table('gl_journals')->count()===4,'Only newly conferred group applies');});
|
||||
$case('rights-person-and-replay-authority',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$scope,$g,$refuse){$p=$captureCorrection();DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['prepare']]);$refuse(fn()=>$approveCorrection($p));DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['validate']]);$approveCorrection($p);$key=(string)Str::uuid();$command('prepare','correct-post',$postCorrectionBody($p),$key);DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','general_ledger.correct_post')->delete();$refuse(fn()=>$command('prepare','correct-post',$postCorrectionBody($p),$key));});
|
||||
$case('readers-foreign-original-disabled-capability-and-closed-period',function()use($command,$correctionBody,$captureCorrection,$scope,$g,$refuse){$refuse(fn()=>$command('read','correct-prepare',$correctionBody));$refuse(fn()=>$command('prepare','correct-prepare',array_replace($correctionBody,['journal_id'=>999999])));$refuse(fn()=>$command('prepare','correct-prepare',array_replace($correctionBody,['journal_on'=>'2026-10-28'])));$old=config('industrial_ledger.correction_enabled');config(['industrial_ledger.correction_enabled'=>false]);try{$refuse($captureCorrection);}finally{config(['industrial_ledger.correction_enabled'=>$old]);}$period=(array)DB::table('gl_periods')->where($scope)->first();unset($period['id']);$period['version']=2;$period['status']='closed';DB::table('gl_periods')->insert($period);$refuse($captureCorrection);});
|
||||
$case('new-change-after-correction-blocks-owner-conference-no-chain',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$revise,$refuse,$ok,$provider,$enter,$scope){$p=$captureCorrection();$approveCorrection($p);$command('prepare','correct-post',$postCorrectionBody($p));$period=(array)DB::table('gl_periods')->where($scope)->orderByDesc('version')->first();$ok($provider->period($enter('read'),$period)['pending']===0,'Current industrial pair resolves selected source');$revise();$ok($provider->period($enter('read'),$period)['pending']===1,'Later same-value result requires new conference');$refuse($captureCorrection);$ok(!DB::table('gl_period_closures')->exists()&&DB::table('gl_correction_results')->count()===1,'No implicit chain or close');});
|
||||
$ok($originalsIntact()&&DB::table('gl_journals')->count()===2&&!DB::table('gl_corrections')->exists(),'All probes rolled back, original/source/cost/outbox/rights remain exact');file_put_contents($output,json_encode(['passed'=>true,'cases'=>$cases,'checks'=>$checks,'rollback_only'=>true,'source_originals_outbox_rights_unchanged'=>true,'asymmetric_pair_and_orphan_sql_enforced'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Industrial correction regression: ".count($cases)." cases/$checks checks, exact asymmetric pair, SQL orphan refusal, atomic failures and preserved originals.\n";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user