Implementa reforcos orcamentais com aprovacao independente

This commit is contained in:
2026-10-28 15:03:49 +02:00
parent c41628d738
commit 153ba2c1af
24 changed files with 1023 additions and 25 deletions
+8 -2
View File
@@ -23,6 +23,12 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
## Entrega actual
- Reforços de orçamento por empresa/centro/exercício, com proposta, aprovação
independente e histórico imutável. A dotação inicial e a base de IVA são
preservadas; apenas reforços aprovados aumentam o saldo disponível. Migração
000013 aplicada após backup/restauro verificados; três cenários concorrentes
PostgreSQL aprovados. Exemplo público em `/consulta/reforcos`.
- Registo de pagamentos externos por empresa, com conferência independente,
liquidação parcial/total de facturas e revalidação da versão bancária. Sem
consumo duplicado do orçamento ou execução de transferências. Migração 000012
@@ -46,7 +52,7 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
Migração 000009 aplicada após backup e restauro verificados; cinco cenários
concorrentes PostgreSQL aprovados. Sem dotações fictícias na base real.
Centros sem dotação mantêm o circuito de desenvolvimento sem controlo.
Reforços e estornos continuam pendentes; não constitui validação
Estornos continuam pendentes; não constitui validação
operacional integral do orçamento. Interface em `/orcamentos`.
- Equipamentos e peças ligados aos artigos existentes, compatibilidades, criticidade configurada, mínimos explícitos e alertas, saídas rastreáveis e validação técnica. Finanças é obrigatória para equipamento em qualquer valor; a adjudicação/emissão de investimento continua bloqueada até definição do limite próprio. Migração 000008 aplicada no lote 9 após backup recuperável e verificação da preservação. Demonstração em `/consulta/equipamentos`.
@@ -81,7 +87,7 @@ O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0%
## Próximas etapas
Revisão visual dos módulos entregues; reposição de stock; conciliação de pagamentos externos; reforços orçamentais, correcções de facturas e estornos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; a adjudicação de equipamento aguarda a definição da regra de investimento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir.
Revisão visual dos módulos entregues; reposição de stock; conciliação de pagamentos externos; regras de investimento, correcções de facturas e estornos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; a adjudicação de equipamento aguarda a definição da regra de investimento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir.
O cadastro guarda referências e ficheiros anexos no disco privado `supplier_documents`, em `storage/app/supplier-documents`, fora da pasta pública e sem URLs públicas. As fichas abrem pelo directório **Fornecedores → Ver ficha → Abrir ficha, edição e documentos**. As versões antigas e documentos existentes são preservados na auditoria e no armazenamento; nesta entrega não há eliminação de documentos, antivírus, quotas acumuladas ou política de retenção. A validação do NUIT é de formato e unicidade, sem consulta a uma autoridade fiscal. Além das declarações por operação, existem declarações pessoais por fornecedor e processo; a periodicidade anual e a política de validade/levantamento ainda não foram definidas. As propostas e avaliações registadas não são editáveis nesta etapa. Os pesos 40/20/15/15/10 são provisórios e ficam guardados por proposta. A comparação liga à fase de adjudicação e emissão. A ordem exige novas aprovações do valor final e revalidação no servidor. As propostas usam MZN; a multimoeda fica para a fase correspondente.
+5 -2
View File
@@ -6,6 +6,7 @@ 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\ConflictGuard;
use App\Services\RequisitionWorkflow;
@@ -34,8 +35,10 @@ class BudgetController extends Controller
$committed = (int) $entries->sum('committed_minor');
$consumed = (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
return [...$budget->toArray(), 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->amount_minor - $reserved - $committed - $consumed];
})]);
$total = $budget->approvedTotalMinor();
return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $total, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $total - $reserved - $committed - $consumed];
}), 'reinforcements' => BudgetReinforcement::query()->latest('id')->paginate(20)]);
}
public function store(Request $request): RedirectResponse
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetReinforcement;
use App\Services\BudgetReinforcementWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class BudgetReinforcementController extends Controller
{
public function store(Request $request, Budget $budget, BudgetReinforcementWorkflow $workflow): RedirectResponse
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$data = $request->validate(['reference' => 'required|string|max:100', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $budget, $data);
return back();
}
public function review(Request $request, BudgetReinforcement $reinforcement, BudgetReinforcementWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|max:4000']);
$workflow->review($request->user(), $reinforcement, $data['decision'], $data['reason']);
return back();
}
}
+8
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Brick\Math\BigDecimal;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
@@ -16,6 +17,13 @@ class Budget extends Model
protected $guarded = ['id'];
public function approvedTotalMinor(): int
{
$sum = BudgetReinforcement::query()->where('budget_id', $this->id)->where('status', 'approved')->sum('amount_minor');
return BigDecimal::of((string) $sum)->plus($this->amount_minor)->toInt();
}
protected static function booted(): void
{
static::updating(function (Budget $budget) {
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $budget_id
* @property int $created_by
* @property int $amount_minor
* @property string $status
* @property string $payload_hash
*/
class BudgetReinforcement extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'pending'];
protected $hidden = ['payload_hash'];
protected static function booted(): void
{
static::updating(function (BudgetReinforcement $reinforcement) {
if ($reinforcement->getRawOriginal('status') !== 'pending' || array_diff(array_keys($reinforcement->getDirty()), ['status', 'reviewed_by', 'review_reason', 'reviewed_at', 'updated_at']) !== []) {
throw new \LogicException('Preservar o reforço e a decisão.');
}
});
static::deleting(fn () => throw new \LogicException('Não eliminar reforços orçamentais.'));
}
}
@@ -0,0 +1,101 @@
<?php
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);
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;
});
}
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]);
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ class BudgetWorkflow
$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->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()]);
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('budget_reinforcements', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('budget_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
$table->string('reference', 100);
$table->string('payload_hash', 64);
$table->bigInteger('amount_minor');
$table->text('reason');
$table->string('status')->default('pending');
$table->text('review_reason')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'reference']);
});
}
public function down(): void
{
throw new LogicException('Preservar dotações, reforços e decisões.');
}
};
+25 -16
View File
@@ -2,27 +2,36 @@
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 |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Acesso e organização | Fortify, conta, empresa, equipa, departamentos e centros de custo | MFA obrigatório e ciclo completo de acessos |
| Requisições e aprovações | Rascunhos, cálculos exactos, etapas, segregação e snapshots | Validação empresarial das regras provisórias |
| Fornecedores | Cadastro, anexos privados e versões bancárias com duas validações | Múltiplas contas e verificação bancária externa |
| Cotações | Propostas, avaliações e matriz comparativa | Multimoeda e revisão das propostas |
| RF-AF-01 | Declarações pessoais, histórico e ConflictGuard no servidor | Política de periodicidade, validade e levantamento |
| Adjudicação | Escolha fundamentada, revalidação e aprovação do valor final | Excepções, revisão e cancelamento sem política ficam bloqueados |
| Ordens | Emissão única/idempotente, snapshots, consulta e impressão | Orçamento não validado; documentos de desenvolvimento |
| Recepção | Parcial/total, qualidade, divergências, pendentes, segregação do emitente e auditoria | Substituições e anexos; rejeições não autorizam receber acima do total |
| Stock | Artigos, armazéns, entradas, saídas fundamentadas, transferências atómicas, contagens e ajustes auditados por empresa | Revisão visual, reposição e conversões |
| Equipamentos e peças | Cadastro, compatibilidade, mínimos, alertas, validação técnica e rastreabilidade | Regra de investimento; adjudicação/emissão de equipamento bloqueadas |
| Orçamento | Dotação por empresa/centro/exercício, aprovação independente, reservas e compromissos | Revisão visual, reforços e validação operacional integral |
| Facturas e pagamentos | Facturas, consumo e registo/conferência de pagamentos externos | Estornos, notas de crédito e anexos |
| Início e navegação | Página inicial própria, menus por área/perfil e consulta pública por módulo | Revisão visual |
| Auditoria | Histórico das operações implementadas | Exportações, retenção e protecção contra SQL administrativo |
| Área | Implementado no Laravel | Pendente |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Acesso e organização | Fortify, conta, empresa, equipa, departamentos e centros de custo | MFA obrigatório e ciclo completo de acessos |
| Requisições e aprovações | Rascunhos, cálculos exactos, etapas, segregação e snapshots | Validação empresarial das regras provisórias |
| Fornecedores | Cadastro, anexos privados e versões bancárias com duas validações | Múltiplas contas e verificação bancária externa |
| Cotações | Propostas, avaliações e matriz comparativa | Multimoeda e revisão das propostas |
| RF-AF-01 | Declarações pessoais, histórico e ConflictGuard no servidor | Política de periodicidade, validade e levantamento |
| Adjudicação | Escolha fundamentada, revalidação e aprovação do valor final | Excepções, revisão e cancelamento sem política ficam bloqueados |
| Ordens | Emissão única/idempotente, snapshots, consulta e impressão | Orçamento não validado; documentos de desenvolvimento |
| Recepção | Parcial/total, qualidade, divergências, pendentes, segregação do emitente e auditoria | Substituições e anexos; rejeições não autorizam receber acima do total |
| Stock | Artigos, armazéns, entradas, saídas fundamentadas, transferências atómicas, contagens e ajustes auditados por empresa | Revisão visual, reposição e conversões |
| Equipamentos e peças | Cadastro, compatibilidade, mínimos, alertas, validação técnica e rastreabilidade | Regra de investimento; adjudicação/emissão de equipamento bloqueadas |
| Orçamento | Dotação, reforços aprovados, reservas e compromissos por empresa/centro/exercício | Revisão visual, reduções/transferências e validação operacional integral |
| Facturas e pagamentos | Facturas, consumo e registo/conferência de pagamentos externos | Estornos, notas de crédito e anexos |
| Início e navegação | Página inicial própria, menus por área/perfil e consulta pública por módulo | Revisão visual |
| Auditoria | Histórico das operações implementadas | Exportações, retenção e protecção contra SQL administrativo |
Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por empresa. As regras guardadas nos snapshots não são substituídas pela configuração actual. Ausência de conflito não anula conflito registado. Exemplos públicos fictícios não dão acesso aos dados de negócio.
## Verificação actual
Reforços entregues por empresa/centro/exercício, com aprovação independente e
histórico, sem reescrever a dotação inicial. Migração 000013 no lote 14 após
backup e restauro verificados de 45 tabelas. Três cenários concorrentes PostgreSQL
aprovados. Suite actual: **370 aprovados, 3 ignorados, 2.704 asserções**.
Tabela de reforços vazia na base real. Regras de investimento, reduções,
transferências, estornos e revisão visual continuam pendentes.
### Histórico da entrega de pagamentos externos
Registo e conferência de pagamentos externos entregues, com liquidação parcial,
independência, revalidação bancária e consumo orçamental sem duplicação.
Suite actual: **347 aprovados, 3 ignorados e 2.630 asserções**.
+63
View File
@@ -1,5 +1,68 @@
# Desenvolvimento do SGC
## Reforços orçamentais por empresa
Implementadas propostas de reforço em `/orcamentos`, por dotação existente de
empresa/centro de custo/exercício. Administração ou Finanças propõe referência
interna, montante positivo e fundamento; outra pessoa destes perfis aprova ou
rejeita, fundamentando. Perfis do operador e do proponente são recarregados em
transacção. O autor não aprova o próprio reforço.
A dotação inicial aprovada não é alterada. Dotação acumulada = valor inicial
mais reforços aprovados. Disponível = acumulada menos reservas, compromissos
remanescentes e consumos. Pendentes e rejeitados não aumentam o saldo; a
aprovação permite novas reservas apenas dentro do disponível actualizado.
Reforçar não altera consumo ou compromissos anteriores, base de IVA, moeda,
centro, exercício ou regras de aprovação de compras. Montantes são definidos
pela própria empresa, sem valores financeiros predefinidos para clientes.
Referência normalizada única por empresa, permitindo referências iguais noutras
empresas. Repetição idêntica da proposta ou da aprovação não duplica a verba.
Decisões finais e dados do reforço não admitem reescrita ou eliminação pelos
modelos. Falha na auditoria reverte integralmente proposta ou decisão.
Só dotações aprovadas do exercício actual recebem reforços. Há o mesmo limite
técnico de representação monetária das requisições, não um limite empresarial
ou autorização comercial. Aprovações históricas não são revogadas automaticamente
por mudanças posteriores de perfil; correcções exigem fluxo próprio, ainda ausente.
### Verificação e aplicação
**370 testes aprovados, 3 ignorados, 2.704 asserções** na suite completa.
Inclui **23 testes de reforços, 74 asserções**: perfis, independência, exactidão,
idempotência, bases preservadas, isolamento, valores por empresa, ano encerrado,
auditoria e rollback, inputs manipulados e disponibilidade para reservas.
PHPStan, Pint, TypeScript e compilação aprovados.
Três cenários concorrentes PostgreSQL aprovados: mesma aprovação repetida,
reforços distintos aprovados em simultâneo e aprovação concorrente com reserva.
Duas ligações independentes em processos PHP observadas simultaneamente à
espera de bloqueios por cenário. Conferidos dotação acumulada, saldo reservado,
ausência de excesso e preservação do valor inicial. Base temporária eliminada.
São transacções de serviço, não teste HTTP ou de carga.
[Evidência](reforcos-concorrencia-postgresql.json).
Migração 000013 aplicada no **lote 14**, após backup custom PostgreSQL 17 de
**245.514 bytes** e restauro verificado das **45 tabelas** anteriores numa base
temporária, depois eliminada. Dump e manifesto partilharam snapshot consistente.
Registos de negócio, restrições e `.env` preservados. Tabela de reforços vazia:
sem seeds, verbas fictícias, contas novas ou alterações de credenciais na base
real. Backup/manifesto privados, fora do Git.
[Evidência sem dados privados](reforcos-migracao-000013.json).
`/consulta/reforcos` contém somente exemplos fictícios; não consulta dotações ou
utilizadores e não grava dados. O navegador voltou a reportar
`The browser access host is unavailable`: **revisão visual pública, autenticada
e de impressão continua pendente**. Não constitui validação operacional integral.
### Próximo passo e limitações
Próximo incremento: regras de investimento configuráveis por empresa e aprovadas
independentemente. Não inventar montantes comuns aos clientes, semântica de
limites ou dispensa de aprovações. Enquanto não houver regra própria aprovada,
equipamentos continuam bloqueados para adjudicação/emissão. Reduções, transferências
de dotações, estornos de reforços e passagem de saldos entre exercícios não
foram implementados; exigem fluxos e decisões próprios.
## Registo e conferência de pagamentos externos
Implementado `/pagamentos`, por empresa. Finanças regista pagamentos já
+10
View File
@@ -1,5 +1,15 @@
# Continuidade do desenvolvimento do SGC
## Próxima etapa após reforços
Reforços por empresa/centro/exercício já estão implementados e migrados
(000013, lote 14). Não repetir as orientações históricas abaixo. Prosseguir
com regras de investimento configuráveis por empresa, com decisões e valores
explícitos, versionamento e aprovação independente. Não presumir um valor ou
interpretar limite ausente como autorização. Até existir regra própria aprovada,
preservar o bloqueio de adjudicação/emissão de equipamento. Revisão visual e
validação operacional integral continuam pendentes. Consultar o relatório actual.
## Próxima etapa após pagamentos externos
Registo e conferência independente de pagamentos externos já implementados,
+1 -1
View File
@@ -7,7 +7,7 @@ Actualização: o utilizador autorizou avançar com a abordagem. Dotações, res
compromissos e conferência de facturas/consumo foram implementados e migrados.
A base de IVA é uma decisão explícita de cada empresa. As secções seguintes
conservam a proposta original; não comprovam aprovação de limites monetários,
tolerâncias ou regras de investimento. Reforços continuam pendentes. Registo e
tolerâncias ou regras de investimento. Reforços positivos, com aprovação independente, foram implementados posteriormente. Registo e
conferência de pagamentos externos foram acrescentados depois; não executam
transferências nem substituem a confirmação humana da evidência.
Estado actual em `docs/desenvolvimento.md`.
@@ -0,0 +1,33 @@
{
"tested_at_utc": "2026-10-28T12:59:44+00:00",
"separate_database": true,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
{
"name": "same_approval",
"passed": true,
"overlapping_lock_waits": 2,
"approved_total_minor": 12000,
"reserved_minor": 0,
"original_allocation_unchanged": true
},
{
"name": "distinct_reinforcements",
"passed": true,
"overlapping_lock_waits": 2,
"approved_total_minor": 19000,
"reserved_minor": 0,
"original_allocation_unchanged": true
},
{
"name": "approval_vs_reservation",
"passed": true,
"overlapping_lock_waits": 2,
"approved_total_minor": 12000,
"reserved_minor": 10000,
"original_allocation_unchanged": true
}
],
"passed": true,
"test_database_dropped": true
}
+15
View File
@@ -0,0 +1,15 @@
{
"verified_at_utc": "2026-10-28T13:00:53+00:00",
"migration": "2026_10_28_000013_create_budget_reinforcements",
"batch": 14,
"backup_created": true,
"backup_bytes": 245514,
"restore_verified": true,
"restored_tables": 45,
"restore_database_dropped": true,
"business_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"budget_reinforcements_count": 0,
"operational_validation": false
}
@@ -0,0 +1,97 @@
import { useForm } from '@inertiajs/react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export function ReinforcementProposal({ budgetId }: { budgetId: number }) {
const form = useForm({ reference: '', amount: '', reason: '' });
return (
<details className="mt-4 rounded-lg border p-4">
<summary className="cursor-pointer font-semibold">
Propor reforço desta dotação
</summary>
<form
className="mt-4 grid gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/orcamentos/${budgetId}/reforcos`, {
onSuccess: () => form.reset(),
});
}}
>
<FormErrors errors={form.errors} />
<p className="text-sm">
Aumenta o saldo apenas depois da aprovação por outra pessoa.
Mantém a moeda e a base de IVA da dotação.
</p>
<label>
Referência interna única na empresa
<Input
required
maxLength={100}
value={form.data.reference}
onChange={(e) =>
form.setData('reference', e.target.value)
}
/>
</label>
<label>
Montante do reforço (MZN)
<Input
required
inputMode="decimal"
value={form.data.amount}
onChange={(e) => form.setData('amount', e.target.value)}
/>
</label>
<label>
Fundamento
<textarea
required
className="w-full rounded border p-3"
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button disabled={form.processing}>
Enviar para aprovação independente
</Button>
</form>
</details>
);
}
export function ReinforcementReview({ id }: { id: number }) {
const form = useForm({ decision: 'approved', reason: '' });
return (
<form
className="mt-4 grid gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/reforcos/${id}/decidir`);
}}
>
<FormErrors errors={form.errors} />
<label>
Decisão
<select
className={controlClass}
value={form.data.decision}
onChange={(e) => form.setData('decision', e.target.value)}
>
<option value="approved">Aprovar reforço</option>
<option value="rejected">Rejeitar reforço</option>
</select>
</label>
<label>
Fundamento da decisão
<textarea
required
className="w-full rounded border p-3"
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button disabled={form.processing}>Guardar decisão imutável</Button>
</form>
);
}
+1
View File
@@ -12,5 +12,6 @@ export const previewPaths: Record<string, string> = {
invoices: '/consulta/facturas',
bank: '/consulta/dados-bancarios',
payments: '/consulta/pagamentos',
reinforcements: '/consulta/reforcos',
appearance: '/consulta/aparencia',
};
+73 -1
View File
@@ -8,6 +8,12 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { money } from '@/types/procurement';
import {
ReinforcementProposal,
ReinforcementReview,
} from '@/components/budget-reinforcements';
import { InventoryPagination } from '@/components/inventory';
import type { Page } from '@/types/inventory';
type Budget = {
id: number;
@@ -22,13 +28,27 @@ type Budget = {
approved_by: number | null;
reason: string;
includes_tax: boolean;
reinforced_minor: number;
approved_total_minor: number;
};
export default function Budgets({
centers,
budgets,
reinforcements,
}: {
centers: { id: number; code: string; name: string }[];
budgets: Budget[];
reinforcements: Page<{
id: number;
budget_id: number;
reference: string;
amount_minor: number;
reason: string;
status: string;
created_by: number;
reviewed_by: number | null;
review_reason: string | null;
}>;
}) {
const { auth } = usePage().props;
const form = useForm({
@@ -57,7 +77,7 @@ export default function Budgets({
</p>
<Panel
title="Preparar dotação"
description="Outra pessoa de Administração ou Finanças deve aprovar. Não existem alterações ou reforços nesta etapa."
description="Outra pessoa de Administração ou Finanças deve aprovar. A dotação inicial é preservada; reforços têm aprovação própria."
>
<form
className="grid gap-4"
@@ -165,6 +185,10 @@ export default function Budgets({
{money(b.consumed_minor)} · Disponível:{' '}
{money(b.available_minor)}
</p>
<p>
Reforços aprovados: {money(b.reinforced_minor)} ·
Dotação acumulada: {money(b.approved_total_minor)}
</p>
<p className="my-3">{b.reason}</p>
{!b.approved_by && b.created_by !== auth.user.id && (
<Button
@@ -175,8 +199,56 @@ export default function Budgets({
Aprovar dotação
</Button>
)}
{b.approved_by && (
<ReinforcementProposal budgetId={b.id} />
)}
</Panel>
))}
<Panel
title="Histórico de reforços"
description="Pendentes e rejeitados não alteram o saldo. O valor inicial nunca é reescrito."
>
<div className="space-y-4">
{reinforcements.data.map((r) => (
<article
key={r.id}
className="rounded border p-4 text-sm"
>
<p className="font-semibold">
{r.reference} · Dotação #{r.budget_id} ·{' '}
{money(r.amount_minor)}
</p>
<p>
{{
pending: 'Pendente',
approved: 'Aprovado',
rejected: 'Rejeitado',
}[r.status] ?? r.status}{' '}
· Proposto por #{r.created_by}
{r.reviewed_by
? ` · Decidido por #${r.reviewed_by}`
: ''}
</p>
<p className="my-2 whitespace-pre-wrap">
{r.reason}
</p>
{r.review_reason && (
<p className="whitespace-pre-wrap">
Decisão: {r.review_reason}
</p>
)}
{r.status === 'pending' &&
r.created_by !== auth.user.id && (
<ReinforcementReview id={r.id} />
)}
</article>
))}
{reinforcements.data.length === 0 && (
<p>Sem reforços nesta empresa.</p>
)}
</div>
<InventoryPagination page={reinforcements} />
</Panel>
</div>
</>
);
+33 -1
View File
@@ -121,6 +121,7 @@ const navigation = [
{ id: 'invoices', title: 'Facturas e conferência', icon: FileText },
{ id: 'bank', title: 'Dados bancários', icon: ShieldCheck },
{ id: 'payments', title: 'Pagamentos externos', icon: FileText },
{ id: 'reinforcements', title: 'Reforços orçamentais', icon: FileText },
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
@@ -129,7 +130,10 @@ const navigationGroups = [
{ title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] },
{ title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] },
{ title: 'Controlo', ids: ['conflicts'] },
{ title: 'Finanças', ids: ['invoices', 'bank', 'payments'] },
{
title: 'Finanças',
ids: ['invoices', 'bank', 'payments', 'reinforcements'],
},
{ title: 'Apresentação', ids: ['appearance'] },
];
export default function Preview({
@@ -665,6 +669,34 @@ export default function Preview({
)}
{screen === 'operations' && <OperationsDemo />}
{screen === 'equipment' && <EquipmentDemo />}
{screen === 'reinforcements' && (
<Panel
title="Reforço orçamental · exemplo fictício"
description="Valores ilustrativos; cada empresa define as suas dotações. Sem consultas ou gravações de negócio."
>
<div className="space-y-3 text-sm">
<p>
Dotação inicial fictícia de 1.000,00
MZN, com 300,00 MZN utilizados:
disponível de 700,00 MZN.
</p>
<p>
Um reforço proposto de 400,00 MZN fica
pendente. Só depois da aprovação por
outra pessoa a dotação acumulada passa a
1.400,00 MZN e o disponível a 1.100,00
MZN.
</p>
<p>
O valor inicial, a base de IVA, o centro
de custo e o exercício são preservados.
Repetir uma aprovação não duplica a
verba. Rejeições ficam no histórico sem
alterar o saldo.
</p>
</div>
</Panel>
)}
{screen === 'payments' && (
<Panel
title="Pagamento externo · exemplo fictício"
+2 -1
View File
@@ -7,7 +7,7 @@ use Inertia\Inertia;
// Public visual consultation. No business records or authenticated identities.
Route::get('consulta', function (Request $request) {
$screen = $request->query('ecra', 'overview');
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'payments', 'appearance'];
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'payments', 'reinforcements', 'appearance'];
return Inertia::render('preview/index', [
'auth' => ['user' => null],
@@ -22,6 +22,7 @@ Route::get('consulta/{screen}', function (string $screen) {
$screens['facturas'] = 'invoices';
$screens['dados-bancarios'] = 'bank';
$screens['pagamentos'] = 'payments';
$screens['reforcos'] = 'reinforcements';
abort_unless(isset($screens[$screen]), 404);
return Inertia::render('preview/index', ['auth' => ['user' => null], 'initialScreen' => $screens[$screen]]);
+3
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\BudgetController;
use App\Http\Controllers\BudgetReinforcementController;
use App\Http\Controllers\CompanyController;
use App\Http\Controllers\ConflictDeclarationController;
use App\Http\Controllers\EquipmentController;
@@ -27,6 +28,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::post('orcamentos/{budget}/reforcos', [BudgetReinforcementController::class, 'store'])->name('budgets.reinforcements.store');
Route::post('reforcos/{reinforcement}/decidir', [BudgetReinforcementController::class, 'review'])->name('budgets.reinforcements.review');
Route::get('pagamentos', [ExternalPaymentController::class, 'index'])->name('payments.index');
Route::post('facturas/{invoice}/pagamentos', [ExternalPaymentController::class, 'store'])->name('payments.store');
Route::post('pagamentos/{payment}/conferir', [ExternalPaymentController::class, 'review'])->name('payments.review');
@@ -0,0 +1,42 @@
<?php
use App\Models\BudgetReinforcement;
use App\Models\Requisition;
use App\Models\User;
use App\Services\BudgetReinforcementWorkflow;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
require __DIR__.'/../../vendor/autoload.php';
$database = $argv[1] ?? '';
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
throw new LogicException('Base de teste inválida.');
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
throw new LogicException('Worker não isolado.');
}
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_reinforcement_'.$argv[4]]);
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
$user = User::query()->findOrFail($argv[2]);
auth()->guard()->setUser($user);
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
try {
if ($payload['action'] === 'reserve') {
DB::transaction(function () use ($user, $argv) {
app(ConflictGuard::class)->lockCompany($user);
$request = Requisition::query()->lockForUpdate()->findOrFail($argv[3]);
app(BudgetWorkflow::class)->record($user, $request, 'reserve', 10000, 10000);
});
} else {
app(BudgetReinforcementWorkflow::class)->review($user, BudgetReinforcement::query()->findOrFail($argv[3]), 'approved', 'Conferência isolada de teste');
}
$status = 200;
} catch (ValidationException) {
$status = 422;
}
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Models\BudgetReinforcement;
use App\Models\User;
use App\Services\BudgetReinforcementWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require __DIR__.'/../Support/ReceiptFixture.php';
$original = DB::connection();
if ($original->getDriverName() !== 'pgsql') {
throw new LogicException('Exige PostgreSQL.');
}
$config = $original->getConfig();
$admin = $original->getPdo();
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
$created = false;
$processes = [];
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
try {
$admin->exec('CREATE DATABASE "'.$database.'"');
$created = true;
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
DB::purge('pgsql');
DB::setDefaultConnection('pgsql');
Schema::clearResolvedInstance('db.schema');
app()->forgetInstance('db.schema');
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
throw new LogicException('Migração fora do isolamento recusada.');
}
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
foreach (['same_approval', 'distinct_reinforcements', 'approval_vs_reservation'] as $scenario) {
$f = receiptFixture();
$author = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$reviewer = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
auth()->guard()->setUser($author);
$budget = Budget::query()->create(['tenant_id' => $author->tenant_id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 5000, 'includes_tax' => true, 'created_by' => $author->id, 'approved_by' => $reviewer->id, 'reason' => 'Fixture isolada']);
$data = ['reference' => 'REF-PG-1', 'amount' => '70.00', 'reason' => 'Fixture isolada'];
$first = app(BudgetReinforcementWorkflow::class)->propose($author, $budget, $data);
$second = $first;
if ($scenario === 'distinct_reinforcements') {
$second = app(BudgetReinforcementWorkflow::class)->propose($author, $budget, [...$data, 'reference' => 'REF-PG-2']);
}
$jobs = [
['user' => $reviewer, 'target' => $first->id, 'payload' => ['action' => 'approve']],
['user' => $reviewer, 'target' => $second->id, 'payload' => ['action' => 'approve']],
];
if ($scenario === 'approval_vs_reservation') {
$jobs[1] = ['user' => $author, 'target' => $f['r']->id, 'payload' => ['action' => 'reserve']];
}
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $author->tenant_id)->lockForUpdate()->first();
$processes = [];
foreach ($jobs as $index => $job) {
$process = new Process([PHP_BINARY, __DIR__.'/reinforcement-worker.php', $database, (string) $job['user']->id, (string) $job['target'], $index === 0 ? 'A' : 'B'], base_path(), $workerEnv);
$process->setInput(json_encode($job['payload'], JSON_THROW_ON_ERROR));
$process->setTimeout(40);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_reinforcement_A','sgc_reinforcement_B') AND wait_event_type = 'Lock'");
$deadline = microtime(true) + 20;
do {
$statement->execute([$database]);
$waits = (int) $statement->fetchColumn();
if ($waits === 2) {
break;
}
foreach ($processes as $process) {
if (! $process->isRunning()) {
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
}
}
usleep(20000);
} while (microtime(true) < $deadline);
$connection->commit();
if ($waits !== 2) {
throw new RuntimeException('Sem sobreposição de bloqueios.');
}
$results = [];
foreach ($processes as $process) {
$process->wait();
if (! $process->isSuccessful()) {
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
}
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
}
$statuses = array_column($results, 'status');
sort($statuses);
$total = $budget->approvedTotalMinor();
$reserved = (int) BudgetEntry::query()->sum('reserved_minor');
$expectedTotal = $scenario === 'distinct_reinforcements' ? 19000 : 12000;
$expectedReserved = $scenario === 'approval_vs_reservation' && $results[1]['status'] === 200 ? 10000 : 0;
$validStatuses = $scenario === 'approval_vs_reservation' ? in_array($statuses, [[200, 200], [200, 422]], true) : $statuses === [200, 200];
if (! $validStatuses || $results[0]['status'] !== 200 || $total !== $expectedTotal || $reserved !== $expectedReserved || $reserved > $total || BudgetReinforcement::query()->where('status', 'approved')->count() !== ($scenario === 'distinct_reinforcements' ? 2 : 1) || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
throw new RuntimeException('Resultado concorrente de refor?os inesperado.');
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'approved_total_minor' => $total, 'reserved_minor' => $reserved, 'original_allocation_unchanged' => $budget->refresh()->amount_minor === 5000];
}
$report['passed'] = true;
} finally {
foreach ($processes as $process) {
if ($process->isRunning()) {
$process->stop();
}
}
if (isset($connection) && $connection->transactionLevel() > 0) {
$connection->rollBack();
}
DB::disconnect('pgsql');
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
$admin->exec('DROP DATABASE "'.$database.'"');
$report['test_database_dropped'] = true;
}
}
file_put_contents(base_path('docs/reforcos-concorrencia-postgresql.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
+167
View File
@@ -0,0 +1,167 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Models\BudgetReinforcement;
use App\Models\User;
use App\Services\BudgetReinforcementWorkflow;
use App\Services\BudgetWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
require_once __DIR__.'/../Support/ReceiptFixture.php';
beforeEach(function () {
$this->f = receiptFixture();
$this->author = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($this->author);
$this->budget = Budget::query()->create(['tenant_id' => $this->author->tenant_id, 'cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 5000, 'includes_tax' => false, 'created_by' => $this->author->id, 'approved_by' => $this->reviewer->id, 'reason' => 'Dotação inicial de teste']);
$this->data = ['reference' => 'REF TEST 01', 'amount' => '70.25', 'reason' => 'Reforço fictício definido pela empresa de teste'];
});
function proposeTestReinforcement(object $test): BudgetReinforcement
{
$test->actingAs($test->author);
return app(BudgetReinforcementWorkflow::class)->propose($test->author, $test->budget, $test->data);
}
function approveTestReinforcement(object $test, BudgetReinforcement $reinforcement, string $decision = 'approved'): void
{
$test->actingAs($test->reviewer);
app(BudgetReinforcementWorkflow::class)->review($test->reviewer, $reinforcement, $decision, 'Decisão independente de teste');
}
test('only approved reinforcement increases available budget without rewriting the original', function () {
$reinforcement = proposeTestReinforcement($this);
expect($this->budget->approvedTotalMinor())->toBe(5000);
approveTestReinforcement($this, $reinforcement);
expect($this->budget->refresh()->amount_minor)->toBe(5000)
->and($this->budget->includes_tax)->toBeFalse()
->and($this->budget->approvedTotalMinor())->toBe(12025);
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.available_minor', 12025)->where('budgets.0.reinforced_minor', 7025));
});
test('approved reinforcement enables a previously unaffordable procurement reservation', function () {
expect(fn () => DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->author, $this->f['r'], 'reserve', 10000, 10000)))->toThrow(ValidationException::class);
approveTestReinforcement($this, proposeTestReinforcement($this));
$this->actingAs($this->author);
DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->author, $this->f['r'], 'reserve', 10000, 10000));
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
});
test('reinforcement proposals and approvals are idempotent', function () {
$first = proposeTestReinforcement($this);
$this->data['reference'] = ' ref test 01 ';
expect(proposeTestReinforcement($this)->id)->toBe($first->id);
approveTestReinforcement($this, $first);
approveTestReinforcement($this, $first);
expect(BudgetReinforcement::query()->count())->toBe(1)->and($this->budget->approvedTotalMinor())->toBe(12025);
});
test('duplicate reference with changed values cannot create a second reinforcement', function () {
proposeTestReinforcement($this);
$this->data['amount'] = '71.00';
expect(fn () => proposeTestReinforcement($this))->toThrow(ValidationException::class);
});
test('proposer cannot approve own reinforcement', function () {
$reinforcement = proposeTestReinforcement($this);
$this->post('/reforcos/'.$reinforcement->id.'/decidir', ['decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
});
test('rejection leaves balance unchanged and is final', function () {
$reinforcement = proposeTestReinforcement($this);
approveTestReinforcement($this, $reinforcement, 'rejected');
expect($this->budget->approvedTotalMinor())->toBe(5000);
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(ValidationException::class);
});
test('reinforcement amount must be positive and technically representable', function (string $amount) {
$this->data['amount'] = $amount;
expect(fn () => proposeTestReinforcement($this))->toThrow(ValidationException::class);
expect(BudgetReinforcement::query()->count())->toBe(0);
})->with(['0.00', '-1.00', '1000000000000.00']);
test('foreign finance cannot inspect or approve another company reinforcement', function () {
$reinforcement = proposeTestReinforcement($this);
$other = receiptFixture();
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($user)->post('/reforcos/'.$reinforcement->id.'/decidir', ['decision' => 'approved', 'reason' => 'Teste'])->assertNotFound();
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->has('reinforcements.data', 0));
});
test('warehouse procurement and requesters cannot propose reinforcements', function (PurchasingRole $role) {
$user = User::factory()->create(['tenant_id' => $this->author->tenant_id, 'purchasing_role' => $role]);
$this->actingAs($user)->post('/orcamentos/'.$this->budget->id.'/reforcos', $this->data)->assertForbidden();
})->with([PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Requester, PurchasingRole::DepartmentHead]);
test('reinforcement proposal and decision roll back on audit failure', function () {
$reinforcement = proposeTestReinforcement($this);
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed'));
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(RuntimeException::class);
expect($reinforcement->refresh()->status)->toBe('pending')->and($this->budget->approvedTotalMinor())->toBe(5000);
$this->data['reference'] = 'REF TEST 02';
expect(fn () => proposeTestReinforcement($this))->toThrow(RuntimeException::class);
expect(BudgetReinforcement::query()->count())->toBe(1);
});
test('reinforcement history cannot be rewritten or deleted', function () {
$reinforcement = proposeTestReinforcement($this);
expect(fn () => $reinforcement->update(['amount_minor' => 999]))->toThrow(LogicException::class);
expect(fn () => $reinforcement->refresh()->delete())->toThrow(LogicException::class);
});
test('review rechecks current author eligibility', function () {
$reinforcement = proposeTestReinforcement($this);
$this->author->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(ValidationException::class);
});
test('closed exercise prevents new reinforcement approval', function () {
$reinforcement = proposeTestReinforcement($this);
$this->travel(1)->years();
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(ValidationException::class);
$this->travelBack();
});
test('unapproved initial allocation cannot receive reinforcement', function () {
DB::table('budgets')->where('id', $this->budget->id)->update(['approved_by' => null]);
$this->budget->refresh();
expect(fn () => proposeTestReinforcement($this))->toThrow(ValidationException::class);
});
test('current operator role is rechecked instead of trusting a stale session profile', function () {
DB::table('users')->where('id', $this->author->id)->update(['purchasing_role' => 'warehouse']);
$this->post('/orcamentos/'.$this->budget->id.'/reforcos', $this->data)->assertForbidden();
});
test('browser cannot approve reinforcement or alter the parent allocation base', function () {
$this->post('/orcamentos/'.$this->budget->id.'/reforcos', [...$this->data, 'status' => 'approved', 'includes_tax' => true, 'tenant_id' => PHP_INT_MAX, 'budget_id' => PHP_INT_MAX])->assertRedirect();
expect(BudgetReinforcement::query()->firstOrFail()->status)->toBe('pending')
->and($this->budget->refresh()->includes_tax)->toBeFalse()
->and($this->budget->approvedTotalMinor())->toBe(5000);
});
test('different companies may use their own identical reinforcement references and different amounts', function () {
proposeTestReinforcement($this);
$other = receiptFixture();
$author = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$reviewer = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($author);
$budget = Budget::query()->create(['tenant_id' => $author->tenant_id, 'cost_center_id' => $other['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 2000, 'includes_tax' => true, 'created_by' => $author->id, 'approved_by' => $reviewer->id, 'reason' => 'Outra decisão fictícia']);
$reinforcement = app(BudgetReinforcementWorkflow::class)->propose($author, $budget, [...$this->data, 'amount' => '10.01']);
$this->actingAs($reviewer);
app(BudgetReinforcementWorkflow::class)->review($reviewer, $reinforcement, 'approved', 'Outra decisão independente');
expect($budget->approvedTotalMinor())->toBe(3001);
$this->actingAs($this->author);
expect($this->budget->approvedTotalMinor())->toBe(5000);
});
test('public reinforcement example contains no company allocations', function () {
auth()->forgetGuards();
$this->get('/consulta/reforcos')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'reinforcements')->where('auth.user', null)->missing('budgets')->missing('reinforcements'));
});
@@ -0,0 +1,105 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000013.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = DB::connection();
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
}
$pdo = $connection->getPdo();
$migration = '2026_10_28_000013_create_budget_reinforcements';
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
throw new LogicException('Migração já aplicada; não repetir.');
}
$config = $connection->getConfig();
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
if (! is_file($bin.$tool)) {
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
}
}
$envHash = hash_file('sha256', base_path('.env'));
$directory = storage_path('app/private/backups/reinforcement-000013-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Não foi possível criar backup privado.');
}
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
$fingerprints = function (PDO $db, array $tables) use ($quote): array {
$result = [];
foreach ($tables as $table) {
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM public.'.$quote($table).' t')->fetchAll(PDO::FETCH_COLUMN);
sort($rows, SORT_STRING);
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
}
return $result;
};
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
$restoreCreated = false;
try {
// pg_dump and the manifest read exactly the same committed snapshot.
$pdo->beginTransaction();
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
$backupState = $fingerprints($pdo, $tables);
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
$dump->setTimeout(120);
$dump->run();
if (! $dump->isSuccessful()) {
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
}
$pdo->commit();
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
$restoreCreated = true;
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
$restore->setTimeout(120);
$restore->run();
if (! $restore->isSuccessful()) {
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
}
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
$restored = DB::connection('budget_restore')->getPdo();
if ($fingerprints($restored, $tables) !== $backupState) {
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
}
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
DB::disconnect('budget_restore');
unset($restored);
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
$restoreCreated = false;
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
$before = $fingerprints($pdo, $businessTables);
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
if ($exit !== 0) {
throw new RuntimeException('Migração falhou; consultar backup privado.');
}
$after = $fingerprints($pdo, $businessTables);
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
}
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'budget_reinforcements_count' => (int) $pdo->query('SELECT count(*) FROM budget_reinforcements')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/reforcos-migracao-000013.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
DB::disconnect('budget_restore');
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
}
}