Implementa reforcos orcamentais com aprovacao independente
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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**.
|
||||
|
||||
@@ -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á
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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]]);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user