Valida concorrencia e aplica orcamento com recuperacao verificada

This commit is contained in:
2026-10-28 14:13:00 +02:00
parent c05dc12aea
commit f86ecef44d
12 changed files with 483 additions and 17 deletions
+8
View File
@@ -23,6 +23,14 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
## Entrega actual
- Orçamentos por empresa, centro de custo e exercício, com montante e base
com/sem IVA explícitos, aprovação independente, reservas e compromissos auditados.
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.
Facturas, reforços e pagamentos 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`.
- Página inicial em `/`, demonstração pública por módulo em `/consulta` e navegação autenticada agrupada por área e perfil.
- Recepções parciais/totais com qualidade, divergências, quantidades pendentes e segregação do emitente. Entradas de stock apenas para quantidades físicas aceites, sem duplicação; catálogo, armazéns, existências e histórico por empresa.
+14
View File
@@ -16,6 +16,20 @@ class Budget extends Model
protected $guarded = ['id'];
protected static function booted(): void
{
static::updating(function (Budget $budget) {
if ($budget->getRawOriginal('approved_by') !== null) {
throw new \LogicException('Uma dotação aprovada não pode ser reescrita.');
}
});
static::deleting(function (Budget $budget) {
if ($budget->approved_by !== null) {
throw new \LogicException('Preservar a dotação aprovada e os seus movimentos.');
}
});
}
protected function casts(): array
{
return ['includes_tax' => 'boolean'];
+6
View File
@@ -6,6 +6,7 @@ use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class BudgetWorkflow
@@ -15,6 +16,11 @@ class BudgetWorkflow
*/
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)
+26 -15
View File
@@ -2,26 +2,37 @@
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, edição, renovação, validação independente e anexos privados | Dados bancários com dupla validação |
| 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, facturas e pagamentos | Ainda não implementados | Definir regras antes do próximo incremento |
| 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, edição, renovação, validação independente e anexos privados | Dados bancários com dupla validação |
| 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 | Ainda não implementados | Conferência, consumo e liquidação |
| 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
Actualização de orçamento: cadastro e aprovação independente de dotações por
empresa/centro/exercício, com base de IVA explícita, reservas, libertações e
compromissos implementados. Migração 000009 aplicada no lote 10 após backup e
restauro verificados de 37 tabelas. Cinco cenários concorrentes PostgreSQL
aprovados; 14 testes de orçamento passaram com 40 asserções. Não foram criadas
dotações na base real. Facturas, reforços, pagamentos e validação operacional
integral continuam pendentes. Ver [relatório actual](desenvolvimento.md).
### Referência anterior à aplicação do orçamento
Referência consolidada: `75ffdb3`, com **262 testes aprovados, 3 ignorados e
2.327 asserções**. Nova verificação dirigida de equipamentos, adjudicação,
ordens e navegação: **87 testes aprovados, 1.163 asserções**. As onze migrações,
+44
View File
@@ -1,5 +1,49 @@
# Desenvolvimento do SGC
## Aplicação e validação PostgreSQL do orçamento
- Migração 000009 aplicada à base actual no **lote 10**, sem migrações pendentes.
Backup custom PostgreSQL 17 de 206.615 bytes em armazenamento privado;
manifesto privado com hashes e instruções de recuperação. O dump e o manifesto
partilharam um snapshot consistente. Restauro verificado em base temporária:
**37 tabelas** coincidiram em contagens e hashes. Base de restauro eliminada.
- Registos de negócio anteriores e restrições preservados; `.env` inalterado.
Nenhuma conta, dotação ou movimento financeiro foi criado na base real.
As tabelas `budgets` e `budget_entries` permanecem vazias. Sessões, caches
e filas não integram a comparação de negócio, pois podem variar com os acessos.
[Evidência sem dados privados](orcamento-migracao-000009.json).
- **Cinco cenários concorrentes PostgreSQL aprovados**: excesso de reservas,
mesma reserva, mesmo compromisso, mesma libertação e excesso na adjudicação.
Duas transacções de serviço em processos PHP e ligações independentes por
cenário, observadas simultaneamente à espera de bloqueios. Saldos exactos e
ausência de duplicações confirmados. Base exclusiva de teste eliminada.
Isto não é teste HTTP, de carga ou de todos os fluxos financeiros.
[Evidência](orcamento-concorrencia-postgresql.json).
- Reforçada a autorização no serviço: autor autenticado, empresa do processo,
transacção obrigatória e bloqueio da empresa. Dotação já aprovada não admite
reescrita nem eliminação pelo modelo. Corrigida a legenda da dotação para
respeitar a base com/sem IVA escolhida pela empresa.
- **14 testes de orçamento aprovados, 40 asserções**, incluindo submissão real
sem saldo e rollback, submissão/reserva seguida de rejeição/libertação,
isolamento no serviço e imutabilidade da dotação aprovada.
- Suite completa concluída: **275 aprovados, 3 ignorados e 2.364 asserções**;
inclui os 13 casos de orçamento existentes no início dessa execução. O caso
adicional de imutabilidade foi validado depois no conjunto de 14 acima.
PHPStan, Pint, TypeScript, compilação e formatação/lint frontend aprovados.
- `/` e `/consulta/equipamentos` responderam HTTP 200; `/orcamentos` sem sessão
respondeu 302 para login. Nova tentativa de acesso ao navegador devolveu
`The browser access host is unavailable`. **Revisão visual pública, autenticada
e de impressão permanece pendente.**
Cada empresa mantém dotações e base de IVA próprias; não existem valores
monetários de orçamento comuns a todos os clientes. Centros sem dotação continuam
no circuito de desenvolvimento sem controlo orçamental. Facturas, consumo validado,
reforços, pagamentos e regras próprias de investimento não estão implementados.
**A validação operacional integral do orçamento permanece pendente**, embora
as reservas e compromissos tenham sido testados e a migração esteja aplicada.
## Histórico do primeiro incremento de orçamento
## Primeiro incremento de orçamento por empresa
Implementados cadastro de dotação anual por empresa/centro de custo, montantes
@@ -0,0 +1,44 @@
{
"tested_at_utc": "2026-10-28T12:11:20+00:00",
"separate_database": true,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
{
"name": "over_reserve",
"passed": true,
"overlapping_lock_waits": 2,
"reserved_minor": 6000,
"committed_minor": 0
},
{
"name": "same_reserve",
"passed": true,
"overlapping_lock_waits": 2,
"reserved_minor": 6000,
"committed_minor": 0
},
{
"name": "same_commit",
"passed": true,
"overlapping_lock_waits": 2,
"reserved_minor": 0,
"committed_minor": 6000
},
{
"name": "same_release",
"passed": true,
"overlapping_lock_waits": 2,
"reserved_minor": 0,
"committed_minor": 0
},
{
"name": "over_award",
"passed": true,
"overlapping_lock_waits": 2,
"reserved_minor": 8000,
"committed_minor": 0
}
],
"passed": true,
"test_database_dropped": true
}
+16
View File
@@ -0,0 +1,16 @@
{
"verified_at_utc": "2026-10-28T12:09:22+00:00",
"migration": "2026_10_28_000009_create_budgets",
"batch": 10,
"backup_created": true,
"backup_bytes": 206615,
"restore_verified": true,
"restored_tables": 37,
"restore_database_dropped": true,
"business_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"budgets_count": 0,
"budget_entries_count": 0,
"operational_validation": false
}
+3 -2
View File
@@ -118,7 +118,7 @@ export default function Budgets({
/>
</label>
<label>
Dotação com IVA (MZN)
Dotação na base seleccionada (MZN)
<Input
required
inputMode="decimal"
@@ -153,7 +153,8 @@ export default function Budgets({
{b.approved_by
? 'Aprovado'
: 'Pendente de aprovação'}{' '}
· Dotação: {money(b.amount_minor)}
· Dotação: {money(b.amount_minor)} · Base:{' '}
{b.includes_tax ? 'com IVA' : 'sem IVA'}
</p>
<p>
Reservado: {money(b.reserved_minor)} · Comprometido:{' '}
+36
View File
@@ -0,0 +1,36 @@
<?php
use App\Models\Requisition;
use App\Models\User;
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_budget_'.$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 {
DB::transaction(function () use ($user, $argv, $payload) {
app(ConflictGuard::class)->lockCompany($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($argv[3]);
app(BudgetWorkflow::class)->record($user, $requisition, $payload['kind'], $payload['amount'], $payload['amount']);
});
$status = 200;
} catch (ValidationException) {
$status = 422;
}
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
+135
View File
@@ -0,0 +1,135 @@
<?php
use App\Models\Budget;
use App\Models\BudgetEntry;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
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;
$isolated = [...$config, 'database' => $database, 'url' => null];
config(['database.connections.pgsql' => $isolated]);
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 (['over_reserve', 'same_reserve', 'same_commit', 'same_release', 'over_award'] as $scenario) {
$f = receiptFixture();
$user = $f['issuer'];
auth()->guard()->setUser($user);
$budget = Budget::query()->create(['tenant_id' => $user->tenant_id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 10000, 'includes_tax' => true, 'created_by' => $user->id, 'approved_by' => $f['receiver']->id, 'reason' => 'Fixture isolada']);
$first = $f['r'];
$second = $first;
if (in_array($scenario, ['over_reserve', 'over_award'])) {
$second = $first->replicate();
$second->number = 'REQ-TEST-'.bin2hex(random_bytes(5));
$second->save();
}
if (in_array($scenario, ['same_commit', 'same_release', 'over_award'])) {
foreach ($scenario === 'over_award' ? [$first, $second] : [$first] as $request) {
DB::transaction(function () use ($user, $request) {
app(ConflictGuard::class)->lockCompany($user);
app(BudgetWorkflow::class)->record($user, $request, 'reserve', 2000, 2000);
});
}
}
$kind = match ($scenario) {
'same_commit' => 'commit', 'same_release' => 'release', 'over_award' => 'award', default => 'reserve'
};
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $user->tenant_id)->lockForUpdate()->first();
$processes = [];
foreach ([$first, $second] as $index => $request) {
$process = new Process([PHP_BINARY, __DIR__.'/budget-worker.php', $database, (string) $user->id, (string) $request->id, $index === 0 ? 'A' : 'B'], base_path(), $workerEnv);
$process->setInput(json_encode(['kind' => $kind, 'amount' => 6000], 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_budget_A','sgc_budget_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);
$reserved = (int) BudgetEntry::query()->sum('reserved_minor');
$committed = (int) BudgetEntry::query()->sum('committed_minor');
[$expectedReserve, $expectedCommit] = match ($scenario) {
'same_commit' => [0, 6000], 'same_release' => [0, 0], 'over_award' => [8000, 0], default => [6000, 0]
};
if ($statuses !== (in_array($scenario, ['over_reserve', 'over_award']) ? [200, 422] : [200, 200]) || $reserved !== $expectedReserve || $committed !== $expectedCommit || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
throw new RuntimeException('Resultado inesperado: '.json_encode([$statuses, $reserved, $committed]));
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'reserved_minor' => $reserved, 'committed_minor' => $committed];
}
$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/orcamento-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;
+46
View File
@@ -9,6 +9,7 @@ use App\Services\ConflictGuard;
use App\Services\RequisitionWorkflow;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
require_once __DIR__.'/../Support/ReceiptFixture.php';
@@ -76,6 +77,13 @@ test('company chooses its own tax basis', function () {
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
});
test('approved company allocation and tax basis cannot be rewritten', function () {
$this->budget->update(['approved_by' => $this->approver->id]);
expect(fn () => $this->budget->update(['amount_minor' => 999999]))->toThrow(LogicException::class);
expect(fn () => $this->budget->refresh()->update(['includes_tax' => false]))->toThrow(LogicException::class);
expect(fn () => $this->budget->refresh()->delete())->toThrow(LogicException::class);
});
test('foreign company cannot read or approve allocation', function () {
$other = receiptFixture();
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
@@ -103,3 +111,41 @@ test('failed audit rolls back budget reservation', function () {
expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(RuntimeException::class);
expect(BudgetEntry::query()->count())->toBe(0);
});
test('submission with insufficient approved budget leaves draft and approvals unchanged', function () {
$this->budget->update(['approved_by' => $this->approver->id, 'amount_minor' => 9999]);
$request = $this->f['r'];
$request->update(['status' => 'draft']);
$this->actingAs($this->f['issuer']);
expect(fn () => app(RequisitionWorkflow::class)->submit($this->f['issuer'], $request))->toThrow(ValidationException::class);
expect($request->refresh()->status)->toBe('draft')
->and($request->steps()->count())->toBe(0)
->and(BudgetEntry::query()->count())->toBe(0);
});
test('submission reserves approved allocation and rejection releases it', function () {
$this->budget->update(['approved_by' => $this->approver->id]);
$request = $this->f['r'];
$request->update(['status' => 'draft']);
$this->actingAs($this->f['issuer']);
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $request);
expect($request->refresh()->status)->toBe('pending')
->and((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($head);
app(RequisitionWorkflow::class)->decide($head, $request, 'rejected', 'Necessidade não confirmada.');
expect($request->refresh()->status)->toBe('rejected')
->and((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0);
});
test('budget service refuses a requisition belonging to another company', function () {
$other = receiptFixture();
$this->actingAs($this->author);
try {
DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->author, $other['r'], 'reserve', 10000));
$this->fail('A foreign requisition must be rejected.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403);
}
expect(BudgetEntry::query()->count())->toBe(0);
});
+105
View File
@@ -0,0 +1,105 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000009.
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_000009_create_budgets';
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/budget-000009-'.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, 'budgets_count' => (int) $pdo->query('SELECT count(*) FROM budgets')->fetchColumn(), 'budget_entries_count' => (int) $pdo->query('SELECT count(*) FROM budget_entries')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/orcamento-migracao-000009.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));
}
}