Accept technical extraction of shared budget core with QA limits

This commit is contained in:
2026-10-29 13:25:05 +02:00
parent d071bc03d3
commit f77900e198
15 changed files with 1220 additions and 0 deletions
@@ -0,0 +1,78 @@
<?php
use Illuminate\Support\Facades\DB;
function budgetQaAssert(bool $condition, string $message): void
{
if (! $condition) throw new RuntimeException($message);
}
function budgetQaFixture(string $mode): array
{
if ($mode === 'compras') {
require_once base_path('tests/Support/ReceiptFixture.php');
$f = receiptFixture();
$a = \App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'finance']);
$v = \App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'admin']);
return ['actor'=>$a->id,'validator'=>$v->id,'tenant'=>$f['tenant']->id,'centre'=>$f['r']->cost_center_id,'origin'=>$f['r']->id,'invoice'=>$f['order']->id,'scope'=>['tenant_id'=>$f['tenant']->id]];
}
$now = now();
$tenant = DB::table('tenants')->insertGetId(['name'=>'Synthetic tenant','created_at'=>$now,'updated_at'=>$now]);
// Deliberately different tenant/company/unit identifiers, no numeric equivalence.
DB::table('companies')->insert(['tenant_id'=>$tenant,'name'=>'Unused synthetic company','created_at'=>$now,'updated_at'=>$now]);
$company = DB::table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Synthetic purchaser','created_at'=>$now,'updated_at'=>$now]);
DB::table('units')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Reserved','created_at'=>$now,'updated_at'=>$now]);
$unit = DB::table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Purchasing QA','created_at'=>$now,'updated_at'=>$now]);
$scope = ['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$a = \App\Models\User::create(['name'=>'Synthetic preparer','email'=>'prepare-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
$v = \App\Models\User::create(['name'=>'Synthetic validator','email'=>'validate-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
foreach ([$a,$v] as $u) {
DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);
foreach (['company.view'=>null,'unit.view'=>$unit] as $right=>$unitId) DB::table('permission_grants')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unitId,'permission'=>$right,'created_at'=>$now,'updated_at'=>$now]);
}
foreach ([$a->id=>['create','record','consume','view','reinforcement.propose'],$v->id=>['approve','view','reinforcement.review']] as $id=>$rights) foreach ($rights as $right) DB::table('permission_grants')->insert($scope+['user_id'=>$id,'permission'=>'purchasing_budget.'.$right,'created_at'=>$now,'updated_at'=>$now]);
$centre = DB::table('cb_centres')->insertGetId($scope+['code'=>'CENTRE-TEST']);
$origin = DB::table('cb_origins')->insertGetId($scope+['cost_center_id'=>$centre,'required_kind'=>'operating','reference'=>'REQ-'.bin2hex(random_bytes(6))]);
return ['actor'=>$a->id,'validator'=>$v->id,'tenant'=>$tenant,'company'=>$company,'unit'=>$unit,'centre'=>$centre,'origin'=>$origin,'invoice'=>$origin,'scope'=>$scope];
}
function budgetQaSelect(string $mode, array $f, string $role='actor'): object
{
$user = \App\Models\User::findOrFail($f[$role]);
auth()->guard()->setUser($user);
if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($user->id,$f['tenant'],$f['company'],$f['unit']);
return $user;
}
function budgetQaEngine(string $mode, array $f, string $role='actor'): \Tempo\Finance\Budget\BudgetControl
{
$user = budgetQaSelect($mode,$f,$role);
return $mode === 'compras' ? app(\App\Services\CommonBudget::class)->for($user) : app(\App\Modules\Purchasing\BudgetAdapter::class)->for($user);
}
function budgetQaRecord(string $mode, array $f, string $kind, int $amount=0): mixed
{
$user = budgetQaSelect($mode,$f);
return DB::transaction(function () use ($mode,$f,$kind,$amount,$user) {
if ($mode === 'compras') {
$request = \App\Models\Requisition::findOrFail($f['origin']);
return app(\App\Services\BudgetWorkflow::class)->record($user,$request,$kind,$amount,$amount);
}
return app(\App\Modules\Purchasing\BudgetAdapter::class)->record($user,$f['origin'],$kind,$amount,$amount);
});
}
function budgetQaCreate(string $mode, array $f, int $amount=100000, bool $tax=true): int
{
$data = ['cost_center_id'=>$f['centre'],'year'=>(int)now()->year,'kind'=>'operating','amount_minor'=>$amount,'includes_tax'=>$tax,'reason'=>'Synthetic allocation'];
$user = budgetQaSelect($mode,$f);
$id = $mode==='compras' ? budgetQaEngine($mode,$f)->create($data) : app(\App\Modules\Purchasing\BudgetAdapter::class)->create($user,$data);
budgetQaEngine($mode,$f,'validator')->approve($id);
return $id;
}
function budgetQaReject(callable $work, string $message): void
{
try { $work(); } catch (\Tempo\Finance\Budget\BudgetFailure|\Illuminate\Validation\ValidationException|\Symfony\Component\HttpKernel\Exception\HttpException $e) { return; }
throw new RuntimeException('Expected rejection: '.$message);
}
+145
View File
@@ -0,0 +1,145 @@
<?php
// Runs inside a real Laravel host, with a coordinator-provisioned disposable database.
$root = $argv[1] ?? '';
$mode = $argv[2] ?? '';
$database = getenv('DB_DATABASE');
$pattern = $mode === 'compras' ? '/^compras_receipts_test_[a-f0-9]{12}$/D' : '/^budget_core_sgf_[a-f0-9]{12}$/D';
if (! in_array($mode,['compras','sgf'],true) || ! preg_match($pattern,$database) || getenv('DB_HOST') !== '127.0.0.1') throw new LogicException('Unsafe host test configuration.');
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
if (DB::connection()->getDatabaseName() !== $database || DB::selectOne('select current_database() as db')->db !== $database) throw new LogicException('Effective QA database mismatch.');
require __DIR__.'/host-support.php';
$worker = in_array('--worker',$argv,true);
if ($worker) {
$input = json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);
DB::select('select set_config(?, ?, false)',['application_name',$input['label']]);
try {
budgetQaRecord($mode,$input['fixture'],$input['kind'],$input['amount']);
$status = 200;
} catch (\Tempo\Finance\Budget\BudgetFailure|\Illuminate\Validation\ValidationException $e) { $status = 422; }
echo json_encode(['status'=>$status,'pid'=>DB::selectOne('select pg_backend_pid() as pid')->pid],JSON_THROW_ON_ERROR);
exit;
}
if ($mode === 'compras') {
\Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);
} else {
require __DIR__.'/sgf-schema.php';
}
$prefix = $mode==='sgf'?'cb_':'';
$f = budgetQaFixture($mode);
$budget = budgetQaCreate($mode,$f);
$steps = [];
foreach ([['reserve',30000],['award',35000],['commit',35000]] as [$kind,$amount]) {
budgetQaRecord($mode,$f,$kind,$amount);
budgetQaRecord($mode,$f,$kind,$amount);
$steps[$kind] = budgetQaEngine($mode,$f)->balance($budget);
}
// Real invoice foreign key in Compras, source-reference-only consumption in SGF.
if ($mode==='compras') {
budgetQaSelect($mode,$f);
$sourceInvoice = \App\Models\Invoice::query()->create(['tenant_id'=>$f['tenant'],'supplier_id'=>\App\Models\PurchaseOrder::findOrFail($f['invoice'])->supplier_id,'purchase_order_id'=>$f['invoice'],'created_by'=>$f['actor'],'number'=>'TEST-BUDGET-ONLY','issued_on'=>now()->format('Y-m-d'),'subtotal_minor'=>10000,'tax_minor'=>0,'total_minor'=>10000,'snapshot'=>[]]);
$f['invoice'] = $sourceInvoice->id;
}
$engine = budgetQaEngine($mode,$f);
$engine->consume($f['origin'],$f['invoice'],10000,10000);
$engine->consume($f['origin'],$f['invoice'],10000,10000);
$steps['consume'] = $engine->balance($budget);
budgetQaAssert($steps['consume']['reserved_minor']===0 && $steps['consume']['committed_minor']===25000 && $steps['consume']['consumed_minor']===10000 && $steps['consume']['available_minor']===65000,'Partial consumption differs.');
budgetQaReject(fn()=>budgetQaRecord($mode,$f,'reserve',30001),'changed replay');
budgetQaReject(fn()=>budgetQaRecord($mode,$f,'release'),'release committed source');
budgetQaReject(fn()=>$engine->consume($f['origin'],$f['invoice'],10001,10001),'changed consumption replay');
$rid = $engine->proposeReinforcement($budget,20000,'REF-QA','Synthetic reinforcement');
budgetQaAssert($engine->balance($budget)['available_minor']===65000,'Pending reinforcement changed availability.');
budgetQaReject(fn()=>$engine->reviewReinforcement($rid,'approved','Self approval'),'self approval');
$review = budgetQaEngine($mode,$f,'validator');
$review->reviewReinforcement($rid,'approved','Distinct synthetic validation');
$review->reviewReinforcement($rid,'approved','Distinct synthetic validation');
$steps['reinforcement'] = $review->balance($budget);
budgetQaAssert($steps['reinforcement']['available_minor']===85000,'Reinforcement differs.');
budgetQaSelect($mode,$f);
$foreign = budgetQaFixture($mode);
budgetQaCreate($mode,$foreign);
budgetQaSelect($mode,$f);
budgetQaReject(fn()=>budgetQaEngine($mode,$f)->balance($budget+1),'foreign allocation');
if ($mode === 'sgf') {
budgetQaReject(fn()=>app(\App\Modules\Purchasing\BudgetAdapter::class)->record(budgetQaSelect($mode,$f),$foreign['origin'],'reserve',100,100),'foreign mapped source');
budgetQaReject(fn()=>app(\App\Shared\Context\CompanyContext::class)->selectFor($f['actor'],$f['tenant'],$f['company'],$f['unit']-1),'reserved unit');
$stale = budgetQaEngine($mode,$f);
budgetQaSelect($mode,$foreign);
budgetQaReject(fn()=>$stale->balance($budget),'stale context');
}
// Release is allowed once before commitment.
$release = budgetQaFixture($mode); $releaseBudget = budgetQaCreate($mode,$release,10000);
budgetQaRecord($mode,$release,'reserve',4000);
budgetQaRecord($mode,$release,'release'); budgetQaRecord($mode,$release,'release');
budgetQaAssert(budgetQaEngine($mode,$release)->balance($releaseBudget)['available_minor']===10000,'Release differs.');
// The below-tax basis is preserved, without a document recalculation.
$net = budgetQaFixture($mode); $netBudget = budgetQaCreate($mode,$net,10000,false);
budgetQaEngine($mode,$net)->record($net['origin'],$net['centre'],'operating','reserve',11600,10000);
budgetQaAssert(budgetQaEngine($mode,$net)->balance($netBudget)['reserved_minor']===10000,'Net basis differs.');
// Failed audit must roll back the ledger and make retry safe.
$failure = budgetQaFixture($mode); $failureBudget = budgetQaCreate($mode,$failure,10000);
$auditTable = $mode==='compras'?'audit_logs':'cb_audit';
DB::unprepared("CREATE FUNCTION budget_qa_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='budget.reserve' THEN RAISE EXCEPTION 'QA_AUDIT_FAILURE'; END IF; RETURN NEW; END $$; CREATE TRIGGER budget_qa_fail BEFORE INSERT ON {$auditTable} FOR EACH ROW EXECUTE FUNCTION budget_qa_fail()");
try { budgetQaRecord($mode,$failure,'reserve',4000); throw new RuntimeException('Audit fault was not observed.'); }
catch (\Illuminate\Database\QueryException $expected) { budgetQaAssert(str_contains($expected->getMessage(),'QA_AUDIT_FAILURE'),'Wrong injected failure.'); }
DB::unprepared("DROP TRIGGER budget_qa_fail ON {$auditTable}; DROP FUNCTION budget_qa_fail()");
budgetQaAssert(budgetQaEngine($mode,$failure)->balance($failureBudget)['available_minor']===10000,'Fault left an orphan reservation.');
budgetQaRecord($mode,$failure,'reserve',4000); budgetQaRecord($mode,$failure,'reserve',4000);
budgetQaAssert(budgetQaEngine($mode,$failure)->balance($failureBudget)['reserved_minor']===4000,'Recovery duplicated effect.');
// Revalidate host permission even when the command is an idempotent replay.
if ($mode==='compras') DB::table('users')->where('id',$f['validator'])->update(['purchasing_role'=>'warehouse']);
else DB::table('permission_grants')->where('user_id',$f['validator'])->where('permission','purchasing_budget.reinforcement.review')->delete();
budgetQaReject(fn()=>budgetQaEngine($mode,$f,'validator')->reviewReinforcement($rid,'approved','Replay after revocation'),'revoked reviewer replay');
$races=[];
$monitorConfig=DB::connection()->getConfig();
config(['database.connections.budget_monitor'=>$monitorConfig]);
$monitor=DB::connection('budget_monitor');
foreach (['over_reserve','same_reserve','same_commit','same_release'] as $scenario) {
$r = budgetQaFixture($mode); $rb = budgetQaCreate($mode,$r,10000); $second=$r;
if ($scenario==='over_reserve') {
if ($mode==='compras') {
budgetQaSelect($mode,$r);
$clone=\App\Models\Requisition::findOrFail($r['origin'])->replicate(); $clone->number='REQ-QA-'.bin2hex(random_bytes(6)); $clone->save(); $second['origin']=$clone->id;
} else $second['origin']=DB::table('cb_origins')->insertGetId($r['scope']+['cost_center_id'=>$r['centre'],'required_kind'=>'operating','reference'=>'REQ-RACE-'.bin2hex(random_bytes(6))]);
}
if (in_array($scenario,['same_commit','same_release'],true)) budgetQaRecord($mode,$r,'reserve',6000);
$kind=match($scenario){'same_commit'=>'commit','same_release'=>'release',default=>'reserve'};
$db=DB::connection(); $db->beginTransaction();
$db->table($mode==='compras'?'tenants':'companies')->where('id',$mode==='compras'?$r['tenant']:$r['company'])->lockForUpdate()->firstOrFail();
$processes=[];
try {
foreach ([$r,$second] as $i=>$fixture) {
$p=new Process([PHP_BINARY,__FILE__,$root,$mode,'--worker'],$root);
$p->setInput(json_encode(['fixture'=>$fixture,'label'=>'common_budget_'.$mode.'_'.$i,'kind'=>$kind,'amount'=>6000],JSON_THROW_ON_ERROR));
$p->setTimeout(35); $p->start(); $processes[]=$p;
}
$deadline=microtime(true)+15; $waits=0;
do {
$waits=(int)$monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in (?,?) and wait_event_type='Lock'",[$database,'common_budget_'.$mode.'_0','common_budget_'.$mode.'_1'])->n;
if ($waits===2) break;
foreach($processes as $p) if(!$p->isRunning()) throw new RuntimeException('Race worker exited before overlap: '.$p->getOutput().$p->getErrorOutput());
usleep(20000);
} while(microtime(true)<$deadline);
budgetQaAssert($waits===2,'Two overlapping PostgreSQL lock waits not observed.');
$db->commit(); $results=[];
foreach($processes as $p){$p->wait();budgetQaAssert($p->isSuccessful(),'Race worker failed: '.$p->getOutput().$p->getErrorOutput());$results[]=json_decode($p->getOutput(),true,32,JSON_THROW_ON_ERROR);}
$statuses=array_column($results,'status');sort($statuses);
budgetQaAssert($statuses===($scenario==='over_reserve'?[200,422]:[200,200]),'Race statuses differ.');
budgetQaAssert(count(array_unique(array_column($results,'pid')))===2,'Independent DB workers required.');
$b=budgetQaEngine($mode,$r)->balance($rb);
budgetQaAssert($b['available_minor']===($scenario==='same_release'?10000:4000),'Race duplicated budget effect.');
$races[]=['scenario'=>$scenario,'passed'=>true,'observed_lock_waits'=>$waits,'balance'=>$b];
} finally {
if($db->transactionLevel())$db->rollBack();
foreach($processes as $p)if($p->isRunning())$p->stop();
}
}
$reflection = new ReflectionClass(\Tempo\Finance\Budget\BudgetControl::class);
if($mode==='compras')budgetQaAssert(str_contains(str_replace('\\','/',$reflection->getFileName()),'common-budget-artifact-'),'Standalone did not load the detached package artifact.');
echo json_encode(['host'=>$mode,'passed'=>true,'detached_package_artifact'=>$mode==='compras','core_sha256'=>hash_file('sha256',$reflection->getFileName()),'steps'=>$steps,'races'=>$races,'checks'=>['independent_approval','partial_consumption','same_command_replay','changed_replay_rejected','committed_release_refused','reinforcement_pending_and_approved','tenant_isolation','release_once','net_tax_basis','audit_rollback_and_retry','revoked_reviewer_replay'],'sgf_context_checks'=>$mode==='sgf'?['foreign_source_refused','reserved_unit_refused','stale_context_refused']:[],'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// QA fixture schema only. Never registered as an application migration.
\App\Modules\Purchasing\BudgetAdapter::guard();
foreach (['0001_01_01_000000_create_users_table.php', '2026_10_25_000000_create_identity_foundation.php', '2026_10_25_010000_add_identity_workflow.php'] as $migration) {
(require base_path('database/migrations/'.$migration))->up();
}
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 ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR ((permission IN ('unit.view','unit.edit') OR permission IN ('purchasing_budget.create','purchasing_budget.approve','purchasing_budget.record','purchasing_budget.consume','purchasing_budget.view','purchasing_budget.reinforcement.propose','purchasing_budget.reinforcement.review')) AND unit_id IS NOT NULL))");
$scope = function (Blueprint $t): void {
$t->unsignedBigInteger('tenant_id');
$t->unsignedBigInteger('company_id');
$t->unsignedBigInteger('unit_id');
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
};
Schema::create('cb_centres', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->string('code');
$t->unique(['tenant_id','company_id','unit_id','code']);
});
Schema::create('cb_origins', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('cost_center_id')->constrained('cb_centres');
$t->string('required_kind'); $t->string('reference')->unique();
});
Schema::create('cb_budgets', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('cost_center_id')->constrained('cb_centres');
$t->integer('year'); $t->string('kind')->default('operating'); $t->bigInteger('amount_minor');
$t->boolean('includes_tax'); $t->text('reason');
$t->foreignId('created_by')->constrained('users'); $t->foreignId('approved_by')->nullable()->constrained('users');
$t->timestamp('approved_at')->nullable(); $t->timestamps();
$t->unique(['tenant_id','company_id','unit_id','cost_center_id','year']);
});
Schema::create('cb_budget_entries', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('budget_id')->constrained('cb_budgets');
$t->foreignId('requisition_id')->constrained('cb_origins'); $t->foreignId('created_by')->constrained('users');
$t->string('kind'); $t->bigInteger('reserved_minor'); $t->bigInteger('committed_minor'); $t->timestamp('created_at');
$t->unique(['requisition_id','kind']);
});
Schema::create('cb_budget_consumptions', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('budget_id')->constrained('cb_budgets');
// Only a synthetic source identifier, not a payable or invoice implementation.
$t->unsignedBigInteger('invoice_id')->unique(); $t->foreignId('created_by')->constrained('users');
$t->bigInteger('amount_minor'); $t->timestamp('created_at');
});
Schema::create('cb_budget_reinforcements', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('budget_id')->constrained('cb_budgets');
$t->foreignId('created_by')->constrained('users'); $t->foreignId('reviewed_by')->nullable()->constrained('users');
$t->string('reference'); $t->string('payload_hash',64); $t->bigInteger('amount_minor'); $t->text('reason');
$t->string('status')->default('pending'); $t->text('review_reason')->nullable(); $t->timestamp('reviewed_at')->nullable(); $t->timestamps();
$t->unique(['tenant_id','company_id','unit_id','reference']);
});
Schema::create('cb_audit', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('actor_id')->constrained('users'); $t->string('action');
$t->unsignedBigInteger('origin_id')->nullable(); $t->jsonb('payload'); $t->timestamp('created_at');
});