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
+11
View File
@@ -0,0 +1,11 @@
# Shared budget control — COMMON-BUDGET-SYNTHETIC-v1
Canonical source: this directory. PHP 8.4+, Illuminate Database 13 as supplied by each host. No Brick Math dependency, new vendor packages or version changes. Monetary inputs are exact existing integer MZN cents; document tax/quantity calculations remain in their current host workflows.
`BudgetContext` explicitly carries actor, organisation scope, fiscal reference year and supported currency. `BudgetControl` is the single writer for allocations, approvals, reinforcements, reservation/award/commit/release and invoice budget consumption. Host callbacks acquire the organisation lock, revalidate identity/permissions, audit within the same transaction and check proposer eligibility. No call to auth/session/global DB facade occurs in the package.
Compras uses its existing tables and scope `tenant_id` (company); SGF QA uses separate `cb_` tables scoped by tenant/company/unit with mapped centres/origins. No automatic identifier conversion. There are no sales, payable, payment, industrial budget or remote SGF dependencies.
Load `bootstrap.php` after the host's existing vendor autoloader. For local Compras development the provider locates this source tree. A standalone deployment installs the same versioned package artifact under `packages/budget-control`, or sets `COMMON_BUDGET_PATH` to its installed artifact; the local sibling fallback is disabled in production. No second maintained source copy is created. QA records the loaded source hash in both booted Laravel hosts.
Existing tables/constraints remain valid in Compras; no live migration is run. SGF schema/rights are QA-only in this increment. SGF operational activation, full procurement UI installation and customer data migration remain pending. See the delivery report for evidence and limitations.
+6
View File
@@ -0,0 +1,6 @@
<?php
// One source tree, loaded by each composition; no Composer or Brick Math replacement.
require_once __DIR__.'/src/BudgetContext.php';
require_once __DIR__.'/src/BudgetFailure.php';
require_once __DIR__.'/src/BudgetControl.php';
@@ -0,0 +1,19 @@
<?php
namespace Tempo\Finance\Budget;
final readonly class BudgetContext
{
/** @param array<string, int> $scope Explicit host identifiers, never inferred from each other. */
public function __construct(public int $actorId, public array $scope, public int $year, public string $currency)
{
if ($actorId < 1 || $scope === [] || $year < 2026 || $currency !== 'MZN') {
throw new \InvalidArgumentException('Contexto orçamental ou moeda não suportados.');
}
foreach ($scope as $column => $id) {
if (! in_array($column, ['tenant_id', 'company_id', 'unit_id'], true) || ! is_int($id) || $id < 1) {
throw new \InvalidArgumentException('Âmbito explícito inválido.');
}
}
}
}
@@ -0,0 +1,291 @@
<?php
namespace Tempo\Finance\Budget;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
/**
* Single writer of allocation, reinforcement and procurement budget effects.
* Hosts supply authentication/authorisation and organisation locks, not financial rules.
* All money is the existing exact integer minor unit; no tax/price rounding here.
*/
final class BudgetControl
{
public const VERSION = 'COMMON-BUDGET-SYNTHETIC-v1';
public const MAX_MINOR = 100000000000000;
public function __construct(
private ConnectionInterface $db,
public readonly BudgetContext $context,
private string $prefix,
private \Closure $lockAndAuthorize,
private \Closure $audit,
private \Closure $eligibleAuthor,
) {
if (! preg_match('/^[a-z_]*$/D', $prefix)) {
throw new \InvalidArgumentException('Invalid budget table prefix.');
}
}
private function query(string $table): Builder
{
return $this->db->table($this->prefix.$table)->where($this->context->scope);
}
private function atomic(string $action, callable $work): mixed
{
return $this->db->transaction(function () use ($action, $work) {
($this->lockAndAuthorize)($action, $this->context);
return $work();
});
}
private function fail(string $message, string $field = 'budget', int $status = 422): never
{
throw new BudgetFailure($message, $field, $status);
}
private function allocation(int $id): object
{
return $this->query('budgets')->where('id', $id)->lockForUpdate()->first()
?? $this->fail('Dotação fora do âmbito autorizado.', status: 403);
}
private function stamp(): string
{
return date('Y-m-d H:i:s');
}
private function emit(string $action, array $data, ?int $origin = null): void
{
($this->audit)($action, $data, $origin, $this->context);
}
public static function approvedTotal(int $initial, int $reinforcements): int
{
$sum = $initial + $reinforcements;
if (! is_int($sum)) {
throw new \OverflowException('Montante orçamental não representável.');
}
return $sum;
}
private function total(object $budget): int
{
return self::approvedTotal((int) $budget->amount_minor, (int) $this->query('budget_reinforcements')->where('budget_id', $budget->id)->where('status', 'approved')->sum('amount_minor'));
}
/** Host validates the cost centre and input format; original integer amount is preserved. */
public function create(array $data): int
{
return $this->atomic('create', function () use ($data) {
$fields = ['cost_center_id', 'year', 'kind', 'includes_tax', 'amount_minor', 'reason'];
if (array_diff(array_keys($data), $fields) !== [] || array_diff($fields, array_keys($data)) !== []
|| ! is_int($data['cost_center_id']) || $data['cost_center_id'] < 1
|| ! is_int($data['year']) || $data['year'] < 2026 || $data['year'] > 2100
|| ! is_bool($data['includes_tax']) || ! is_string($data['reason']) || trim($data['reason']) === '') {
$this->fail('Dados de dotação inválidos ou campos protegidos.');
}
if ($this->query('budgets')->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists()) {
$this->fail('Já existe dotação para este centro e exercício.', status: 409);
}
if (! is_int($data['amount_minor']) || $data['amount_minor'] < 0 || $data['amount_minor'] > self::MAX_MINOR || ! in_array($data['kind'], ['operating', 'investment'], true)) {
$this->fail('Dotação não representável.');
}
$id = $this->query('budgets')->insertGetId($this->context->scope + $data + ['created_by' => $this->context->actorId, 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.created', (array) $this->allocation($id));
return $id;
});
}
public function approve(int $id): void
{
$this->atomic('approve', function () use ($id) {
$budget = $this->allocation($id);
if ((int) $budget->created_by === $this->context->actorId) {
$this->fail('Aprovação exige identidade distinta.', status: 403);
}
if ($budget->approved_by) {
return;
}
$this->query('budgets')->where('id', $id)->update(['approved_by' => $this->context->actorId, 'approved_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.approved', (array) $this->allocation($id));
});
}
/** @return array<string, int>|null */
public function record(int $origin, int $centre, string $requiredKind, string $kind, int $amount = 0, ?int $subtotal = null): ?array
{
return $this->atomic('record', function () use ($origin, $centre, $requiredKind, $kind, $amount, $subtotal) {
if (! in_array($kind, ['reserve', 'award', 'commit', 'release'], true) || $amount < 0) {
$this->fail('Movimento orçamental inválido.');
}
$own = $this->query('budget_entries')->where('requisition_id', $origin);
$first = (clone $own)->orderBy('id')->first();
$budget = $first ? $this->allocation((int) $first->budget_id) : $this->query('budgets')->where('cost_center_id', $centre)->where('year', $this->context->year)->lockForUpdate()->first();
// Exact legacy behaviour: unconfigured development documents are explicitly uncontrolled.
if (! $budget) {
return null;
}
if ($budget->kind !== $requiredKind) {
$this->fail('A dotação deve ter a classificação explícita adequada: investimento para processos com equipamento; corrente para os restantes.');
}
if (! $budget->approved_by || ($kind !== 'release' && (int) $budget->year !== $this->context->year)) {
$this->fail('Dotação pendente de aprovação ou exercício encerrado.');
}
if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'], true)) {
$amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
}
$result = ['budget_id' => (int) $budget->id, 'year' => (int) $budget->year];
$old = (clone $own)->where('kind', $kind)->first();
if ($old) {
// Reconstruct original target before this entry, including historical rows without hashes.
$before = (int) (clone $own)->where('id', '<', $old->id)->sum('reserved_minor');
$target = $kind === 'commit' ? (int) $old->committed_minor : $before + (int) $old->reserved_minor;
if ($kind !== 'release' && $target !== $amount) {
$this->fail('Origem já registada com outro montante; não é criado segundo efeito.');
}
return $result;
}
$reserved = (int) (clone $own)->sum('reserved_minor');
$committed = (int) (clone $own)->sum('committed_minor');
if ($committed !== 0 || ($kind !== 'reserve' && ! $first)) {
$this->fail('Processo sem reserva válida ou já comprometido.');
}
[$rd, $cd] = match ($kind) {
'reserve', 'award' => [$amount - $reserved, 0],
'release' => [-$reserved, 0],
'commit' => [-$reserved, $amount],
};
$balance = $this->balanceOf($budget);
if ($balance['available_minor'] < $rd + $cd) {
$this->fail('Saldo orçamental insuficiente. Não são permitidos excessos.');
}
$this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => $kind, 'reserved_minor' => $rd, 'committed_minor' => $cd, 'created_at' => $this->stamp()]);
$this->emit('budget.'.$kind, ['budget_id' => $budget->id, 'reserved_minor' => $rd, 'committed_minor' => $cd], $origin);
return $result;
});
}
/** Only budget consumption: does not create a payable, invoice, payment or accounting entry. */
public function consume(int $origin, int $invoice, int $total, int $subtotal): array
{
return $this->atomic('consume', function () use ($origin, $invoice, $total, $subtotal) {
$own = $this->query('budget_entries')->where('requisition_id', $origin);
$commit = (clone $own)->where('kind', 'commit')->first();
if (! $commit) {
$this->fail('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.', 'invoice');
}
$budget = $this->allocation((int) $commit->budget_id);
if (! $budget->approved_by || (int) $budget->year !== $this->context->year) {
$this->fail('Orçamento não aprovado ou fora do exercício.', 'invoice');
}
$amount = (int) ($budget->includes_tax ? $total : $subtotal);
$old = $this->query('budget_consumptions')->where('invoice_id', $invoice)->first();
$result = ['budget_id' => (int) $budget->id, 'consumed_minor' => $amount];
if ($old) {
$entry = (clone $own)->where('kind', 'invoice-'.$invoice)->first();
if ((int) $old->budget_id !== (int) $budget->id || (int) $old->amount_minor !== $amount || ! $entry || (int) $entry->committed_minor !== -$amount) {
$this->fail('Factura já consumida com outra origem ou montante.', 'invoice');
}
return $result;
}
if ($amount < 0 || $amount > (int) (clone $own)->sum('committed_minor')) {
$this->fail('Compromisso insuficiente para a factura.', 'invoice');
}
$this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => 'invoice-'.$invoice, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => $this->stamp()]);
$this->query('budget_consumptions')->insert($this->context->scope + ['budget_id' => $budget->id, 'invoice_id' => $invoice, 'created_by' => $this->context->actorId, 'amount_minor' => $amount, 'created_at' => $this->stamp()]);
$this->emit('budget.consumed', $result + ['invoice_id' => $invoice], $origin);
return $result;
});
}
private function validReinforcement(object $budget, int $amount): void
{
if (! $budget->approved_by || (int) $budget->year !== $this->context->year || $amount <= 0) {
$this->fail('Exige dotação aprovada do exercício actual e montante positivo.', 'reinforcement');
}
if ($amount > self::MAX_MINOR - $this->total($budget)) {
$this->fail('A dotação acumulada ultrapassa o limite técnico de valores suportados.', 'reinforcement');
}
}
public function proposeReinforcement(int $id, int $amount, string $reference, string $reason): int
{
return $this->atomic('reinforcement.propose', function () use ($id, $amount, $reference, $reason) {
$budget = $this->allocation($id);
$hash = hash('sha256', json_encode(['budget_id' => $id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
$old = $this->query('budget_reinforcements')->where('reference', $reference)->first();
if ($old) {
if ((int) $old->created_by !== $this->context->actorId || $old->payload_hash !== $hash) {
$this->fail('Referência já registada com outros dados.', 'reference');
}
return (int) $old->id;
}
$this->validReinforcement($budget, $amount);
if ($reference === '' || $reason === '') {
$this->fail('Referência e fundamento obrigatórios.', 'reinforcement');
}
$rid = $this->query('budget_reinforcements')->insertGetId($this->context->scope + ['budget_id' => $id, 'created_by' => $this->context->actorId, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason, 'status' => 'pending', 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.reinforcement_proposed', ['reinforcement_id' => $rid, 'budget_id' => $id, 'amount_minor' => $amount, 'status' => 'pending']);
return $rid;
});
}
public function reviewReinforcement(int $id, string $decision, string $reason): void
{
$this->atomic('reinforcement.review', function () use ($id, $decision, $reason) {
$r = $this->query('budget_reinforcements')->where('id', $id)->first() ?? $this->fail('Reforço fora do âmbito.', status: 403);
$budget = $this->allocation((int) $r->budget_id);
$r = $this->query('budget_reinforcements')->where('id', $id)->lockForUpdate()->first();
if ((int) $r->created_by === $this->context->actorId) {
$this->fail('Aprovação exige identidade distinta.', status: 403);
}
if (! in_array($decision, ['approved', 'rejected'], true) || trim($reason) === '') {
$this->fail('Decisão e fundamento obrigatórios.', 'reinforcement');
}
if ($r->status !== 'pending') {
if ($r->status !== $decision) {
$this->fail('Decisão final imutável.', 'reinforcement');
}
return;
}
if ($decision === 'approved') {
$this->validReinforcement($budget, (int) $r->amount_minor);
if (! ($this->eligibleAuthor)((int) $r->created_by, $this->context)) {
$this->fail('Autor da proposta deixou de ser elegível.', 'reinforcement');
}
}
$this->query('budget_reinforcements')->where('id', $id)->update(['status' => $decision, 'reviewed_by' => $this->context->actorId, 'review_reason' => trim($reason), 'reviewed_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.reinforcement_'.$decision, ['reinforcement_id' => $id, 'budget_id' => (int) $r->budget_id, 'amount_minor' => (int) $r->amount_minor, 'status' => $decision]);
});
}
private function balanceOf(object $budget): array
{
$entries = $this->query('budget_entries')->where('budget_id', $budget->id);
$reserved = (int) (clone $entries)->sum('reserved_minor');
$committed = (int) (clone $entries)->sum('committed_minor');
$consumed = (int) $this->query('budget_consumptions')->where('budget_id', $budget->id)->sum('amount_minor');
$total = $this->total($budget);
return ['approved_total_minor' => $budget->approved_by ? $total : 0, 'reinforced_minor' => $total - (int) $budget->amount_minor, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0];
}
public function balance(int $id): array
{
return $this->atomic('view', fn () => $this->balanceOf($this->allocation($id)));
}
}
@@ -0,0 +1,11 @@
<?php
namespace Tempo\Finance\Budget;
final class BudgetFailure extends \DomainException
{
public function __construct(string $message, public readonly string $field = 'budget', public readonly int $status = 422)
{
parent::__construct($message);
}
}
@@ -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');
});