Implementa conferencia de facturas e consumo por empresa
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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.']);
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -136,6 +136,11 @@ export function AppSidebar() {
|
||||
href: '/orcamentos',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
{
|
||||
title: 'Facturas',
|
||||
href: '/facturas',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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]]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user