From 332cb9f1b77f03bcb71cdaab0439e6904d53a956 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 14:26:18 +0200 Subject: [PATCH] Implementa conferencia de facturas e consumo por empresa --- README.md | 10 +- app/Http/Controllers/BudgetController.php | 4 +- app/Http/Controllers/InvoiceController.php | 39 +++ app/Models/BudgetConsumption.php | 16 + app/Models/Invoice.php | 42 +++ app/Models/InvoiceItem.php | 22 ++ app/Services/BudgetWorkflow.php | 2 + app/Services/ConflictGuard.php | 1 + app/Services/InvoiceWorkflow.php | 150 ++++++++ .../2026_10_28_000010_create_invoices.php | 55 +++ docs/compatibilidade.md | 10 +- docs/desenvolvimento.md | 60 ++++ docs/facturas-concorrencia-postgresql.json | 33 ++ docs/facturas-migracao-000010.json | 16 + docs/orientacoes-continuidade.md | 12 + docs/proposta-regras-financeiras.md | 7 + resources/js/components/app-sidebar.tsx | 5 + resources/js/lib/preview-navigation.ts | 1 + resources/js/pages/budgets/index.tsx | 9 +- resources/js/pages/invoices/index.tsx | 330 ++++++++++++++++++ resources/js/pages/preview/index.tsx | 50 +++ routes/preview.php | 3 +- routes/web.php | 4 + tests/Concurrency/invoice-worker.php | 30 ++ tests/Concurrency/run-invoices.php | 121 +++++++ tests/Feature/InvoiceTest.php | 195 +++++++++++ tests/Support/InvoiceFixture.php | 32 ++ tests/Support/apply-invoice-migration.php | 105 ++++++ 28 files changed, 1356 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/InvoiceController.php create mode 100644 app/Models/BudgetConsumption.php create mode 100644 app/Models/Invoice.php create mode 100644 app/Models/InvoiceItem.php create mode 100644 app/Services/InvoiceWorkflow.php create mode 100644 database/migrations/2026_10_28_000010_create_invoices.php create mode 100644 docs/facturas-concorrencia-postgresql.json create mode 100644 docs/facturas-migracao-000010.json create mode 100644 resources/js/pages/invoices/index.tsx create mode 100644 tests/Concurrency/invoice-worker.php create mode 100644 tests/Concurrency/run-invoices.php create mode 100644 tests/Feature/InvoiceTest.php create mode 100644 tests/Support/InvoiceFixture.php create mode 100644 tests/Support/apply-invoice-migration.php diff --git a/README.md b/README.md index 1044e1a..76e50ea 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index b30c674..6843494 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -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]; })]); } diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php new file mode 100644 index 0000000..1e68396 --- /dev/null +++ b/app/Http/Controllers/InvoiceController.php @@ -0,0 +1,39 @@ +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(); + } +} diff --git a/app/Models/BudgetConsumption.php b/app/Models/BudgetConsumption.php new file mode 100644 index 0000000..1f07c38 --- /dev/null +++ b/app/Models/BudgetConsumption.php @@ -0,0 +1,16 @@ + $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 */ + public function items(): HasMany + { + return $this->hasMany(InvoiceItem::class); + } +} diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php new file mode 100644 index 0000000..0ab379b --- /dev/null +++ b/app/Models/InvoiceItem.php @@ -0,0 +1,22 @@ + $snapshot */ +class InvoiceItem extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array']; + } +} diff --git a/app/Services/BudgetWorkflow.php b/app/Services/BudgetWorkflow.php index d524154..ea7bf3d 100644 --- a/app/Services/BudgetWorkflow.php +++ b/app/Services/BudgetWorkflow.php @@ -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.']); } diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php index 9c212d6..71931c5 100644 --- a/app/Services/ConflictGuard.php +++ b/app/Services/ConflictGuard.php @@ -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 = []; diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php new file mode 100644 index 0000000..f1afd82 --- /dev/null +++ b/app/Services/InvoiceWorkflow.php @@ -0,0 +1,150 @@ +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> $inputs + * @return list> + */ + 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 $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]); + }); + } +} diff --git a/database/migrations/2026_10_28_000010_create_invoices.php b/database/migrations/2026_10_28_000010_create_invoices.php new file mode 100644 index 0000000..f26e3d7 --- /dev/null +++ b/database/migrations/2026_10_28_000010_create_invoices.php @@ -0,0 +1,55 @@ +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.'); + } +}; diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md index 5f42b5c..1791810 100644 --- a/docs/compatibilidade.md +++ b/docs/compatibilidade.md @@ -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 diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index 02c59c9..f10ce37 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -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. diff --git a/docs/facturas-concorrencia-postgresql.json b/docs/facturas-concorrencia-postgresql.json new file mode 100644 index 0000000..d04470f --- /dev/null +++ b/docs/facturas-concorrencia-postgresql.json @@ -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 +} diff --git a/docs/facturas-migracao-000010.json b/docs/facturas-migracao-000010.json new file mode 100644 index 0000000..cd890e1 --- /dev/null +++ b/docs/facturas-migracao-000010.json @@ -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 +} diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md index 7096313..20d508c 100644 --- a/docs/orientacoes-continuidade.md +++ b/docs/orientacoes-continuidade.md @@ -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, diff --git a/docs/proposta-regras-financeiras.md b/docs/proposta-regras-financeiras.md index 8551b1c..357ed90 100644 --- a/docs/proposta-regras-financeiras.md +++ b/docs/proposta-regras-financeiras.md @@ -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 diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 9e3d436..7db9c7f 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -136,6 +136,11 @@ export function AppSidebar() { href: '/orcamentos', icon: FileCheck2, }, + { + title: 'Facturas', + href: '/facturas', + icon: FileCheck2, + }, ]} /> )} diff --git a/resources/js/lib/preview-navigation.ts b/resources/js/lib/preview-navigation.ts index 2907aff..a25cc09 100644 --- a/resources/js/lib/preview-navigation.ts +++ b/resources/js/lib/preview-navigation.ts @@ -9,5 +9,6 @@ export const previewPaths: Record = { stock: '/consulta/stock', operations: '/consulta/operacoes-stock', equipment: '/consulta/equipamentos', + invoices: '/consulta/facturas', appearance: '/consulta/aparencia', }; diff --git a/resources/js/pages/budgets/index.tsx b/resources/js/pages/budgets/index.tsx index 7839acf..6c819e7 100644 --- a/resources/js/pages/budgets/index.tsx +++ b/resources/js/pages/budgets/index.tsx @@ -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({

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.

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)}

