Implementa conferencia de facturas e consumo por empresa

This commit is contained in:
2026-10-28 14:26:18 +02:00
parent f86ecef44d
commit 332cb9f1b7
28 changed files with 1356 additions and 8 deletions
+8 -2
View File
@@ -23,12 +23,18 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
## Entrega actual
- Facturas por empresa/fornecedor/ordem, conferência exacta com recepções aceites,
facturação parcial, validação independente por Finanças e conversão de compromisso
em consumo. Migração 000010 aplicada após backup/restauro verificados; três
cenários concorrentes PostgreSQL aprovados. Sem facturas fictícias na base real.
Interface em `/facturas`; exemplo público em `/consulta/facturas`.
- 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
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`.
@@ -63,7 +69,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; dados bancários com dupla validação; orçamento, facturas e pagamentos; 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; dados bancários com dupla validação; reforços orçamentais, correcções de facturas e pagamentos; 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.
+3 -1
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\CostCenter;
use App\Services\ConflictGuard;
@@ -31,8 +32,9 @@ class BudgetController extends Controller
$entries = BudgetEntry::query()->where('budget_id', $budget->id);
$reserved = (int) (clone $entries)->sum('reserved_minor');
$committed = (int) $entries->sum('committed_minor');
$consumed = (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
return [...$budget->toArray(), 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'available_minor' => $budget->amount_minor - $reserved - $committed];
return [...$budget->toArray(), 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->amount_minor - $reserved - $committed - $consumed];
})]);
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Services\InvoiceWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class InvoiceController extends Controller
{
public function index(Request $request): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]);
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
$decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
$data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
$workflow->register($request->user(), $order, $data);
return to_route('invoices.index');
}
public function validateInvoice(Request $request, Invoice $invoice, InvoiceWorkflow $workflow): RedirectResponse
{
$workflow->validate($request->user(), $invoice);
return back();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class BudgetConsumption extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** @property int $id
* @property int $tenant_id
* @property int $purchase_order_id
* @property int $created_by
* @property string $status
* @property array<string,mixed> $snapshot
*/
class Invoice extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
protected static function booted(): void
{
static::updating(function (Invoice $invoice) {
if ($invoice->getRawOriginal('status') !== 'pending' || array_diff(array_keys($invoice->getDirty()), ['status', 'validated_by', 'validated_at', 'updated_at']) !== []) {
throw new \LogicException('Preservar a factura registada e a validação.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar o histórico de facturas.'));
}
/** @return HasMany<InvoiceItem,$this> */
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array<string,mixed> $snapshot */
class InvoiceItem extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+2
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\Requisition;
use App\Models\User;
@@ -52,6 +53,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) {
throw ValidationException::withMessages(['budget' => 'Saldo orçamental insuficiente. Não são permitidos excessos.']);
}
+1
View File
@@ -85,6 +85,7 @@ class ConflictGuard
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
])->pluck('user_id'))
->unique()->values()->all();
$ids = [];
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\GoodsReceiptItem;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class InvoiceWorkflow
{
private function access(User $user): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Finance, 403);
}
private function invalid(string $message): never
{
throw ValidationException::withMessages(['invoice' => $message]);
}
/** @param list<array<string,mixed>> $inputs
* @return list<array<string,mixed>>
*/
private function lines(PurchaseOrder $order, array $inputs): array
{
$sources = $order->items()->get()->keyBy('id');
$seen = [];
$lines = [];
foreach ($inputs as $input) {
$source = $sources->get($input['purchase_order_item_id']);
if (! $source || in_array($source->id, $seen, true)) {
$this->invalid('Linhas distintas e pertencentes à ordem são obrigatórias.');
}
$seen[] = $source->id;
$quantity = ReceiptWorkflow::milli((string) $input['quantity']);
$previous = InvoiceItem::query()->where('purchase_order_item_id', $source->id)->whereIn('invoice_id', Invoice::query()->where('status', 'validated')->select('id'))->sum('quantity_milli');
$accepted = GoodsReceiptItem::query()->where('purchase_order_item_id', $source->id)->sum('accepted_milli');
if ($quantity <= 0 || (int) $previous + $quantity > (int) $accepted || (int) $previous + $quantity > ReceiptWorkflow::milli($source->quantity)) {
$this->invalid('Quantidade superior à recepção aceite ainda não facturada. Serviços também exigem aceitação.');
}
if (BigDecimal::of((string) $input['unit_price'])->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $input['tax_rate'])->compareTo((string) $source->tax_rate) !== 0 || trim((string) $input['unit']) !== $source->unit) {
$this->invalid('Preço, unidade e IVA devem coincidir exactamente com a ordem. Não há tolerância ou excepção configurada.');
}
// Cumulative rounding ensures partial invoices sum exactly to the frozen order.
$subtotal = fn (int $milli) => BigDecimal::of($milli)->multipliedBy($source->unit_price_minor)->dividedBy(1000, 0, RoundingMode::HalfUp)->toInt();
$tax = fn (int $minor) => BigDecimal::of($minor)->multipliedBy((string) $source->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
$before = $subtotal((int) $previous);
$after = $subtotal((int) $previous + $quantity);
$lines[] = ['tenant_id' => $order->tenant_id, 'purchase_order_item_id' => $source->id, 'quantity_milli' => $quantity, 'subtotal_minor' => $after - $before, 'tax_minor' => $tax($after) - $tax($before), 'snapshot' => $source->only(['description', 'unit', 'unit_price_minor', 'tax_rate'])];
}
if ($lines === []) {
$this->invalid('Indique pelo menos uma linha.');
}
return $lines;
}
/** @param array<string,mixed> $data */
public function register(User $user, PurchaseOrder $order, array $data): Invoice
{
$this->access($user);
abort_unless($order->tenant_id === $user->tenant_id, 403);
return DB::transaction(function () use ($user, $order, $data) {
app(ConflictGuard::class)->lockCompany($user);
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
$request = Requisition::query()->findOrFail($order->requisition_id);
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
$number = Str::upper(Str::squish($data['number']));
if ($number === '' || Invoice::query()->where('supplier_id', $order->supplier_id)->where('number', $number)->exists()) {
$this->invalid('Número de factura vazio ou já registado para este fornecedor.');
}
if ($order->status !== 'issued' || $order->currency !== 'MZN' || substr($data['issued_on'], 0, 4) !== now()->format('Y') || $data['issued_on'] > today()->format('Y-m-d') || $data['issued_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) {
$this->invalid('Factura deve referir ordem emitida em MZN, com data válida no exercício actual e posterior à emissão.');
}
$lines = $this->lines($order, $data['items']);
$subtotal = array_sum(array_column($lines, 'subtotal_minor'));
$tax = array_sum(array_column($lines, 'tax_minor'));
foreach (['subtotal' => $subtotal, 'tax' => $tax, 'total' => $subtotal + $tax] as $key => $expected) {
if (BigDecimal::of((string) $data[$key])->multipliedBy(100)->compareTo($expected) !== 0) {
$this->invalid('Totais da factura divergem da conferência com a ordem e recepção. Verifique os arredondamentos das facturas parciais.');
}
}
$invoice = Invoice::query()->create(['tenant_id' => $user->tenant_id, 'purchase_order_id' => $order->id, 'supplier_id' => $order->supplier_id, 'created_by' => $user->id, 'number' => $number, 'issued_on' => $data['issued_on'], 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'snapshot' => ['supplier' => $order->snapshot['supplier'], 'order_number' => $order->number, 'author' => $user->only(['id', 'name']), 'currency' => 'MZN', 'development_only' => true, 'matching_tolerance' => 'zero', 'rounding' => 'cumulative_half_up']]);
$invoice->items()->createMany($lines);
app(RequisitionWorkflow::class)->audit($user, 'invoice.created', $request, null, $invoice->load('items')->toArray());
return $invoice;
});
}
public function validate(User $user, Invoice $invoice): void
{
$this->access($user);
abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403);
DB::transaction(function () use ($user, $invoice) {
app(ConflictGuard::class)->lockCompany($user);
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
if ($invoice->status === 'validated') {
return;
}
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($invoice->purchase_order_id);
$request = Requisition::query()->findOrFail($order->requisition_id);
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
if (substr((string) $invoice->issued_on, 0, 4) !== now()->format('Y')) {
$this->invalid('Conferência entre exercícios não suportada.');
}
$stored = $invoice->items()->orderBy('id')->get();
$inputs = [];
foreach ($stored as $line) {
$inputs[] = ['purchase_order_item_id' => $line->purchase_order_item_id, 'quantity' => (string) BigDecimal::of($line->quantity_milli)->dividedBy(1000, 3), 'unit' => $line->snapshot['unit'], 'unit_price' => (string) BigDecimal::of($line->snapshot['unit_price_minor'])->dividedBy(100, 2), 'tax_rate' => $line->snapshot['tax_rate']];
}
$current = $this->lines($order, $inputs);
foreach ($current as $index => $line) {
if ($line['subtotal_minor'] !== $stored[$index]->subtotal_minor || $line['tax_minor'] !== $stored[$index]->tax_minor) {
$this->invalid('Conferência alterada por outra factura; a validação está bloqueada.');
}
}
$commit = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'commit')->first();
if (! $commit) {
$this->invalid('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.');
}
$budget = Budget::query()->lockForUpdate()->findOrFail($commit->budget_id);
if (! $budget->approved_by || (int) $budget->year !== now()->year) {
$this->invalid('Orçamento não aprovado ou fora do exercício.');
}
$amount = (int) ($budget->includes_tax ? $invoice->total_minor : $invoice->subtotal_minor);
$remaining = (int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor');
if ($amount > $remaining) {
$this->invalid('Compromisso insuficiente para a factura.');
}
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => 'invoice-'.$invoice->id, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => now()]);
BudgetConsumption::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'invoice_id' => $invoice->id, 'created_by' => $user->id, 'amount_minor' => $amount, 'created_at' => now()]);
$invoice->update(['status' => 'validated', 'validated_by' => $user->id, 'validated_at' => now()]);
app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id, 'budget_id' => $budget->id, 'consumed_minor' => $amount]);
});
}
}
@@ -0,0 +1,55 @@
<?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('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('purchase_order_id')->constrained()->restrictOnDelete();
$table->foreignId('supplier_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->foreignId('validated_by')->nullable()->constrained('users')->restrictOnDelete();
$table->string('number', 100);
$table->date('issued_on');
$table->string('status')->default('pending');
$table->bigInteger('subtotal_minor');
$table->bigInteger('tax_minor');
$table->bigInteger('total_minor');
$table->json('snapshot');
$table->timestamp('validated_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'supplier_id', 'number']);
});
Schema::create('invoice_items', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('invoice_id')->constrained()->restrictOnDelete();
$table->foreignId('purchase_order_item_id')->constrained()->restrictOnDelete();
$table->bigInteger('quantity_milli');
$table->bigInteger('subtotal_minor');
$table->bigInteger('tax_minor');
$table->json('snapshot');
$table->unique(['invoice_id', 'purchase_order_item_id']);
});
Schema::create('budget_consumptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('budget_id')->constrained()->restrictOnDelete();
$table->foreignId('invoice_id')->unique()->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->bigInteger('amount_minor');
$table->timestamp('created_at');
});
}
public function down(): void
{
throw new LogicException('Preservar facturas, conferências e consumo.');
}
};
+9 -1
View File
@@ -15,7 +15,7 @@ Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\com
| 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 |
| Facturas e pagamentos | Registo por empresa, conferência, validação independente e consumo orçamental | Dados bancários, pagamentos, 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 |
@@ -23,6 +23,14 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em
## Verificação actual
Facturas e consumo entregues: migração 000010 aplicada no lote 11 após backup e
restauro verificados de 39 tabelas. Três cenários concorrentes PostgreSQL e
22 testes de facturas aprovados. Suite completa actual: **298 aprovados,
3 ignorados, 2.449 asserções**. Novas tabelas vazias na base real. Revisão visual,
dados bancários, pagamentos, reforços e regras de investimento continuam pendentes.
### Histórico da aplicação do orçamento
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
+60
View File
@@ -1,5 +1,65 @@
# Desenvolvimento do SGC
## Facturas, conferência e consumo orçamental
Implementado `/facturas`: Administração consulta; Finanças regista e outra pessoa
de Finanças valida. Número normalizado único por empresa/fornecedor. Conferência
exacta de quantidade, unidade, preço, IVA e totais com a ordem congelada e a
recepção aceite ainda não facturada. Serviços também exigem aceitação.
Diferenças impedem registo/validação, sem correcção automática do documento.
Facturação parcial com arredondamento acumulado por linha para preservar os
centavos e IVA da ordem. Validação revalida quantidades e conflitos dentro da
transacção com bloqueio da empresa; repetição não duplica lançamentos. Sem
compromisso aprovado do exercício actual, a factura permanece pendente. Na
validação, a parcela facturada converte compromisso em consumo. O disponível
desconta reservas, compromisso remanescente e consumo, sem duplicar despesa
ou libertar saldo ao facturar. Falha de auditoria reverte toda a operação.
Cada documento, orçamento e configuração pertence à empresa correspondente.
A base com/sem IVA é a escolhida pela empresa; não existem dotações globais.
Autores de facturas integram os intervenientes auditados do processo. Facturas,
linhas e consumos não admitem reescrita/eliminação pelos modelos.
`/consulta/facturas` apresenta somente exemplos fictícios, sem consultar ou
gravar dados de negócio; os valores não são configurações predefinidas.
### Verificação e aplicação
**22 testes de facturas aprovados, 82 asserções**: independência, isolamento,
valores, duplicação, concorrência lógica de pendentes, auditoria/rollback,
compromisso obrigatório, serviços, consumo e arredondamento parcial de IVA.
Suite completa actual: **298 aprovados, 3 ignorados e 2.449 asserções**.
PHPStan, Pint, TypeScript, compilação e verificação frontend aprovados.
Três cenários concorrentes PostgreSQL aprovados: excesso de facturação, mesma
validação e facturas complementares. Duas ligações em processos PHP independentes,
observadas simultaneamente à espera de bloqueios, por cenário. Saldos, consumo
e número de facturas validadas confirmados. Base temporária eliminada.
São testes de serviço, não HTTP ou carga.
[Evidência](facturas-concorrencia-postgresql.json).
Migração 000010 aplicada no **lote 11** após backup custom PostgreSQL de
**216.008 bytes** e restauro verificado de **39 tabelas** numa base temporária,
depois eliminada. Dump e manifesto partilharam um snapshot consistente.
Registos de negócio, restrições anteriores e `.env` preservados. Novas tabelas
de facturas, linhas e consumos vazias; sem seeds, contas novas ou alteração de
credenciais. Backup e manifesto privados, fora do Git.
[Evidência sem dados privados](facturas-migracao-000010.json).
### Limitações e próximo passo
Pagamentos, dados bancários com dupla validação, reforços, notas de crédito,
anexos de factura, adiantamentos, compras sem ordem e operações cambiais não
estão implementados. Facturas entre exercícios ou sem compromisso ficam
bloqueadas. Conferência alterada por outra factura bloqueia a validação; ainda
não existe correcção/substituição do registo pendente.
Nova tentativa de acesso ao navegador: `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 do orçamento nem autorização de
compras/pagamentos reais. Próximo passo: dados bancários com dupla validação,
antes de pagamentos; reforços e regras de investimento configuráveis por empresa.
Equipamento continua bloqueado para adjudicação/emissão sem regra própria.
## Aplicação e validação PostgreSQL do orçamento
- Migração 000009 aplicada à base actual no **lote 10**, sem migrações pendentes.
@@ -0,0 +1,33 @@
{
"tested_at_utc": "2026-10-28T12:19:17+00:00",
"separate_database": true,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
{
"name": "over_invoice",
"passed": true,
"overlapping_lock_waits": 2,
"consumed_minor": 6000,
"committed_minor": 4000,
"validated_invoices": 1
},
{
"name": "same_validation",
"passed": true,
"overlapping_lock_waits": 2,
"consumed_minor": 6000,
"committed_minor": 4000,
"validated_invoices": 1
},
{
"name": "complementary_invoices",
"passed": true,
"overlapping_lock_waits": 2,
"consumed_minor": 10000,
"committed_minor": 0,
"validated_invoices": 2
}
],
"passed": true,
"test_database_dropped": true
}
+16
View File
@@ -0,0 +1,16 @@
{
"verified_at_utc": "2026-10-28T12:21:41+00:00",
"migration": "2026_10_28_000010_create_invoices",
"batch": 11,
"backup_created": true,
"backup_bytes": 216008,
"restore_verified": true,
"restored_tables": 39,
"restore_database_dropped": true,
"business_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"invoices_count": 0,
"budget_consumptions_count": 0,
"operational_validation": false
}
+12
View File
@@ -1,5 +1,17 @@
# Continuidade do desenvolvimento do SGC
## Próximo incremento após facturas
Orçamento e facturas já foram implementados e migrados, incluindo conferência
com recepções e consumo. Não repetir as instruções históricas abaixo.
Prosseguir com dados bancários de fornecedores com dupla validação antes de
pagamentos; reforços e regras de investimento ficam em etapas próprias.
Preservar isolamento por empresa e valores configurados por cada cliente.
Não inventar limites de investimento ou tratar os valores fictícios da
demonstração como configuração. Revisão visual pública, autenticada e de
impressão continua pendente por indisponibilidade do navegador. Consultar
`docs/desenvolvimento.md` para resultados e limitações actuais.
## Estado actual após equipamentos e peças
Recepção, stock, saídas, transferências, contagens, ajustes, equipamentos,
+7
View File
@@ -3,6 +3,13 @@
Estado: proposta para decisão empresarial, ainda sem aprovação nem implementação.
Referência funcional: entrega `75ffdb3`. O orçamento continua sem validação.
Actualização: o utilizador autorizou avançar com a abordagem. Dotações, reservas,
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 e pagamentos continuam pendentes.
Estado actual em `docs/desenvolvimento.md`.
## Abordagem recomendada
Implementar primeiro orçamento e reservas; depois compromisso de ordens e
+5
View File
@@ -136,6 +136,11 @@ export function AppSidebar() {
href: '/orcamentos',
icon: FileCheck2,
},
{
title: 'Facturas',
href: '/facturas',
icon: FileCheck2,
},
]}
/>
)}
+1
View File
@@ -9,5 +9,6 @@ export const previewPaths: Record<string, string> = {
stock: '/consulta/stock',
operations: '/consulta/operacoes-stock',
equipment: '/consulta/equipamentos',
invoices: '/consulta/facturas',
appearance: '/consulta/aparencia',
};
+6 -3
View File
@@ -16,6 +16,7 @@ type Budget = {
amount_minor: number;
reserved_minor: number;
committed_minor: number;
consumed_minor: number;
available_minor: number;
created_by: number;
approved_by: number | null;
@@ -49,8 +50,9 @@ export default function Budgets({
<p className="rounded-lg border border-amber-300 p-4 text-sm">
Desenvolvimento. O orçamento continua sem validação
operacional. Centros sem dotação continuam no circuito
anterior, sem controlo orçamental. Facturas e pagamentos
ainda não implementados.
anterior, sem controlo orçamental. Facturas validadas
convertem compromisso em consumo. Pagamentos ainda não
implementados.
</p>
<Panel
title="Preparar dotação"
@@ -158,7 +160,8 @@ export default function Budgets({
</p>
<p>
Reservado: {money(b.reserved_minor)} · Comprometido:{' '}
{money(b.committed_minor)} · Disponível:{' '}
{money(b.committed_minor)} · Consumido:{' '}
{money(b.consumed_minor)} · Disponível:{' '}
{money(b.available_minor)}
</p>
<p className="my-3">{b.reason}</p>
+330
View File
@@ -0,0 +1,330 @@
import { Head, router, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { InventoryPagination } from '@/components/inventory';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { money } from '@/types/procurement';
import type { Page } from '@/types/inventory';
type Source = {
id: number;
description: string;
unit: string;
unit_price_minor: number;
tax_rate: string;
};
type Line = {
purchase_order_item_id: number;
quantity: string;
unit: string;
unit_price: string;
tax_rate: string;
};
type Invoice = {
id: number;
number: string;
issued_on: string;
status: string;
created_by: number;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
snapshot: { supplier: { name: string }; order_number: string };
items: {
id: number;
quantity_milli: number;
subtotal_minor: number;
tax_minor: number;
snapshot: { description: string; unit: string };
}[];
};
export default function Invoices({
invoices,
orders,
canManage,
}: {
invoices: Page<Invoice>;
orders: { id: number; number: string; supplier: string; items: Source[] }[];
canManage: boolean;
}) {
const { auth } = usePage().props;
const [orderId, setOrderId] = useState('');
const [validationError, setValidationError] = useState('');
const [validating, setValidating] = useState<number | null>(null);
const form = useForm({
number: '',
issued_on: '',
subtotal: '',
tax: '',
total: '',
items: [] as Line[],
});
const order = orders.find((o) => String(o.id) === orderId);
return (
<>
<Head title="Facturas" />
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
<PageTitle
eyebrow="SGC · Finanças"
title="Facturas e conferência"
description="Conferência da ordem, recepção aceite e factura. Validação por outra pessoa de Finanças."
/>
<p className="rounded-lg border border-amber-300 p-4 text-sm">
Desenvolvimento. Não autoriza pagamentos. A validação exige
compromisso orçamental aprovado. Diferenças, notas de
crédito e operações entre exercícios ficam bloqueadas.
</p>
{canManage && (
<Panel
title="Registar factura"
description="Transcreva os valores do documento do fornecedor. A conferência exige coincidência exacta; o registo não valida automaticamente a factura."
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((d) => ({
...d,
items: d.items.filter(
(l) => l.quantity !== '',
),
}));
form.post(`/ordens/${orderId}/facturas`, {
onSuccess: () => {
form.reset();
setOrderId('');
},
});
}}
>
<FormErrors errors={form.errors} />
<label>
Ordem
<select
required
className={controlClass}
value={orderId}
onChange={(e) => {
setOrderId(e.target.value);
form.setData(
'items',
orders
.find(
(o) =>
String(o.id) ===
e.target.value,
)
?.items.map((l) => ({
purchase_order_item_id:
l.id,
quantity: '',
unit: l.unit,
unit_price: (
l.unit_price_minor / 100
).toFixed(2),
tax_rate: String(
l.tax_rate,
),
})) ?? [],
);
}}
>
<option value="">Seleccionar ordem</option>
{orders.map((o) => (
<option key={o.id} value={o.id}>
{o.number} · {o.supplier}
</option>
))}
</select>
</label>
<label>
Número do fornecedor
<Input
required
maxLength={100}
value={form.data.number}
onChange={(e) =>
form.setData('number', e.target.value)
}
/>
</label>
<label>
Data da factura
<Input
required
type="date"
value={form.data.issued_on}
onChange={(e) =>
form.setData(
'issued_on',
e.target.value,
)
}
/>
</label>
{form.data.items.map((line, index) => (
<fieldset
key={line.purchase_order_item_id}
className="grid gap-3 rounded border p-4 sm:grid-cols-4"
>
<legend>
{
order?.items.find(
(s) =>
s.id ===
line.purchase_order_item_id,
)?.description
}
</legend>
{(
[
'quantity',
'unit',
'unit_price',
'tax_rate',
] as const
).map((key) => (
<label key={key}>
{
{
quantity:
'Quantidade (vazio para excluir)',
unit: 'Unidade',
unit_price: 'Preço sem IVA',
tax_rate: 'IVA (%)',
}[key]
}
<Input
value={line[key]}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(l, i) =>
i === index
? {
...l,
[key]: e
.target
.value,
}
: l,
),
)
}
/>
</label>
))}
</fieldset>
))}
<p className="text-sm">
Nas facturas parciais o arredondamento é
acumulado por linha para preservar o total da
ordem. Divergências exigem revisão, sem
alteração automática do documento.
</p>
{(['subtotal', 'tax', 'total'] as const).map(
(key) => (
<label key={key}>
{
{
subtotal:
'Subtotal sem IVA (MZN)',
tax: 'IVA (MZN)',
total: 'Total (MZN)',
}[key]
}
<Input
required
inputMode="decimal"
value={form.data[key]}
onChange={(e) =>
form.setData(
key,
e.target.value,
)
}
/>
</label>
),
)}
<Button disabled={!orderId || form.processing}>
Registar para conferência independente
</Button>
</form>
</Panel>
)}
{validationError && (
<p role="alert" className="text-red-700">
{validationError}
</p>
)}
{invoices.data.map((invoice) => (
<Panel
key={invoice.id}
title={`${invoice.number} · ${invoice.snapshot.supplier.name}`}
>
<p>
{invoice.snapshot.order_number} ·{' '}
{invoice.issued_on} ·{' '}
{invoice.status === 'validated'
? 'Validada'
: 'Pendente de validação'}
</p>
<p>
Subtotal {money(invoice.subtotal_minor)} · IVA{' '}
{money(invoice.tax_minor)} · Total{' '}
{money(invoice.total_minor)}
</p>
<ul className="my-3 space-y-2">
{invoice.items.map((l) => (
<li key={l.id}>
{l.snapshot.description} ·{' '}
{l.quantity_milli / 1000} {l.snapshot.unit}{' '}
· {money(l.subtotal_minor + l.tax_minor)}
</li>
))}
</ul>
{canManage &&
invoice.status === 'pending' &&
invoice.created_by !== auth.user.id && (
<Button
disabled={validating !== null}
onClick={() => {
setValidationError('');
setValidating(invoice.id);
router.post(
`/facturas/${invoice.id}/validar`,
{},
{
onError: (errors) =>
setValidationError(
Object.values(
errors,
).join(' '),
),
onFinish: () =>
setValidating(null),
},
);
}}
>
Conferir e validar
</Button>
)}
</Panel>
))}
{invoices.data.length === 0 && (
<p>Sem facturas registadas nesta empresa.</p>
)}
<InventoryPagination page={invoices} />
</div>
</>
);
}
+50
View File
@@ -118,6 +118,7 @@ const navigation = [
{ id: 'stock', title: 'Stock e movimentos', icon: Warehouse },
{ id: 'operations', title: 'Saídas e inventários', icon: Warehouse },
{ id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse },
{ id: 'invoices', title: 'Facturas e conferência', icon: FileText },
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
@@ -126,6 +127,7 @@ 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'] },
{ title: 'Apresentação', ids: ['appearance'] },
];
export default function Preview({
@@ -661,6 +663,54 @@ export default function Preview({
)}
{screen === 'operations' && <OperationsDemo />}
{screen === 'equipment' && <EquipmentDemo />}
{screen === 'invoices' && (
<Panel
title="Factura parcial · exemplo fictício"
description="Sem consultas ou gravações de negócio. Os valores são apenas exemplos; cada empresa define as suas dotações e base de IVA."
>
<p className="mb-4 text-sm">
FT-DEMO-01 · Fornecedor fictício ·
OC-DEMO-01 · Validada por uma segunda pessoa
de Finanças.
</p>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt>Quantidade autorizada e aceite</dt>
<dd>10 unidades</dd>
</div>
<div>
<dt>Quantidade facturada</dt>
<dd>6 unidades</dd>
</div>
<div>
<dt>Valor desta factura</dt>
<dd>600,00 MZN (exemplo sem IVA)</dd>
</div>
<div>
<dt>
Compromisso antes da conferência
</dt>
<dd>1.000,00 MZN</dd>
</div>
<div>
<dt>Consumo após validação</dt>
<dd>600,00 MZN</dd>
</div>
<div>
<dt>Compromisso remanescente</dt>
<dd>400,00 MZN</dd>
</div>
</dl>
<p className="mt-4 text-sm">
O disponível não aumenta: o compromisso
converte-se em consumo. Preço, unidade, IVA
e quantidades são novamente conferidos no
servidor. Sem compromisso aprovado, a
factura permanece pendente. Não autoriza
pagamentos.
</p>
</Panel>
)}
{screen === 'appearance' && (
<>
<Panel
+2 -1
View File
@@ -7,7 +7,7 @@ use Inertia\Inertia;
// Public visual consultation. No business records or authenticated identities.
Route::get('consulta', function (Request $request) {
$screen = $request->query('ecra', 'overview');
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'appearance'];
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'appearance'];
return Inertia::render('preview/index', [
'auth' => ['user' => null],
@@ -19,6 +19,7 @@ Route::get('consulta/{screen}', function (string $screen) {
$screens = ['requisicoes' => 'requests', 'fornecedores' => 'suppliers', 'cotacoes' => 'quotes', 'conflitos' => 'conflicts', 'ordens' => 'orders', 'recepcoes' => 'receipts', 'stock' => 'stock', 'aparencia' => 'appearance'];
$screens['operacoes-stock'] = 'operations';
$screens['equipamentos'] = 'equipment';
$screens['facturas'] = 'invoices';
abort_unless(isset($screens[$screen]), 404);
return Inertia::render('preview/index', ['auth' => ['user' => null], 'initialScreen' => $screens[$screen]]);
+4
View File
@@ -5,6 +5,7 @@ use App\Http\Controllers\CompanyController;
use App\Http\Controllers\ConflictDeclarationController;
use App\Http\Controllers\EquipmentController;
use App\Http\Controllers\GoodsReceiptController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\PurchaseController;
use App\Http\Controllers\QuotationController;
use App\Http\Controllers\RequisitionController;
@@ -24,6 +25,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::get('facturas', [InvoiceController::class, 'index'])->name('invoices.index');
Route::post('ordens/{order}/facturas', [InvoiceController::class, 'store'])->name('invoices.store');
Route::post('facturas/{invoice}/validar', [InvoiceController::class, 'validateInvoice'])->name('invoices.validate');
Route::get('orcamentos', [BudgetController::class, 'index'])->name('budgets.index');
Route::post('orcamentos', [BudgetController::class, 'store'])->name('budgets.store');
Route::post('orcamentos/{budget}/aprovar', [BudgetController::class, 'approve'])->name('budgets.approve');
+30
View File
@@ -0,0 +1,30 @@
<?php
use App\Models\Invoice;
use App\Models\User;
use App\Services\InvoiceWorkflow;
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_invoice_'.$argv[4]]);
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
$user = User::query()->findOrFail($argv[2]);
auth()->guard()->setUser($user);
try {
app(InvoiceWorkflow::class)->validate($user, Invoice::query()->findOrFail($argv[3]));
$status = 200;
} catch (ValidationException) {
$status = 422;
}
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
+121
View File
@@ -0,0 +1,121 @@
<?php
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\Invoice;
use App\Services\InvoiceWorkflow;
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/InvoiceFixture.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 (['over_invoice', 'same_validation', 'complementary_invoices'] as $scenario) {
$f = invoiceFixture();
$second = $f['invoice'];
if ($scenario !== 'same_validation') {
$data = $f['data'];
$data['number'] = 'FT-PG-2';
if ($scenario === 'complementary_invoices') {
$data['items'][0]['quantity'] = '0.400';
$data['subtotal'] = $data['total'] = '40.00';
}
$second = app(InvoiceWorkflow::class)->register($f['author'], $f['order'], $data);
}
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $f['tenant']->id)->lockForUpdate()->first();
$processes = [];
foreach ([$f['invoice'], $second] as $index => $invoice) {
$process = new Process([PHP_BINARY, __DIR__.'/invoice-worker.php', $database, (string) $f['validator']->id, (string) $invoice->id, $index === 0 ? 'A' : 'B'], base_path(), $workerEnv);
$process->setTimeout(40);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_invoice_A','sgc_invoice_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);
$consumed = (int) BudgetConsumption::query()->sum('amount_minor');
$committed = (int) BudgetEntry::query()->sum('committed_minor');
$expectedConsumption = $scenario === 'complementary_invoices' ? 10000 : 6000;
$count = $scenario === 'complementary_invoices' ? 2 : 1;
if ($statuses !== ($scenario === 'over_invoice' ? [200, 422] : [200, 200]) || $consumed !== $expectedConsumption || $committed !== 10000 - $expectedConsumption || BudgetConsumption::query()->count() !== $count || Invoice::query()->where('status', 'validated')->count() !== $count || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
throw new RuntimeException('Resultado inesperado: '.json_encode([$statuses, $consumed, $committed]));
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'consumed_minor' => $consumed, 'committed_minor' => $committed, 'validated_invoices' => $count];
}
$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/facturas-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;
+195
View File
@@ -0,0 +1,195 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\Invoice;
use App\Models\User;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\InvoiceWorkflow;
use App\Services\ReceiptWorkflow;
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->finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->validator = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($this->f['receiver']);
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
$this->actingAs($this->finance);
$this->budget = Budget::query()->create(['tenant_id' => $this->finance->tenant_id, 'cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 20000, 'includes_tax' => true, 'created_by' => $this->finance->id, 'approved_by' => $this->validator->id, 'reason' => 'Fixture financeira isolada']);
DB::transaction(function () {
app(ConflictGuard::class)->lockCompany($this->finance);
app(BudgetWorkflow::class)->record($this->finance, $this->f['r'], 'reserve', 10000, 10000);
app(BudgetWorkflow::class)->record($this->finance, $this->f['r'], 'commit', 10000, 10000);
});
$this->data = ['number' => 'FT TEST 01', 'issued_on' => today()->format('Y-m-d'), 'subtotal' => '60.00', 'tax' => '0.00', 'total' => '60.00', 'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '0.600', 'unit' => 'cx', 'unit_price' => '100.00', 'tax_rate' => '0']]];
});
function registerTestInvoice(object $test): Invoice
{
$test->actingAs($test->finance);
return app(InvoiceWorkflow::class)->register($test->finance, $test->f['order'], $test->data);
}
test('partial invoice converts commitment to consumption exactly once', function () {
$invoice = registerTestInvoice($this);
expect(BudgetConsumption::query()->count())->toBe(0);
$this->actingAs($this->validator);
app(InvoiceWorkflow::class)->validate($this->validator, $invoice);
app(InvoiceWorkflow::class)->validate($this->validator, $invoice);
expect($invoice->refresh()->status)->toBe('validated')
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(4000)
->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(6000)
->and(BudgetConsumption::query()->count())->toBe(1);
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.available_minor', 10000)->where('budgets.0.consumed_minor', 6000));
});
test('author cannot validate own invoice', function () {
$invoice = registerTestInvoice($this);
$this->post('/facturas/'.$invoice->id.'/validar')->assertForbidden();
expect(BudgetConsumption::query()->count())->toBe(0);
});
test('number normalized per supplier prevents duplicates', function () {
registerTestInvoice($this);
$this->data['number'] = ' ft test 01 ';
expect(fn () => registerTestInvoice($this))->toThrow(ValidationException::class);
expect(Invoice::query()->count())->toBe(1);
});
test('differences in invoice values block registration', function (string $field) {
if (in_array($field, ['unit_price', 'tax_rate', 'unit', 'quantity'])) {
$this->data['items'][0][$field] = match ($field) {
'unit_price' => '101.00', 'tax_rate' => '16', 'unit' => 'un', default => '1.001'
};
} else {
$this->data[$field] = '61.00';
}
expect(fn () => registerTestInvoice($this))->toThrow(ValidationException::class);
expect(Invoice::query()->count())->toBe(0);
})->with(['unit_price', 'tax_rate', 'unit', 'quantity', 'subtotal', 'tax', 'total']);
test('pending overlapping invoices are rechecked at independent validation', function () {
$first = registerTestInvoice($this);
$this->data['number'] = 'FT TEST 02';
$second = registerTestInvoice($this);
$this->actingAs($this->validator);
app(InvoiceWorkflow::class)->validate($this->validator, $first);
expect(fn () => app(InvoiceWorkflow::class)->validate($this->validator, $second))->toThrow(ValidationException::class);
expect($second->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(1);
});
test('failed audit rolls back invoice validation and consumption', function () {
$invoice = registerTestInvoice($this);
$this->actingAs($this->validator);
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed'));
expect(fn () => app(InvoiceWorkflow::class)->validate($this->validator, $invoice))->toThrow(RuntimeException::class);
expect($invoice->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0)
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(10000);
});
test('foreign finance cannot read or validate invoice', function () {
$invoice = registerTestInvoice($this);
$other = receiptFixture();
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($user)->post('/facturas/'.$invoice->id.'/validar')->assertNotFound();
$this->get('/facturas')->assertOk()->assertInertia(fn ($page) => $page->has('invoices.data', 0));
});
test('warehouse cannot access or register invoices', function () {
$this->actingAs($this->f['receiver'])->get('/facturas')->assertForbidden();
$this->post('/ordens/'.$this->f['order']->id.'/facturas', $this->data)->assertForbidden();
});
test('registered documents cannot be rewritten or deleted', function () {
$invoice = registerTestInvoice($this);
expect(fn () => $invoice->update(['total_minor' => 1]))->toThrow(LogicException::class);
expect(fn () => $invoice->items()->firstOrFail()->delete())->toThrow(LogicException::class);
expect(fn () => $invoice->refresh()->delete())->toThrow(LogicException::class);
});
test('complementary invoices consume the exact commitment without increasing available budget', function () {
$first = registerTestInvoice($this);
$this->actingAs($this->validator);
app(InvoiceWorkflow::class)->validate($this->validator, $first);
$this->data['number'] = 'FT TEST 02';
$this->data['items'][0]['quantity'] = '0.400';
$this->data['subtotal'] = $this->data['total'] = '40.00';
$second = registerTestInvoice($this);
$this->actingAs($this->validator);
app(InvoiceWorkflow::class)->validate($this->validator, $second);
expect((int) BudgetEntry::query()->sum('committed_minor'))->toBe(0)
->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(10000);
$request = $this->f['r']->replicate();
$request->number = 'REQ-SECOND';
$request->save();
expect(fn () => DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->validator, $request, 'reserve', 10001, 10001)))->toThrow(ValidationException::class);
});
test('invoice without a committed budget remains pending', function () {
$other = receiptFixture();
app(ReceiptWorkflow::class)->confirm($other['receiver'], $other['order'], receiptPayload($other, '1.000'));
$author = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$validator = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($author);
$data = $this->data;
$data['items'][0]['purchase_order_item_id'] = $other['line']->id;
$invoice = app(InvoiceWorkflow::class)->register($author, $other['order'], $data);
$this->actingAs($validator);
expect(fn () => app(InvoiceWorkflow::class)->validate($validator, $invoice))->toThrow(ValidationException::class);
expect($invoice->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0);
});
test('future invoice date and foreign order line are rejected', function (string $case) {
if ($case === 'future') {
$this->data['issued_on'] = today()->addDay()->format('Y-m-d');
} else {
$this->data['items'][0]['purchase_order_item_id'] = PHP_INT_MAX;
}
expect(fn () => registerTestInvoice($this))->toThrow(ValidationException::class);
})->with(['future', 'foreign_line']);
test('reception acceptance is required before invoicing services', function () {
$other = receiptFixture('1.000', 'service');
$author = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($author);
$data = $this->data;
$data['items'][0]['purchase_order_item_id'] = $other['line']->id;
expect(fn () => app(InvoiceWorkflow::class)->register($author, $other['order'], $data))->toThrow(ValidationException::class);
});
test('partial invoices preserve cents and VAT across cumulative rounding', function () {
// Configure only the isolated frozen-order fixture before registering any invoice.
DB::table('purchase_order_items')->where('id', $this->f['line']->id)->update(['unit_price_minor' => 10001, 'tax_rate' => '16.00', 'subtotal_minor' => 10001, 'tax_minor' => 1600, 'total_minor' => 11601]);
DB::table('purchase_orders')->where('id', $this->f['order']->id)->update(['subtotal_minor' => 10001, 'tax_minor' => 1600, 'total_minor' => 11601]);
DB::table('budget_entries')->where('requisition_id', $this->f['r']->id)->where('kind', 'commit')->update(['committed_minor' => 11601]);
foreach ([['0.333', '33.30', '5.33', '38.63'], ['0.333', '33.31', '5.33', '38.64'], ['0.334', '33.40', '5.34', '38.74']] as $index => [$quantity, $subtotal, $tax, $total]) {
$this->data['number'] = 'FT-ROUND-'.$index;
$this->data['items'][0]['quantity'] = $quantity;
$this->data['items'][0]['unit_price'] = '100.01';
$this->data['items'][0]['tax_rate'] = '16';
$this->data['subtotal'] = $subtotal;
$this->data['tax'] = $tax;
$this->data['total'] = $total;
$invoice = registerTestInvoice($this);
$this->actingAs($this->validator);
app(InvoiceWorkflow::class)->validate($this->validator, $invoice);
}
expect((int) Invoice::query()->sum('subtotal_minor'))->toBe(10001)
->and((int) Invoice::query()->sum('tax_minor'))->toBe(1600)
->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(11601)
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(0);
});
test('public invoice example is available without exposing company invoices', function () {
auth()->forgetGuards();
$this->get('/consulta/facturas')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'invoices')->where('auth.user', null)->missing('invoices')->missing('orders'));
});
+32
View File
@@ -0,0 +1,32 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\User;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\InvoiceWorkflow;
use App\Services\ReceiptWorkflow;
use Illuminate\Support\Facades\DB;
require_once __DIR__.'/ReceiptFixture.php';
/** Synthetic data: receiptFixture enforces an isolated test database. */
function invoiceFixture(): array
{
$f = receiptFixture();
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '1.000'));
$author = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$validator = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
auth()->guard()->setUser($author);
Budget::query()->create(['tenant_id' => $author->tenant_id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 20000, 'includes_tax' => true, 'created_by' => $author->id, 'approved_by' => $validator->id, 'reason' => 'Somente para teste isolado']);
DB::transaction(function () use ($author, $f) {
app(ConflictGuard::class)->lockCompany($author);
app(BudgetWorkflow::class)->record($author, $f['r'], 'reserve', 10000, 10000);
app(BudgetWorkflow::class)->record($author, $f['r'], 'commit', 10000, 10000);
});
$data = ['number' => 'FT-PG-1', 'issued_on' => today()->format('Y-m-d'), 'subtotal' => '60.00', 'tax' => '0.00', 'total' => '60.00', 'items' => [['purchase_order_item_id' => $f['line']->id, 'quantity' => '0.600', 'unit' => 'cx', 'unit_price' => '100.00', 'tax_rate' => '0']]];
$invoice = app(InvoiceWorkflow::class)->register($author, $f['order'], $data);
return [...$f, 'author' => $author, 'validator' => $validator, 'invoice' => $invoice, 'data' => $data];
}
+105
View File
@@ -0,0 +1,105 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000010.
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_000010_create_invoices';
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/invoice-000010-'.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, 'invoices_count' => (int) $pdo->query('SELECT count(*) FROM invoices')->fetchColumn(), 'budget_consumptions_count' => (int) $pdo->query('SELECT count(*) FROM budget_consumptions')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/facturas-migracao-000010.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));
}
}