Preserve shared budget adapter and limited technical acceptance

This commit is contained in:
2026-10-29 13:25:06 +02:00
parent 3b1689ca0f
commit 57f6b3cc36
11 changed files with 164 additions and 172 deletions
+8 -21
View File
@@ -4,13 +4,11 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\BudgetReinforcement;
use App\Models\CostCenter;
use App\Services\CommonBudget;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -30,15 +28,10 @@ class BudgetController extends Controller
{
$this->access($request);
return Inertia::render('budgets/index', ['centers' => CostCenter::query()->orderBy('code')->get(), 'budgets' => Budget::query()->latest('id')->get()->map(function (Budget $budget) {
$entries = BudgetEntry::query()->where('budget_id', $budget->id);
$reserved = (int) (clone $entries)->sum('reserved_minor');
$committed = (int) $entries->sum('committed_minor');
$consumed = (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
return Inertia::render('budgets/index', ['centers' => CostCenter::query()->orderBy('code')->get(), 'budgets' => Budget::query()->latest('id')->get()->map(function (Budget $budget) use ($request) {
$adapter = app(CommonBudget::class);
$total = $budget->approvedTotalMinor();
return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $budget->approved_by ? $total : 0, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0];
return [...$budget->toArray(), ...$adapter->execute(fn () => $adapter->for($request->user())->balance($budget->id))];
}), 'reinforcements' => BudgetReinforcement::query()->latest('id')->paginate(20)]);
}
@@ -50,9 +43,8 @@ class BudgetController extends Controller
DB::transaction(function () use ($request, $data, $kind) {
app(ConflictGuard::class)->lockCompany($request->user());
app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]);
abort_if(Budget::query()->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists(), 409, 'Já existe dotação para este centro e exercício.');
$budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'kind' => $kind, 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]);
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray());
$adapter = app(CommonBudget::class);
$adapter->execute(fn () => $adapter->for($request->user())->create(['cost_center_id' => (int) $data['cost_center_id'], 'year' => (int) $data['year'], 'kind' => $kind, 'includes_tax' => (bool) $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]));
});
return back();
@@ -64,13 +56,8 @@ class BudgetController extends Controller
DB::transaction(function () use ($request, $budget) {
app(ConflictGuard::class)->lockCompany($request->user());
app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]);
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
abort_if($budget->created_by === $request->user()->id, 403);
if ($budget->approved_by) {
return;
}
$budget->update(['approved_by' => $request->user()->id, 'approved_at' => now()]);
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.approved', null, null, $budget->toArray());
$adapter = app(CommonBudget::class);
$adapter->execute(fn () => $adapter->for($request->user())->approve($budget->id));
});
return back();
+2 -2
View File
@@ -3,8 +3,8 @@
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Brick\Math\BigDecimal;
use Illuminate\Database\Eloquent\Model;
use Tempo\Finance\Budget\BudgetControl;
/** @property int $id
* @property int $amount_minor
@@ -22,7 +22,7 @@ class Budget extends Model
{
$sum = BudgetReinforcement::query()->where('budget_id', $this->id)->where('status', 'approved')->sum('amount_minor');
return BigDecimal::of((string) $sum)->plus($this->amount_minor)->toInt();
return BudgetControl::approvedTotal((int) $this->amount_minor, (int) $sum);
}
protected static function booted(): void
+5 -1
View File
@@ -15,7 +15,11 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$package = config('common-budget.path');
if (! is_file($package.'/bootstrap.php') && ! $this->app->environment('production')) {
$package = base_path('../sgf-tempo/packages/budget-control');
}
require_once $package.'/bootstrap.php';
}
/**
+8 -79
View File
@@ -2,100 +2,29 @@
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetReinforcement;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class BudgetReinforcementWorkflow
{
private function actor(User $user, int $tenantId): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
}
private function valid(Budget $budget, int $amount): void
{
if (! $budget->approved_by || (int) $budget->year !== now()->year || $amount <= 0) {
throw ValidationException::withMessages(['reinforcement' => 'Exige dotação aprovada do exercício actual e montante positivo.']);
}
// Same technical monetary ceiling as requisitions; not a company approval threshold.
if (BigDecimal::of($budget->approvedTotalMinor())->plus($amount)->isGreaterThan('100000000000000')) {
throw ValidationException::withMessages(['reinforcement' => 'A dotação acumulada ultrapassa o limite técnico de valores suportados.']);
}
}
/** @param array<string,mixed> $data */
public function propose(User $user, Budget $budget, array $data): BudgetReinforcement
{
$this->actor($user, $budget->tenant_id);
abort_unless($user->tenant_id === $budget->tenant_id, 403);
$adapter = app(CommonBudget::class);
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
$id = $adapter->execute(fn () => $adapter->for($user)->proposeReinforcement($budget->id, $amount, Str::upper(Str::squish($data['reference'])), trim($data['reason'])));
return DB::transaction(function () use ($user, $budget, $data) {
app(ConflictGuard::class)->lockCompany($user);
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
$this->actor($user, $budget->tenant_id);
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
$reference = Str::upper(Str::squish($data['reference']));
$reason = trim($data['reason']);
$hash = hash('sha256', json_encode(['budget_id' => $budget->id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
$existing = BudgetReinforcement::query()->where('reference', $reference)->first();
if ($existing) {
if ($existing->created_by !== $user->id || $existing->payload_hash !== $hash) {
throw ValidationException::withMessages(['reference' => 'Referência já registada com outros dados.']);
}
return $existing;
}
$this->valid($budget, $amount);
if ($reference === '' || $reason === '') {
throw ValidationException::withMessages(['reinforcement' => 'Referência e fundamento obrigatórios.']);
}
$reinforcement = BudgetReinforcement::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'created_by' => $user->id, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason]);
$this->audit($user, $reinforcement, 'budget.reinforcement_proposed');
return $reinforcement;
});
return BudgetReinforcement::query()->findOrFail($id);
}
public function review(User $user, BudgetReinforcement $reinforcement, string $decision, string $reason): void
{
$this->actor($user, $reinforcement->tenant_id);
abort_if($user->id === $reinforcement->created_by, 403);
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
throw ValidationException::withMessages(['reinforcement' => 'Decisão e fundamento obrigatórios.']);
}
DB::transaction(function () use ($user, $reinforcement, $decision, $reason) {
app(ConflictGuard::class)->lockCompany($user);
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
$this->actor($user, $reinforcement->tenant_id);
$budget = Budget::query()->lockForUpdate()->findOrFail($reinforcement->budget_id);
$reinforcement = BudgetReinforcement::query()->lockForUpdate()->findOrFail($reinforcement->id);
if ($reinforcement->status !== 'pending') {
if ($reinforcement->status !== $decision) {
throw ValidationException::withMessages(['reinforcement' => 'Decisão final imutável.']);
}
return;
}
if ($decision === 'approved') {
$this->valid($budget, $reinforcement->amount_minor);
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($reinforcement->created_by);
if (! $author || ! in_array($author->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
throw ValidationException::withMessages(['reinforcement' => 'Autor da proposta deixou de ser elegível.']);
}
}
$reinforcement->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => trim($reason), 'reviewed_at' => now()]);
$this->audit($user, $reinforcement, 'budget.reinforcement_'.$decision);
});
}
private function audit(User $user, BudgetReinforcement $reinforcement, string $action): void
{
app(RequisitionWorkflow::class)->audit($user, $action, null, null, ['reinforcement_id' => $reinforcement->id, 'budget_id' => $reinforcement->budget_id, 'amount_minor' => $reinforcement->amount_minor, 'status' => $reinforcement->status]);
abort_unless($user->tenant_id === $reinforcement->tenant_id, 403);
$adapter = app(CommonBudget::class);
$adapter->execute(fn () => $adapter->for($user)->reviewReinforcement($reinforcement->id, $decision, $reason));
}
}
+7 -50
View File
@@ -2,68 +2,25 @@
namespace App\Services;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class BudgetWorkflow
{
/** Called inside the procurement transaction, after the company lock.
* @return array<string,int>|null
*/
/** @return array<string,int>|null */
public function record(User $user, Requisition $request, string $kind, int $amount = 0, ?int $subtotal = null): ?array
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $request->tenant_id === $user->tenant_id, 403);
if (DB::connection()->transactionLevel() === 0) {
throw new \LogicException('Movimentos de orçamento exigem transacção.');
}
app(ConflictGuard::class)->lockCompany($user);
$existing = BudgetEntry::query()->where('requisition_id', $request->id)->orderBy('id')->first();
$budget = $existing
? Budget::query()->lockForUpdate()->findOrFail($existing->budget_id)
: Budget::query()->where('cost_center_id', $request->cost_center_id)->where('year', now()->year)->lockForUpdate()->first();
// Unconfigured legacy processes remain development documents, explicitly unchecked.
if (! $budget) {
return null;
}
$requiredKind = $request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating';
if ($budget->kind !== $requiredKind) {
throw ValidationException::withMessages(['budget' => '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 !== now()->year)) {
throw ValidationException::withMessages(['budget' => 'Dotação pendente de aprovação ou exercício encerrado.']);
}
if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'])) {
$amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
}
if (BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', $kind)->exists()) {
return ['budget_id' => $budget->id, 'year' => (int) $budget->year];
}
$own = BudgetEntry::query()->where('requisition_id', $request->id);
$reserved = (int) (clone $own)->sum('reserved_minor');
$committed = (int) $own->sum('committed_minor');
if ($committed !== 0 || ($kind !== 'reserve' && ! $existing)) {
throw ValidationException::withMessages(['budget' => 'Processo sem reserva válida ou já comprometido.']);
}
[$reserveDelta, $commitDelta] = match ($kind) {
'reserve', 'award' => [$amount - $reserved, 0],
'release' => [-$reserved, 0],
'commit' => [-$reserved, $amount],
default => throw new \LogicException('Movimento orçamental inválido.'),
};
$ledger = BudgetEntry::query()->where('budget_id', $budget->id);
$used = (int) (clone $ledger)->sum('reserved_minor') + (int) $ledger->sum('committed_minor');
$used += (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
if ($used + $reserveDelta + $commitDelta > $budget->approvedTotalMinor()) {
throw ValidationException::withMessages(['budget' => 'Saldo orçamental insuficiente. Não são permitidos excessos.']);
}
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => $kind, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($user, 'budget.'.$kind, $request, null, ['budget_id' => $budget->id, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta]);
$adapter = app(CommonBudget::class);
return ['budget_id' => $budget->id, 'year' => (int) $budget->year];
return $adapter->execute(fn () => $adapter->for($user)->record(
$request->id, $request->cost_center_id,
$request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating',
$kind, $amount, $subtotal,
));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Tempo\Finance\Budget\BudgetContext;
use Tempo\Finance\Budget\BudgetControl;
use Tempo\Finance\Budget\BudgetFailure;
/** Local host adapter: auth, tenant lock and audit; no budget arithmetic. */
final class CommonBudget
{
public function for(User $user): BudgetControl
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null, 403);
abort_unless(DB::table('tenants')->where('id', $user->tenant_id)->value('currency') === 'MZN', 422, 'Controlo comum desta entrega suporta apenas MZN; câmbio permanece pendente.');
$context = new BudgetContext($user->id, ['tenant_id' => (int) $user->tenant_id], (int) now()->year, 'MZN');
return new BudgetControl(DB::connection(), $context, '',
function (string $action) use ($user): void {
app(ConflictGuard::class)->lockCompany($user);
abort_unless(DB::table('tenants')->where('id', $user->tenant_id)->value('currency') === 'MZN', 422, 'Moeda da organização incompatível com esta entrega.');
$roles = in_array($action, ['create', 'approve', 'reinforcement.propose', 'reinforcement.review', 'view'], true)
? [PurchasingRole::Admin, PurchasingRole::Finance]
: ($action === 'consume' ? [PurchasingRole::Finance] : null);
app(CurrentActor::class)->lock($user, $roles);
},
function (string $action, array $data, ?int $origin) use ($user): void {
// InvoiceWorkflow retains invoice.validated in the enclosing transaction.
if ($action !== 'budget.consumed') {
$request = $origin ? Requisition::query()->findOrFail($origin) : null;
app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data);
}
},
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(),
);
}
/**
* @template T
*
* @param callable(): T $command
* @return T
*/
public function execute(callable $command): mixed
{
try {
return $command();
} catch (BudgetFailure $e) {
abort_if($e->status !== 422, $e->status, $e->getMessage());
throw ValidationException::withMessages([$e->field => $e->getMessage()]);
}
}
}
+3 -19
View File
@@ -3,9 +3,6 @@
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\GoodsReceiptItem;
use App\Models\Invoice;
use App\Models\InvoiceItem;
@@ -130,23 +127,10 @@ class InvoiceWorkflow
$this->invalid('Conferência alterada por outra factura; a validação está bloqueada.');
}
}
$commit = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'commit')->first();
if (! $commit) {
$this->invalid('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.');
}
$budget = Budget::query()->lockForUpdate()->findOrFail($commit->budget_id);
if (! $budget->approved_by || (int) $budget->year !== now()->year) {
$this->invalid('Orçamento não aprovado ou fora do exercício.');
}
$amount = (int) ($budget->includes_tax ? $invoice->total_minor : $invoice->subtotal_minor);
$remaining = (int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor');
if ($amount > $remaining) {
$this->invalid('Compromisso insuficiente para a factura.');
}
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => 'invoice-'.$invoice->id, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => now()]);
BudgetConsumption::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'invoice_id' => $invoice->id, 'created_by' => $user->id, 'amount_minor' => $amount, 'created_at' => now()]);
$adapter = app(CommonBudget::class);
$effect = $adapter->execute(fn () => $adapter->for($user)->consume($request->id, $invoice->id, (int) $invoice->total_minor, (int) $invoice->subtotal_minor));
$invoice->update(['status' => 'validated', 'validated_by' => $user->id, 'validated_at' => now()]);
app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id, 'budget_id' => $budget->id, 'consumed_minor' => $amount]);
app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id] + $effect);
});
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'path' => env('COMMON_BUDGET_PATH', base_path('packages/budget-control')),
];
+6
View File
@@ -1,5 +1,11 @@
# Compatibilidade e estado do SGC
**Aceitação posterior, 29/10/2026:** extracção técnica do núcleo comum aceite e checkpoint exclusivamente local autorizado. [Âmbito e pendências](../../sgf-tempo/docs/COMMON_BUDGET_ACCEPTANCE.md): visual Compras, interface do percurso SGF e cobertura MFA permanecem pendentes; nenhuma utilização real habilitada.
**Controlo orçamental comum, 29/10/2026:** dotação/reforços/ledger/consumo agora delegam numa implementação única, com equivalência nos dois hosts sintéticos. Ver [entrega, provas e limitações](../../sgf-tempo/docs/COMMON_BUDGET_DELIVERY.md). Não significa Compras completo instalado no SGF nem activação operacional; restantes estados/aceitações preservados.
**Orientação posterior de 29/10/2026:** um módulo de Compras, um núcleo financeiro comum e duas composições comerciais. Preservar os percursos desta matriz; a independência comercial não exige outro motor financeiro ou SGF remoto. O [plano de referência](../../sgf-tempo/docs/PURCHASING_COMMON_CORE_PLAN.md) mapeia capacidades/efeitos e delimita o primeiro incremento sintético; não declara extracção concluída. O estado funcional mais recente deve ser lido em `validacao-tecnica.md`, incluindo recomendação/000020, posterior a resumos 000017 abaixo.
Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\compras, Laravel + React + PostgreSQL, http://127.0.0.1:8015. Preservar conta, dados, configuração, cookie e porta. SGF Tempo e Investmoz são independentes. A implementação Python separada fica apenas como referência; não importar contas, sessões ou bases experimentais.
| Área | Implementado no Laravel | Pendente |
+12
View File
@@ -1,5 +1,17 @@
# Continuidade do desenvolvimento do SGC
**Aceitação técnica de 29/10/2026:** a Tempo aceitou a extracção `COMMON-BUDGET-SYNTHETIC-v1` e autorizou checkpoint exclusivamente local nos dois repositórios, sem uso real/push/publicação. Ver [aceitação](../../sgf-tempo/docs/COMMON_BUDGET_ACCEPTANCE.md). Revisão visual Compras pendente; SGF apenas adaptador QA sem interface deste percurso; dois testes MFA ignorados continuam pendentes. Esta aceitação substitui apenas a pendência de encerramento técnico abaixo. Próximo passo: percurso sintético integrado SGF, sem bloquear desenvolvimento pela automação do navegador.
**Execução posterior em 29/10/2026:** `COMMON-BUDGET-SYNTHETIC-v1` implementado, com fonte única no pacote SGF, adaptadores locais e duas bases descartáveis. Ver [relatório técnico](../../sgf-tempo/docs/COMMON_BUDGET_DELIVERY.md). Conservar as alterações e os percursos; sem aceitação presumida, checkpoint de encerramento, migração/concessões/dados reais, publicação ou push. Não avançar para obrigações/pagamentos/contabilidade por arrasto.
## Orientação de produto confirmada — 29/10/2026
Decisão expressa da Tempo: um módulo de Compras, um núcleo financeiro comum e duas composições comerciais — ERP SGF com Compras e Compras autónomo com as capacidades comuns necessárias. O cliente de Compras não precisa de adoptar a facturação de vendas SGF nem de um SGF remoto. Esta orientação substitui a proposta de duas aplicações ligadas por conector remoto; as referências históricas à independência do SGF não impedem o núcleo comum. Preservar todos os percursos, dados, contas, decisões, versões, testes e alterações posteriores; não copiar o motor para manter duas implementações.
O desenho de referência está em [Compras e núcleo financeiro comum](../../sgf-tempo/docs/PURCHASING_COMMON_CORE_PLAN.md), no projecto SGF. Inclui mapa de preservação, capacidades existentes/em falta, responsabilidade única pelos efeitos e transição. Primeiro incremento delimitado: `COMMON-BUDGET-SYNTHETIC-v1`, extracção do controlo orçamental existente e prova nos dois hosts sintéticos, sem dependência de Sales. Nesta passagem só documentação; reestruturação segue esse plano. Pessoas reais/concessões empresariais pertencem à implantação, não são dependências para construir/testar. Não executar seeds ou migrações reais por consequência desta orientação.
Para o estado funcional actual, consultar `docs/validacao-tecnica.md`, que já descreve recomendação e migração 000020; o resumo abaixo até 000017 é anterior. A orientação arquitectural não reabre aceitações nem declara novas provas realizadas.
## Orientação actual após abertura e actas — 28 de Outubro de 2026
Referência consolidada: `07e775c`; consultar primeiro
+50
View File
@@ -0,0 +1,50 @@
<?php
// Coordinator: provision disposable databases via maintenance DB, never migrate/read client data.
require __DIR__.'/../../vendor/autoload.php';
foreach (['APP_ENV'=>'budget-coordinator','DB_CONNECTION'=>'pgsql','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array'] as $key=>$value) { putenv($key.'='.$value); $_ENV[$key]=$_SERVER[$key]=$value; }
$app=require __DIR__.'/../../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
$source=DB::connection('pgsql')->getConfig();
if(($source['driver']??'')!=='pgsql'||($source['host']??'')!=='127.0.0.1')throw new LogicException('Local PostgreSQL maintenance connection required.');
$maintenance=[...$source,'database'=>'postgres','url'=>null];
config(['database.connections.budget_admin'=>$maintenance]);
$admin=DB::connection('budget_admin')->getPdo();
$sgf=realpath(base_path('../sgf-tempo'));
$script=$sgf.'/packages/budget-control/tests/run-host.php';
$report=['version'=>'COMMON-BUDGET-SYNTHETIC-v1','checked_at'=>gmdate('c'),'client_databases_touched'=>false,'hosts'=>[]];
$artifact=sys_get_temp_dir().'/common-budget-artifact-'.bin2hex(random_bytes(6));
if(!mkdir($artifact.'/src',0700,true))throw new RuntimeException('Cannot prepare isolated package artifact.');
$artifactFiles=['bootstrap.php','src/BudgetContext.php','src/BudgetFailure.php','src/BudgetControl.php'];
foreach($artifactFiles as $file)if(!copy($sgf.'/packages/budget-control/'.$file,$artifact.'/'.$file))throw new RuntimeException('Cannot build package artifact.');
try {
foreach(['compras','sgf'] as $mode){
$db=($mode==='compras'?'compras_receipts_test_':'budget_core_sgf_').bin2hex(random_bytes(6));
$created=false;
try{
$admin->exec('CREATE DATABASE "'.$db.'"');$created=true;
$environment=['APP_ENV'=>$mode==='compras'?'testing':'budget-testing','DB_CONNECTION'=>'pgsql','DB_DATABASE'=>$db,'DB_URL'=>'','DB_HOST'=>'127.0.0.1','DB_PORT'=>(string)$source['port'],'DB_USERNAME'=>$source['username'],'DB_PASSWORD'=>$source['password'],'CACHE_STORE'=>'array','SESSION_DRIVER'=>'array','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4','COMMON_BUDGET_PATH'=>$mode==='compras'?$artifact:$sgf.'/packages/budget-control'];
$php=$mode==='compras'?PHP_BINARY:'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
$root=$mode==='compras'?base_path():$sgf;
$process=new Process([$php,$script,$root,$mode],$root,$environment);$process->setTimeout(120);$process->run();
if(!$process->isSuccessful())throw new RuntimeException('QA host failed: '.$process->getOutput().$process->getErrorOutput());
$report['hosts'][]=json_decode($process->getOutput(),true,64,JSON_THROW_ON_ERROR);
}finally{
if(isset($process)&&$process->isRunning())$process->stop();
if($created){
$stmt=$admin->prepare('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=? AND pid<>pg_backend_pid()');$stmt->execute([$db]);
$admin->exec('DROP DATABASE "'.$db.'"');
}
}
}
} finally {
// Delete only the exact files created above, never a recursive or computed project path.
foreach($artifactFiles as $file)if(is_file($artifact.'/'.$file))unlink($artifact.'/'.$file);
rmdir($artifact.'/src');rmdir($artifact);
}
if($report['hosts'][0]['core_sha256']!==$report['hosts'][1]['core_sha256']||$report['hosts'][0]['steps']!==$report['hosts'][1]['steps'])throw new RuntimeException('Host equivalence failed.');
$report['equivalent']=true;$report['disposable_databases_dropped']=true;
file_put_contents($sgf.'/docs/proofs/common-budget-hosts.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);
echo "Two Laravel hosts passed the same core sequence and PostgreSQL concurrency checks. Disposable databases dropped.\n";