{b.reason}

diff --git a/resources/js/pages/invoices/index.tsx b/resources/js/pages/invoices/index.tsx new file mode 100644 index 0000000..38f163c --- /dev/null +++ b/resources/js/pages/invoices/index.tsx @@ -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; + 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(null); + const form = useForm({ + number: '', + issued_on: '', + subtotal: '', + tax: '', + total: '', + items: [] as Line[], + }); + const order = orders.find((o) => String(o.id) === orderId); + return ( + <> + +
+ +

+ 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. +

+ {canManage && ( + +
{ + e.preventDefault(); + form.transform((d) => ({ + ...d, + items: d.items.filter( + (l) => l.quantity !== '', + ), + })); + form.post(`/ordens/${orderId}/facturas`, { + onSuccess: () => { + form.reset(); + setOrderId(''); + }, + }); + }} + > + + + + + {form.data.items.map((line, index) => ( +
+ + { + order?.items.find( + (s) => + s.id === + line.purchase_order_item_id, + )?.description + } + + {( + [ + 'quantity', + 'unit', + 'unit_price', + 'tax_rate', + ] as const + ).map((key) => ( + + ))} +
+ ))} +

+ 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. +

+ {(['subtotal', 'tax', 'total'] as const).map( + (key) => ( + + ), + )} + + +
+ )} + {validationError && ( +

+ {validationError} +

+ )} + {invoices.data.map((invoice) => ( + +

+ {invoice.snapshot.order_number} ·{' '} + {invoice.issued_on} ·{' '} + {invoice.status === 'validated' + ? 'Validada' + : 'Pendente de validação'} +

+

+ Subtotal {money(invoice.subtotal_minor)} · IVA{' '} + {money(invoice.tax_minor)} · Total{' '} + {money(invoice.total_minor)} +

+
    + {invoice.items.map((l) => ( +
  • + {l.snapshot.description} ·{' '} + {l.quantity_milli / 1000} {l.snapshot.unit}{' '} + · {money(l.subtotal_minor + l.tax_minor)} +
  • + ))} +
+ {canManage && + invoice.status === 'pending' && + invoice.created_by !== auth.user.id && ( + + )} +
+ ))} + {invoices.data.length === 0 && ( +

Sem facturas registadas nesta empresa.

+ )} + +
+ + ); +} diff --git a/resources/js/pages/preview/index.tsx b/resources/js/pages/preview/index.tsx index 279a4e1..b04df39 100644 --- a/resources/js/pages/preview/index.tsx +++ b/resources/js/pages/preview/index.tsx @@ -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' && } {screen === 'equipment' && } + {screen === 'invoices' && ( + +

+ FT-DEMO-01 · Fornecedor fictício · + OC-DEMO-01 · Validada por uma segunda pessoa + de Finanças. +

+
+
+
Quantidade autorizada e aceite
+
10 unidades
+
+
+
Quantidade facturada
+
6 unidades
+
+
+
Valor desta factura
+
600,00 MZN (exemplo sem IVA)
+
+
+
+ Compromisso antes da conferência +
+
1.000,00 MZN
+
+
+
Consumo após validação
+
600,00 MZN
+
+
+
Compromisso remanescente
+
400,00 MZN
+
+
+

+ 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. +

+
+ )} {screen === 'appearance' && ( <> 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]]); diff --git a/routes/web.php b/routes/web.php index f2e6b2f..2dc6dc3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Concurrency/invoice-worker.php b/tests/Concurrency/invoice-worker.php new file mode 100644 index 0000000..57a163e --- /dev/null +++ b/tests/Concurrency/invoice-worker.php @@ -0,0 +1,30 @@ +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); diff --git a/tests/Concurrency/run-invoices.php b/tests/Concurrency/run-invoices.php new file mode 100644 index 0000000..5c4a70a --- /dev/null +++ b/tests/Concurrency/run-invoices.php @@ -0,0 +1,121 @@ + '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; diff --git a/tests/Feature/InvoiceTest.php b/tests/Feature/InvoiceTest.php new file mode 100644 index 0000000..fa45bd7 --- /dev/null +++ b/tests/Feature/InvoiceTest.php @@ -0,0 +1,195 @@ +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')); +}); diff --git a/tests/Support/InvoiceFixture.php b/tests/Support/InvoiceFixture.php new file mode 100644 index 0000000..02e9164 --- /dev/null +++ b/tests/Support/InvoiceFixture.php @@ -0,0 +1,32 @@ +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]; +} diff --git a/tests/Support/apply-invoice-migration.php b/tests/Support/apply-invoice-migration.php new file mode 100644 index 0000000..705a340 --- /dev/null +++ b/tests/Support/apply-invoice-migration.php @@ -0,0 +1,105 @@ +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)); + } +}