commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-29 22:03:09 +02:00
parent 57f6b3cc36
commit 413c201d97
54 changed files with 2753 additions and 14 deletions
+6
View File
@@ -65,3 +65,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Activação deliberada, depois de aplicar/verificar o esquema e conceder acessos.
COMMON_PAYABLES_ENABLED=false
COMMON_PAYABLES_HISTORY_ENABLED=false
COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED=false
PAYABLE_SOURCE_INSTANCE=
+1
View File
@@ -43,3 +43,4 @@ yarn-error.log
/boost.json
/opencode.json
/opencode.jsonc
/storage/framework/payables/
+12
View File
@@ -6,6 +6,16 @@ Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existent
## Executar localmente
Conferência dos pagamentos pendentes após importação: [entrega e provas](docs/conferencia-pendentes-importados.md). Implementada em testes, com migração 000023 e activação ainda por aplicar; demonstração actual preservada.
### Explorar a aplicação sem palavra-passe
Na demonstração local isolada, abrir **http://127.0.0.1:8005/avaliar** e escolher **Entrar como proprietário**. A página «Explorar Compras», acessível também pela ligação acima da aplicação, reúne os 20 módulos/páginas principais. Disponibiliza ainda os seis perfis operacionais sintéticos para experimentar os respectivos passos sem procurar credenciais. Os dados e alterações desta demonstração persistem entre visitas.
A entrada pertence exclusivamente à base QA local existente de obrigações, com empresa e exemplos sintéticos; não altera contas/dados da instalação normal em 8015. As regras de aprovação independente e de integridade permanecem. O proprietário tem o perfil administrativo e as três acções do ensaio de obrigações; os passos reservados a Procurement, Finanças ou Armazém usam os perfis de avaliação disponíveis. Não se declara que todos os percursos já tenham exemplos completos nesta base.
Retomar o servidor existente, sem apagar dados: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\laragon\www\sgf-tempo\scripts\payables-demo.ps1 start`. Se já estiver activo, basta abrir a ligação. Verificação HTTP: `scripts/evaluation-access-check.mjs`; resultados em `docs/proofs/evaluation-access.json` (20 páginas, seis perfis, CSRF, recusa de identificadores arbitrários e saída; sem operações de negócio). A revisão visual do utilizador é separada.
Endereço local deste projecto: **http://127.0.0.1:8015**. Use `SERVER_PORT=8015`, `APP_URL=http://127.0.0.1:8015` e `SESSION_COOKIE=procuraflow_session` no `.env` para evitar colisões com outros projectos. O comando `php artisan serve` respeita `SERVER_PORT`.
As dependências e o `.env` devem estar configurados. O projecto suporta a ligação PostgreSQL existente; os testes usam SQLite em memória.
@@ -23,6 +33,8 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
## Entrega actual
- Passagem explícita de pagamentos históricos para a obrigação comum: principal integral, confirmados deduzidos uma vez, pendentes/rejeitados preservados e validação distinta. Migração 000022 aplicada e activação histórica concluída em `compras_dev`, após backup/restauro e preservação conferida; sem incorporação automática. A demonstração em 8005 mantém o seu cenário anterior. Não executa pagamentos nem confirma pendentes após a passagem. Ver [entrega, aplicação e provas](docs/passagem-historico-pagamentos.md).
- Regras de investimento próprias por empresa: tecto por processo, base de IVA
e vigência explícitos, com duas aprovações administrativas distintas do autor.
Equipamento exige também validação técnica, Finanças e reserva em dotação de
+2 -1
View File
@@ -6,6 +6,7 @@ use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Services\InvoiceWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -17,7 +18,7 @@ class InvoiceController extends Controller
{
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]);
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]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]);
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
+10 -1
View File
@@ -25,8 +25,17 @@ class RequisitionController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])]]);
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]);
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
if (($filters['view'] ?? 'all') === 'mine') {
$query->where('requester_id', $request->user()->id);
} elseif (($filters['view'] ?? 'all') === 'approvals') {
$eligible = (clone $query)->where('status', 'pending')->get()
->filter(fn (Requisition $requisition) => $requisition->canDecide($request->user()))->modelKeys();
$query->whereIn('id', $eligible);
} elseif (($filters['view'] ?? 'all') === 'decided') {
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $request->user()->id)->whereIn('decision', ['approved', 'rejected']));
}
if (! empty($filters['search'])) {
$query->where('number', 'like', '%'.$filters['search'].'%');
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Services\SupplierPayables;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Tempo\Finance\Payables\Workspace;
final class SupplierPayablesController extends Controller
{
public function index(Request $r, SupplierPayables $s): JsonResponse
{
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']);
return response()->json($s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null))->header('Cache-Control', 'no-store');
}
public function page(Request $r, SupplierPayables $s): Response
{
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']);
$data = $s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null);
$company = Tenant::query()->findOrFail($r->user()->tenant_id)->name;
return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $r->user()->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store');
}
public function command(Request $r, string $action, SupplierPayables $s): JsonResponse
{
abort_if(str_ends_with($action, '_history') && ! SupplierPayables::historyEnabled(), 403, 'Passagem de histórico não activada nesta instalação.');
$d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']);
abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422);
try {
return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
} catch (\DomainException $e) {
abort(409, $e->getMessage());
} catch (\InvalidArgumentException $e) {
abort(422, $e->getMessage());
}
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Services\SupplierPayables;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -41,6 +42,7 @@ class HandleInertiaRequests extends Middleware
'auth' => [
'user' => $request->user(),
],
'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
+5
View File
@@ -20,6 +20,11 @@ class AppServiceProvider extends ServiceProvider
$package = base_path('../sgf-tempo/packages/budget-control');
}
require_once $package.'/bootstrap.php';
$payables = config('common-payables.path');
if (! is_file($payables.'/bootstrap.php') && ! $this->app->environment('production')) {
$payables = base_path('../sgf-tempo/packages/payables');
}
require_once $payables.'/bootstrap.php';
}
/**
+22 -2
View File
@@ -30,6 +30,11 @@ class ExternalPaymentWorkflow
public function outstanding(Invoice $invoice): int
{
$canonical = app(SupplierPayables::class)->balance($invoice);
if ($canonical !== null) {
return $canonical;
}
return (int) $invoice->total_minor - (int) ExternalPayment::query()->where('invoice_id', $invoice->id)->where('status', 'confirmed')->sum('amount_minor');
}
@@ -59,6 +64,7 @@ class ExternalPaymentWorkflow
/** @param array<string,mixed> $data */
public function register(User $user, Invoice $invoice, array $data): ExternalPayment
{
abort_if(app(SupplierPayables::class)->principal($invoice) !== null, 403, 'Pagamento de obrigação do novo ensaio fora do âmbito.');
$this->actor($user, $invoice->tenant_id);
return DB::transaction(function () use ($user, $invoice, $data) {
@@ -66,6 +72,7 @@ class ExternalPaymentWorkflow
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
$this->actor($user, $invoice->tenant_id);
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
abort_if(app(SupplierPayables::class)->principal($invoice) !== null, 403, 'Pagamento de obrigação do novo ensaio fora do âmbito.');
$reference = Str::upper(Str::squish($data['external_reference']));
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
$canonical = ['invoice_id' => $invoice->id, 'bank_id' => (int) $data['supplier_bank_detail_id'], 'amount_minor' => $amount, 'paid_on' => $data['paid_on'], 'evidence_reference' => trim($data['evidence_reference'])];
@@ -102,7 +109,9 @@ class ExternalPaymentWorkflow
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
$this->actor($user, $payment->tenant_id);
$invoice = Invoice::query()->lockForUpdate()->findOrFail($payment->invoice_id);
$historical = app(SupplierPayables::class)->assertHistoricalReview($user, $invoice);
$payment = ExternalPayment::query()->lockForUpdate()->findOrFail($payment->id);
abort_if($payment->created_by === $user->id, 403);
if ($payment->status !== 'pending') {
if ($payment->status !== $decision) {
$this->invalid('Conferência já decidida; não pode ser reescrita.');
@@ -122,8 +131,19 @@ class ExternalPaymentWorkflow
$this->invalid('O autor do registo deixou de ser elegível.');
}
}
$payment->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => $reason, 'reviewed_at' => now()]);
$this->audit($user, $invoice, $payment, 'payment.'.$decision);
$write = function () use ($payment, $decision, $user, $reason, $invoice) {
$payment->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => $reason, 'reviewed_at' => now()]);
$this->audit($user, $invoice, $payment, 'payment.'.$decision);
};
if ($historical) {
try {
app(SupplierPayables::class)->engine($user)->decideImportedPayment($invoice->id, $payment->id, $write);
} catch (\DomainException $e) {
abort(409, $e->getMessage());
}
} else {
$write();
}
});
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tempo\Finance\Payables\Payables;
final class SupplierPayables
{
public static function enabled(): bool
{
$isolated = app()->environment(['testing', 'payable-testing']) && DB::connection()->getDriverName() === 'pgsql'
&& (bool) preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName());
return ($isolated || config('common-payables.enabled') === true)
&& is_string(config('common-payables.source_instance')) && trim(config('common-payables.source_instance')) !== ''
&& Schema::hasTable('pa_obligations') && Schema::hasTable('pa_access');
}
/** @return array<string,int> */
private function scope(User $u): array
{
return ['tenant_id' => (int) $u->tenant_id, 'company_id' => 0, 'unit_id' => 0];
}
public function can(User $u, string $action): bool
{
return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists();
}
public static function historyEnabled(): bool
{
return self::enabled() && config('common-payables.history_enabled') === true
&& Schema::hasTable('pa_history_imports') && Schema::hasTable('pa_imported_payments');
}
public function assertHistoricalReview(User $user, Invoice $invoice): bool
{
if ($this->principal($invoice) === null) {
return false;
}
abort_unless(self::historyDecisionsEnabled() && $this->can($user, 'validate'), 403, 'Conferência de pendentes importados não activada.');
return true;
}
public static function historyDecisionsEnabled(): bool
{
return self::historyEnabled() && config('common-payables.history_decisions_enabled') === true && Schema::hasTable('pa_payment_decisions');
}
/**
* Original payment facts only; no encrypted references or bank data.
*
* @return list<array<string,mixed>>
*/
private function paymentHistory(User $user, int $invoiceId): array
{
$result = [];
$payments = DB::table('external_payments')->where(['tenant_id' => $user->tenant_id, 'invoice_id' => $invoiceId])->orderBy('id')->get();
foreach ($payments as $payment) {
$audit = fn (string $action, int $actor, string $status) => DB::table('audit_logs')->where('tenant_id', $user->tenant_id)
->where('action', $action)->where('user_id', $actor)->where('after->payment_id', $payment->id)
->where('after->invoice_id', $invoiceId)->where('after->amount_minor', $payment->amount_minor)->where('after->status', $status)->orderByDesc('id')->first(['id']);
$registration = $audit('payment.registered', (int) $payment->created_by, 'pending');
$decision = $payment->status === 'pending' ? null : $audit('payment.'.$payment->status, (int) $payment->reviewed_by, $payment->status);
if (! $registration || ($payment->status !== 'pending' && ! $decision)) {
throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.');
}
$result[] = ['id' => (int) $payment->id, 'company' => (int) $payment->tenant_id, 'document' => (int) $payment->invoice_id,
'amount_minor' => (int) $payment->amount_minor, 'currency' => $payment->currency, 'status' => $payment->status,
'created_by' => (int) $payment->created_by, 'paid_on' => $payment->paid_on, 'registration_audit_id' => (int) $registration->id,
'reviewed_by' => $payment->reviewed_by === null ? null : (int) $payment->reviewed_by, 'reviewed_at' => $payment->reviewed_at,
'decision_audit_id' => $decision ? (int) $decision->id : null];
}
return $result;
}
/** @return array<string,mixed> */
public function document(User $u, int $id): array
{
abort_unless(self::enabled() && auth()->id() === $u->id, 403);
$invoice = Invoice::query()->where('tenant_id', $u->tenant_id)->findOrFail($id);
$order = DB::table('purchase_orders')->where(['id' => $invoice->purchase_order_id, 'tenant_id' => $u->tenant_id])->first();
$supplier = DB::table('suppliers')->where(['id' => $invoice->supplier_id, 'tenant_id' => $u->tenant_id])->first();
$audit = DB::table('audit_logs')->where('tenant_id', $u->tenant_id)->where('action', 'invoice.validated')->where('after->invoice_id', $id)->orderByDesc('id')->first();
return ['local_id' => $id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $u->tenant_id, 'document' => $id, 'number' => $invoice->number,
'supplier_id' => $supplier?->id, 'supplier_name' => $invoice->snapshot['supplier']['name'] ?? null, 'order_id' => $order?->id, 'currency' => $invoice->snapshot['currency'] ?? null,
'issued_on' => $invoice->issued_on, 'subtotal_minor' => (int) $invoice->subtotal_minor, 'tax_minor' => (int) $invoice->tax_minor, 'total_minor' => (int) $invoice->total_minor,
'status' => $invoice->status, 'validated_by' => $invoice->validated_by, 'validated_at' => $invoice->validated_at, 'validation_audit_id' => $audit?->id,
'lines' => $invoice->items()->orderBy('id')->get()->map(fn ($l) => ['id' => $l->id, 'order_line' => $l->purchase_order_item_id, 'quantity_milli' => (int) $l->quantity_milli, 'subtotal_minor' => (int) $l->subtotal_minor, 'tax_minor' => (int) $l->tax_minor, 'snapshot' => $l->snapshot])->all(),
'payment_count' => DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count()];
}
public function engine(User $u): Payables
{
abort_unless(self::enabled() && auth()->id() === $u->id && $u->tenant_id !== null, 403, 'Obrigações não activadas nesta instalação ou acesso indisponível.');
$s = $this->scope($u);
$person = DB::table('pa_access')->where($s)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
abort_unless($person, 403);
return new Payables(DB::connection(), $u->id, $person, $s, function (string $action) use ($u, $s, $person) {
abort_unless(auth()->id() === $u->id, 403);
app(ConflictGuard::class)->lockCompany($u);
app(CurrentActor::class)->lock($u, [PurchasingRole::Finance, PurchasingRole::Admin]);
abort_unless(DB::table('pa_access')->where($s)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.');
abort_unless($this->can($u, 'view') && $this->can($u, $action), 403);
}, fn (int $id) => $this->document($u, $id), self::historyEnabled() ? fn (int $id) => $this->paymentHistory($u, $id) : null);
}
/** @return array<string,mixed> */
public function listing(User $u, ?int $invoiceId = null): array
{
$engine = $this->engine($u);
$d = $engine->listing(Invoice::query()->where('tenant_id', $u->tenant_id)->pluck('id')->all());
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
$d['history_import_available'] = self::historyEnabled();
$d['history_decisions_available'] = self::historyDecisionsEnabled();
if ($invoiceId !== null) {
$invoice = Invoice::query()->where('tenant_id', $u->tenant_id)->findOrFail($invoiceId);
$document = null;
foreach ($d['documents'] as $candidate) {
if ((int) $candidate['local_id'] === $invoice->id) {
$document = $candidate;
break;
}
}
abort_unless($document, 404);
$sourceIds = DB::table('pa_sources')->where($this->scope($u))
->where('source_key', Payables::identity($document))->pluck('id')->map(fn ($id) => (int) $id)->all();
$d['documents'] = [$document];
$d['proposals'] = $d['proposals']->filter(fn ($p) => in_array((int) $p->source_id, $sourceIds, true))->values();
$d['obligations'] = array_values(array_filter($d['obligations'], fn ($o) => in_array((int) $o['source_id'], $sourceIds, true)));
$d['events'] = $d['events']->filter(fn ($event) => in_array((int) $event->source_id, $sourceIds, true))->values();
}
return $d;
}
public function principal(Invoice $invoice): ?int
{
$position = $this->recognisedPosition($invoice);
return $position ? (int) $position['principal_minor'] : null;
}
public function balance(Invoice $invoice): ?int
{
$position = $this->recognisedPosition($invoice);
return $position ? (int) $position['balance_minor'] : null;
}
/** @return array<string,mixed>|null */
private function recognisedPosition(Invoice $invoice): ?array
{
if (! self::enabled()) {
if (Schema::hasTable('pa_obligations') && Schema::hasTable('pa_sources')) {
abort_if(DB::table('pa_sources as s')->join('pa_obligations as o', 'o.source_id', '=', 's.id')
->where('s.tenant_id', $invoice->tenant_id)->where('s.company_id', 0)->where('s.unit_id', 0)
->where('s.local_source_id', $invoice->id)->exists(), 409, 'Núcleo desactivado com obrigação incorporada; saldo exige transição explícita.');
}
return null;
}
$u = auth()->user();
abort_unless($u instanceof User, 403);
abort_unless((int) $invoice->tenant_id === (int) $u->tenant_id, 403);
$s = $this->scope($u);
$identity = Payables::identity(['instance' => config('common-payables.source_instance'), 'company' => (int) $invoice->tenant_id, 'document' => $invoice->id]);
$row = DB::table('pa_sources as s')->join('pa_obligations as o', 'o.source_id', '=', 's.id')->where('s.tenant_id', $s['tenant_id'])->where('s.company_id', 0)->where('s.unit_id', 0)->where('s.source_key', $identity)->where('s.local_source_id', $invoice->id)->select('o.principal_minor', 'o.source_revision')->first();
if (! $row) {
abort_if(DB::table('pa_sources as s')->join('pa_obligations as o', 'o.source_id', '=', 's.id')
->where('s.tenant_id', $s['tenant_id'])->where('s.company_id', 0)->where('s.unit_id', 0)
->where('s.local_source_id', $invoice->id)->exists(), 409, 'Identidade da instalação alterada; reconciliar origem antes de calcular saldo.');
return null;
}
abort_unless($this->can($u, 'view'), 403);
try {
$position = $this->engine($u)->positionForSource($invoice->id);
} catch (\DomainException $e) {
abort(409, $e->getMessage());
}
abort_unless($position && ! $position['source_conflict'] && $position['balance_minor'] !== null, 409, 'Origem ou histórico alterado; principal preservado e saldo pendente de revisão.');
return $position;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
return ['path' => env('COMMON_PAYABLES_PATH', base_path('packages/payables')), 'source_instance' => env('PAYABLE_SOURCE_INSTANCE', ''), 'enabled' => env('COMMON_PAYABLES_ENABLED', false), 'history_enabled' => env('COMMON_PAYABLES_HISTORY_ENABLED', false), 'history_decisions_enabled' => env('COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED', false)];
@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Tempo\Finance\Payables\PayablesSchema;
return new class extends Migration
{
public function up(): void
{
PayablesSchema::install();
}
public function down(): void
{
throw new LogicException('Preservar obrigações, decisões e histórico; reversão exige plano próprio.');
}
};
@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Tempo\Finance\Payables\HistorySchema;
return new class extends Migration
{
public function up(): void
{
HistorySchema::install();
}
public function down(): void
{
throw new LogicException('Preservar a passagem de responsabilidade e os pagamentos históricos.');
}
};
@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Tempo\Finance\Payables\PaymentDecisionSchema;
return new class extends Migration
{
public function up(): void
{
PaymentDecisionSchema::install();
}
public function down(): void
{
throw new LogicException('Preservar decisões e histórico financeiro.');
}
};
+29
View File
@@ -0,0 +1,29 @@
# Conferência dos pagamentos pendentes importados
29/10/2026. Continuação solicitada pelo proprietário: desenvolver a ligação seguinte entre pagamentos originais e obrigação comum. Identificador: `PAYABLE-IMPORTED-DECISION-v1`.
O circuito existente de conferência pode confirmar ou rejeitar pagamentos que estavam pendentes na passagem de histórico. A confirmação reduz o saldo uma única vez; a rejeição conserva o saldo. Não cria pagamentos nem executa transferências.
## Preservação e saldo
A importação original e os seus pagamentos permanecem imutáveis. Cada decisão posterior acrescenta um facto em `pa_payment_decisions`, ligado ao pagamento importado, com sequência por obrigação, fotografia mínima, interveniente e hashes anterior/seguinte. O núcleo comum valida a cadeia e confronta-a com o histórico original actual antes de fornecer o saldo. Alterações estranhas à decisão deixam o saldo indisponível.
O pagamento original e a sua auditoria são actualizados pelo serviço de conferência já existente. A escrita do facto comum e do evento pertence à mesma transacção: uma falha reverte todos os efeitos. Bloqueios de empresa e origem serializam operações; unicidade por pagamento e sequência impede segunda decisão. Repetir a mesma decisão não gera novo abatimento; uma decisão contrária é recusada.
Mantêm-se revalidação do perfil Financeiro, separação do autor, direito explícito `validate`, banco actual com duas validações elegíveis, exercício actual para confirmação, conflito de interesses e limite do saldo actualizado. A importação não concede direitos adicionais. Novos registos de pagamento sobre obrigações reconhecidas continuam recusados.
## Instalação
Migração aditiva preparada: `2026_10_29_000023_create_payable_payment_decisions.php`, uma tabela com protecção contra actualização/eliminação em PostgreSQL e SQLite. Não foi aplicada em `compras_dev` nem na demonstração 8005 nesta entrega.
Capacidade desactivada por defeito: `COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED=false`. Depende da importação histórica activa, do esquema novo e dos direitos existentes. Não se alteraram configurações, contas, concessões ou dados da instalação local/demonstração. A leitura de decisões já registadas conserva o saldo mesmo quando esta nova capacidade de escrita é desactivada; desactivar o núcleo/histórico completo continua a exigir transição explícita.
## Verificação
Regressão geral: 526 testes aprovados, 2 ignorados, 4 383 asserções. PHPStan sem erros e Pint aprovados. Após o último ajuste de tratamento de conflitos/indicação da capacidade, 20 testes específicos passaram com 109 asserções; PHPStan repetido sem erros. Interface PHP com sintaxe válida; nenhuma alteração de recursos React exige novo build nesta entrega.
- Testes de confirmação/rejeição, repetição, decisão contrária, independência, concessão retirada, capacidade desactivada, falha intermédia, histórico adulterado, saldo zero, limite de saldo e proibição de novos pagamentos.
- PostgreSQL descartável: principal 11 600 MZN; confirmados originais 3 000, pendente 1 000, rejeitado 500 → saldo inicial 8 600. Dois processos confirmam o mesmo pendente: uma decisão/evento, confirmado total 4 000, saldo 7 600. Importação original preservada, trigger de imutabilidade verificado e falha injectada com reversão completa.
- Prova: `docs/proofs/payable-payment-decisions-pg.json`. Bases e utilizadores temporários removidos no final. Revisão visual desta capacidade não realizada.
Para disponibilizar esta conferência na instalação local, o passo seguinte é aplicar/verificar a migração com cópia de recuperação e activar a opção. A demonstração 8005 conserva o cenário que o proprietário está a avaliar.
+21
View File
@@ -1,5 +1,26 @@
# Desenvolvimento do SGC
## Reforço da integração de obrigações — 29 de Outubro de 2026
Continuação do ensaio `COMMON-PAYABLE-SOURCE-SIM-v1`, descrito em
[entrega comum](../../sgf-tempo/docs/SUPPLIER_PAYABLE_DELIVERY.md).
`ExternalPaymentWorkflow` passa a revalidar o principal canónico dentro das
transacções de registo e conferência, depois dos bloqueios da empresa e da
factura, antes de aceitar repetições ou gravar decisões. Evita confiar apenas
na consulta anterior à espera pelo bloqueio. Preserva as alterações existentes.
Acrescentado teste em `ExternalPaymentTest.php`: mesmo com instância de origem
configurada, as quatro rotas de consulta/preparação/validação recusam acesso
fora da base isolada e o pagamento legado continua funcional. Testes de
pagamentos: **34 aprovados, 113 asserções**. PHPStan sem erros e Pint dos dois
ficheiros alterados aprovado. Este teste não simula concorrência PostgreSQL.
Regressão completa executada: **499 aprovados, 2 ignorados, 4 147 asserções**.
Os dois ignorados correspondem à cobertura MFA já pendente na configuração.
Sem migrações, seeds, alterações de contas ou dados reais, novos pagamentos
operacionais, commit, push ou publicação. Revisão visual e aceitação do ensaio
continuam pendentes; este reforço não activa obrigações em produção.
## Estado consolidado após abertura e actas — 28 de Outubro de 2026
Referência: commit `07e775c` e [validação técnica](validacao-tecnica.md).
+19
View File
@@ -0,0 +1,19 @@
{
"activated_at_utc": "2026-10-29T19:34:42+00:00",
"database": "compras_dev",
"migration": "000022",
"history_enabled": true,
"existing_tables_verified": 72,
"existing_records_unchanged_except_new_audit": true,
"accounts_and_grants_unchanged": true,
"source_identity_unchanged": true,
"activation_audit_id": 3,
"history_imports": 0,
"payment_links": 0,
"automatic_import": false,
"backup_restore_verified": true,
"backup_bytes": 367910,
"backup_sha256": "1f29bdaf0ea0379475c26e78f07cffe2ca31401ab216031b499c2562bafad7dc",
"environment_sha256_after": "6014bcf47860188fcbba612715fce95ce9cbbd25e62f399f7b2ac03614a2d8ec",
"public_deployment": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"applied_at_utc": "2026-10-29T19:33:27+00:00",
"database": "compras_dev",
"migration": {
"migration": "2026_10_29_000022_create_payable_history_imports",
"batch": 23
},
"existing_tables_verified": 72,
"existing_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"new_table_counts": {
"pa_history_imports": 0,
"pa_imported_payments": 0
},
"append_only_triggers": 2,
"activation_enabled": false,
"accounts_or_grants_created": false,
"restore_verified": true,
"backup_bytes": 367910,
"backup_sha256": "1f29bdaf0ea0379475c26e78f07cffe2ca31401ab216031b499c2562bafad7dc"
}
+67
View File
@@ -0,0 +1,67 @@
# Factura → obrigação: navegação integrada e próxima ligação
29/10/2026. Continuação local durante a avaliação do proprietário.
## Activação local posterior
Após a orientação para prosseguir, o percurso foi activado em `compras_dev`, no ambiente local. Conferida a única conta existente, #1, administrativa, empresa #1; concedidas explicitamente consulta/preparação/validação em `pa_access`, com pessoa estável `local-user-1` e auditoria `supplier_payables.local_activated`. Nome, palavra-passe, perfil e dados anteriores da conta foram preservados. Não se criou nova conta nem se dispensa revisor distinto do preparador.
`COMMON_PAYABLES_ENABLED=true` e identidade persistente própria foram guardados apenas no `.env` local, depois de novo backup privado de 367 719 bytes, restaurado e comparado nas 72 tabelas. O servidor público e as configurações das demonstrações não foram alterados. A aplicação tem zero facturas, zero pagamentos e zero obrigações; não houve importação, reconhecimento ou operação financeira automática. Evidência sem segredos: `obrigacoes-activacao-local.json`.
Acrescentada ligação «Obrigações a fornecedores» no menu Finanças quando a consulta está autorizada pelo servidor. A navegação usa carregamento de página completo, porque a página comum não é uma resposta Inertia. A revogação de consulta retira o menu e mantém a recusa no servidor. Testes de integração/navegação e pagamentos: 39 aprovados, 152 asserções; build, TypeScript e PHPStan do middleware aprovados. O PHPUnit fixa a desactivação e identidade vazia por omissão, para não herdar a activação de `.env` nos restantes testes.
O arranque adicional do servidor em 8015 foi recusado pela revisão automática (`blocked by policy`, sem motivo específico devolvido); não se contornou essa recusa. A configuração activada foi verificada num processo novo. A demonstração em 8005 continua disponível, com a nova ligação de menu após actualizar a página.
Para desactivar esta configuração enquanto não houver obrigações, definir somente `COMMON_PAYABLES_ENABLED=false`; conservar a identidade da instalação e a auditoria. Não alterar identidade ou apagar direitos/histórico como mecanismo de reversão. Havendo obrigações, preparar a transição da responsabilidade pelo saldo antes de qualquer desactivação. A activação local não conclui a migração dos pagamentos históricos nem autoriza uso financeiro real.
## Aplicação autorizada à instalação local
Após pedido expresso «aplique», 000021 foi aplicada em `compras_dev`, lote **22**, em 29/10/2026. Backup privado de 346 030 bytes, SHA-256 `481a4d2a7430e0a43c0a7ba4c190f22e5a2520a5423587f90579bcfb917d373a`, restaurado e comparado numa base separada antes da aplicação; base de restauro removida. As 65 tabelas anteriores, os seus registos, restrições e `.env` ficaram preservados. Sete tabelas novas vazias e seis triggers append-only conferidos. Aplicação e verificação feitas em transacção, com limites de bloqueio/execução, sem seeds.
Evidência sanitizada: `obrigacoes-migracao-000021.json`. Backup e manifesto privados em `storage/app/private/backups/supplier-payables-000021-20261029-185002-93c089/`. A activação continua desligada e nenhum acesso/conta foi criado. As demonstrações persistentes e o servidor público não foram alterados. A aplicação do esquema não incorpora facturas nem constitui obrigações automaticamente.
## Continuação: instalação do esquema e ativação explícita
Preparada a migração `2026_10_29_000021_create_supplier_payables.php`, utilizando `PayablesSchema` do pacote comum. O instalador QA usa a mesma definição: não existe uma segunda cópia do esquema. PostgreSQL e SQLite têm protecção append-only para origens, propostas, obrigações, comandos, eventos e outbox. Instalação sobre tabelas existentes ou parciais recusa reconciliação implícita. O instalador QA pode reconhecer a instalação completa da migração e confere os seis triggers PostgreSQL.
`COMMON_PAYABLES_ENABLED=false` é o estado inicial. A activação explícita exige `PAYABLE_SOURCE_INSTANCE` persistente e esquema instalado; não concede qualquer direito. As entradas QA existentes mantêm o reconhecimento isolado anterior. O saldo passa a conferir também a identidade canónica da origem; mudar a identidade de instalação com obrigações existentes provoca conflito em vez de cair no saldo antigo.
Estado anterior à autorização de aplicação: migração apenas preparada. A aplicação posterior em `compras_dev` está registada acima; as demonstrações persistentes, `.env`, contas, concessões e facturas reais continuam inalterados. A interface mantém identificação de ensaio, sem activação financeira real.
Facturas com pagamentos anteriores mantêm o saldo do percurso existente. A incorporação automática continua recusada; a transição com transporte explícito de histórico fica para incremento próprio. Nesta passagem não se regista ou executa nenhum novo pagamento real, nem se desbloqueiam pagamentos posteriores sobre uma obrigação incorporada.
Novos testes `SupplierPayableIntegrationTest.php`: activação no esquema de aplicação, reconhecimento único, principal de 6 000 centavos e vencimento nulo, saldo preservado de 2 000 após pagamento confirmado de 4 000 sobre factura de 6 000, recusa de segunda dívida, controlo de acesso/empresa, mudança de identidade e imutabilidade. Com facturas/pagamentos: 60 testes aprovados, 215 asserções. Todas as operações destes testes são em SQLite em memória, não na demonstração.
Prova PostgreSQL `payables-demo.ps1 proof` aprovada nos dois hosts, com instalação da nova migração em Compras e instalação QA comum em SGF: obrigação única de 11 600 MZN, vencimento desconhecido, concorrência, falhas/recuperação, versões e isolamento; orçamento/stock/pagamentos conservados. Bases e roles descartáveis removidas. PHPStan do adaptador/controlador aprovado e formatação Pint dos ficheiros deste incremento aplicada. A primeira execução PHPStan com 128 MB tinha esgotado memória; repetição com limite de 512 MB aprovada, sem configuração global.
Regressão completa após este incremento: **503 aprovados, 2 ignorados, 4 167 asserções** (505 testes). Mantêm-se dois testes anteriormente ignorados; não foram apresentados como executados. Sem novo build necessário neste incremento exclusivamente PHP/configuração/migração; o build do incremento anterior mantém-se aprovado.
## Alteração entregue
Cada factura apresenta uma ligação para `/obrigacoes-fornecedores?invoice={id}` quando o utilizador tem consulta do núcleo comum na instalação sintética. A página mostra apenas o documento seleccionado, as propostas, a obrigação e os eventos da respectiva identidade canónica. O regresso leva a `/facturas`. O endpoint de consulta aceita o mesmo filtro; a consulta geral mantém-se.
O filtro usa empresa do utilizador e `Payables::identity`, não apenas o número visível ou o identificador local. Parâmetros inválidos recebem 422; facturas inexistentes ou fora da empresa não são adoptadas. Sem filtro, o comportamento anterior é preservado. Não há reconhecimento, pagamento, consumo de stock/orçamento ou alteração de histórico por navegação.
## Conferência da integração existente
- Compras já fornece factura, fornecedor, ordem, moeda, valores/linhas e a referência da auditoria de conferência ao núcleo único. Não se redigitam esses dados no reconhecimento.
- O núcleo conserva uma identidade por instância/empresa/documento e uma revisão da origem. A conferência documental e a validação da obrigação são decisões separadas.
- O principal comum já é consultado pelo saldo de Compras quando a origem está incorporada e compatível. Origem alterada ou pagamentos associados provocam conflito; não se presume outro saldo.
- A incorporação continua limitada às bases QA reconhecidas. A consulta integrada não publica nem instala o esquema na base habitual de Compras.
- Vencimento desconhecido permanece pendência visível; não impede por si só reconhecer o principal, não é uma data inventada e não produz atraso igual a zero.
## Próximo incremento funcional a delimitar
Integrar a incorporação no percurso normal exige transportar o esquema QA para migrações de aplicação, configurar a identidade persistente da instalação e os direitos por empresa, e definir a transição das facturas já existentes. A configuração será verificável antes de aplicação; não executar migrações reais por consequência deste relatório.
Facturas sem pagamentos podem seguir a origem conferida e o reconhecimento existente. Para facturas com pagamentos, o plano deve preservar separadamente principal original, pagamentos confirmados e pendentes, e atribuir uma só responsabilidade pelo saldo. Nunca importar o saldo líquido como principal e deduzir pagamentos novamente. O caminho de pagamentos existente é conservado até essa transição estar implementada e verificada.
Revisão documental de vencimento é outro incremento: evidência, autor, versão anterior e histórico, sobre a mesma obrigação. Não inferir data a partir de texto livre nem transformar revisão documental em nova dívida. Não incluir cancelamento de ordens, estornos, notas de crédito ou operações bancárias neste delta de navegação.
## Verificação
- `scripts/invoice-payable-link-check.mjs`: consulta da factura presente na demonstração, filtro, regresso, identificadores inválidos/inexistentes e igualdade integral da consulta/histórico antes e depois. Resultado em `proofs/invoice-payable-link.json`. A demonstração tem uma factura: esta prova HTTP não declara teste de duas origens distintas ou de concorrência.
- Regressão dirigida: `InvoiceTest.php` e `ExternalPaymentTest.php`, 56 aprovados, 195 asserções, SQLite em memória. A primeira tentativa não executou os testes por falta do driver; repetição com `-d extension=pdo_sqlite -d extension=sqlite3` aprovada, sem alteração global do PHP.
- Build frontend aprovado, sem novas dependências. Sintaxe PHP e `git diff --check` aprovados.
- PHPStan dos dois ficheiros PHP alterados aprovado, sem erros, com limite de memória de 512 MB por comando; a tentativa inicial com 128 MB esgotou memória, sem alterar configuração global.
- Sem declaração de revisão visual, aceitação, publicação ou checkpoint. Dados e acessos de avaliação conservados.
+48
View File
@@ -0,0 +1,48 @@
# Menus de Compras — primeira aplicação do benchmark
29/10/2026. Pedido do proprietário: renovar os menus antes de retomar o trabalho funcional pendente. Referência fornecida: `referencias/benchmark-menus-20261029.txt`. Este incremento aplica a organização da navegação; não declara implementado todo o benchmark.
## Entregue
A aplicação autenticada usa agora um menu lateral por tarefas e uma barra superior com pesquisa do menu, atalho de nova requisição e conta. As secções podem ser expandidas, a área actual abre automaticamente e a ligação corrente fica destacada. Pesquisa sem distinção de acentos/maiúsculas, limpeza e mensagem de ausência de resultados. Não é uma pesquisa global de documentos.
| Secção | Percursos existentes reunidos |
| --- | --- |
| Painel inicial | Visão geral e indicadores existentes |
| Requisições | Nova, minhas, meus rascunhos, todas as autorizadas e planeamento |
| Aprovações | Próxima decisão elegível para o utilizador e histórico das suas decisões de requisições |
| Consultas e adjudicações | Cotações/comparação, adjudicações/ordens e revisão/cancelamento existente |
| Fornecedores | Cadastro e documentos |
| Recepções e armazém | Recepções/divergências, artigos/stock, saídas/inventários, equipamentos/peças |
| Finanças | Facturas, obrigações autorizadas, pagamentos externos, orçamentos/reforços e investimento |
| Administração e controlo | Empresa/equipa/centros, políticas de admissão/recomendação e conflitos |
O layout real é `SgcShell`, usado por `AppLayout`; o componente antigo `AppSidebar` não é a navegação activa. A renovação foi feita no layout efectivamente utilizado. As consultas públicas mantêm o seu layout próprio e as ligações anteriores. O núcleo comum de obrigações continua com o seu ecrã próprio e regresso às facturas; a sua ligação usa carregamento completo para não receber uma página HTML como modal Inertia.
## Listas ligadas ao servidor
`/requisicoes?view=mine` limita ao autor da sessão. `view=approvals` reutiliza `canDecide`, incluindo empresa, departamento, próxima etapa, autor distinto, decisões anteriores e conflitos. `view=decided` filtra decisões efectivamente registadas pelo utilizador. Todos os conjuntos partem da consulta autorizada existente. Filtros de estado/referência e paginação mantêm o conjunto escolhido. `status=draft&view=mine` abre os rascunhos pessoais; não altera as regras actuais para guardar rascunhos.
Menu filtrado pelos perfis existentes; obrigações só aparece quando o servidor confirma consulta. Não foram criadas contas ou concessões, alterados limites, regras fiscais, processos de aprovação ou dados de negócio. Não há migração nova neste incremento.
## Verificação
- 44 testes aprovados, 539 asserções: requisições, vistas pessoais, elegibilidade, isolamento entre empresas, decisões, entrada pública e demonstrações.
- Build, TypeScript, lint do âmbito, Pint e PHPStan do controlador aprovados; `git diff --check` sem erros.
- Navegador local: inspecção visual a 1280 × 900 e 390 × 844; menu, expansão, pesquisa «obrigacoes» sem acentos, ausência/limpeza de resultados, destaque único da ligação visível, vistas de rascunhos/aprovações e navegação para obrigações/regresso às facturas. Sem deslocamento horizontal nos modos verificados. Dimensão temporária reposta no final.
- Drawer móvel com foco contido e botão de fecho; ligações fecham o drawer. Sem operações de negócio durante a prova visual.
- Capturas em `proofs/screenshots/menu-desktop.png` e `menu-mobile.png`. Esta é verificação técnica visual, não aceitação atribuída ao proprietário.
## Trabalho preservado para continuação
### Conferência das ligações após renovação
`scripts/menu-links-check.mjs` verifica a própria definição do menu contra a instalação local: **94 entradas somadas nos seis perfis**, páginas de conta/aparência, recusas no servidor para empresa/finanças sem direito, vistas pessoais, ordenação dos parâmetros e opção activa. No processo de exemplo, conferidas 12 ligações internas, incluindo requisição, orçamento, cotações, adjudicação, ordem, recepção, facturas, pagamentos, relatório, admissão e recomendação. Factura → obrigação → facturas e igualdade do histórico antes/depois também aprovadas. Evidência em `proofs/menu-links.json`; sem comandos de negócio.
Encontrados e corrigidos dois destaques de navegação: `/requisicoes/{id}/relatorio` pertence a cotações/comparação e `/requisicoes/{id}/adjudicacao` pertence a adjudicações/ordens. Antes abriam a página correcta, mas o menu destacava requisições. Os casos têm agora asserções de regressão na conferência. Build actualizado aprovado.
Esta conferência é HTTP e de correspondência das rotas do menu. A nova inspecção visual não pôde ser repetida porque o controlo do navegador devolveu `browser.user is unavailable`, incluindo ao recuperar o separador existente; não se apresenta a prova visual do incremento anterior como nova verificação. As capturas anteriores permanecem referências da entrega inicial.
Retomar o transporte do histórico de pagamentos na integração factura → obrigação, preservando principal, confirmações/pendências e responsabilidade única pelo saldo. A aplicação do esquema e a activação local anteriores ficam preservadas.
O formulário proposto no benchmark continua a ser evolução funcional própria: guardar dados incompletos, revisão antes de submeter, aviso de alterações não guardadas, anexos e campos/validações adicionais. Contratos/acordos, catálogo unificado, multimoeda, pesquisa global e novos relatórios ainda exigem implementação. Os menus desta entrega conduzem a capacidades existentes; não representam essas capacidades futuras como concluídas.
+22
View File
@@ -0,0 +1,22 @@
{
"activated_at_utc": "2026-10-29T18:53:31+00:00",
"database": "compras_dev",
"user_id": 1,
"tenant_id": 1,
"actions": [
"view",
"prepare",
"validate"
],
"enabled": true,
"source_instance": "compras-local-aa0dbeec75b98ca65246756541f75363",
"backup_restore_verified": true,
"backup_bytes": 367719,
"backup_sha256": "f6df149cd91fae24dbe233952e04064b0325e8d968182c7e76e624cb7b3d93cd",
"existing_account_preserved": true,
"invoice_count_unchanged": true,
"payment_count_unchanged": true,
"obligations": 0,
"automatic_import": false,
"public_deployment": false
}
+27
View File
@@ -0,0 +1,27 @@
{
"applied_at_utc": "2026-10-29T18:50:28+00:00",
"database": "compras_dev",
"migration": {
"migration": "2026_10_29_000021_create_supplier_payables",
"batch": 22
},
"existing_tables_verified": 65,
"existing_records_unchanged": true,
"existing_constraints_preserved": true,
"env_unchanged": true,
"new_table_counts": {
"pa_access": 0,
"pa_sources": 0,
"pa_proposals": 0,
"pa_obligations": 0,
"pa_commands": 0,
"pa_events": 0,
"pa_outbox": 0
},
"append_only_triggers": 6,
"activation_enabled": false,
"accounts_or_grants_created": false,
"restore_verified": true,
"backup_bytes": 346030,
"backup_sha256": "481a4d2a7430e0a43c0a7ba4c190f22e5a2520a5423587f90579bcfb917d373a"
}
+32
View File
@@ -1,5 +1,37 @@
# Continuidade do desenvolvimento do SGC
**Conferência posterior dos pendentes implementada, 29/10/2026:** confirmação/rejeição ligada ao núcleo comum com decisão imutável, saldo único e escrita original atómica. Provas SQLite e PostgreSQL descartável, incluindo dois pedidos simultâneos e reversão de falha, aprovadas. Ver [entrega](conferencia-pendentes-importados.md). Migração 000023 e opção de conferência preparadas, ainda não aplicadas/activadas em `compras_dev` ou 8005; importação local anterior preservada. Substitui a pendência de desenvolver esta conferência, sem novos pagamentos/transferências.
**Aplicação/activação histórica local concluída, 29/10/2026:** após «continue», 000022 aplicada em `compras_dev`, lote 23, e flag histórica activada, após backup/restauro das 72 tabelas. Registos/restrições preservados, duas tabelas novas vazias e dois triggers verificados. Activação auditada sem novas contas/concessões ou alteração de identidade; zero facturas/pagamentos/obrigações e nenhuma incorporação automática. Ver `historico-migracao-000022.json`, `historico-activacao-local.json` e [relatório](passagem-historico-pagamentos.md). Demo 8005 permanece sem esta migração/activação, confirmada em leitura; restantes demos e público preservados. Substitui apenas as indicações históricas de aplicação/activação pendentes abaixo. Novos pagamentos e decisão de pendentes depois da passagem continuam incremento próprio.
**Verificação final do histórico:** regressão geral com 517 aprovados, 2 ignorados e 4 337 asserções; últimos reforços de identidade cobertos por 11 testes específicos/64 asserções. PHPStan/Pint aprovados. PostgreSQL histórico (concorrência, falha/retoma, imutabilidade) e regressão anterior nas duas composições aprovados; bases descartáveis removidas. 000022/flag histórica permanecem por aplicar/activar na base local. Conta, três concessões anteriores e dados locais preservados. Ver [entrega do histórico](passagem-historico-pagamentos.md).
**Passagem de pagamentos históricos implementada em testes, 29/10/2026:** [âmbito, arquitectura e provas](passagem-historico-pagamentos.md), `PAYABLE-HISTORY-IMPORT-v1`. Principal bruto preservado, confirmados deduzidos uma vez, pendentes/rejeitados conservados sem abatimento; fotografia preparada e validada por pessoa distinta, conflito em alterações e responsabilidade única do saldo. Fonte comum; migração 000022 preparada, flag histórica desligada por omissão, sem aplicação às bases habituais/demos ou novas concessões. Prova PostgreSQL com concorrência, falha/retoma, imutabilidade e originais preservados aprovada (`proofs/payable-history-pg.json`). Novos pagamentos/confirmação de pendentes após passagem, reconciliação e contabilidade continuam separados.
**Ligações conferidas após o novo menu:** 94 entradas em seis perfis e 12 ligações internas do processo, filtro pessoal/estado, factura → obrigação/regresso e histórico conservado. Prova em `proofs/menu-links.json`, repetível por `scripts/menu-links-check.mjs`. Corrigido o destaque do relatório (cotações) e adjudicação (ordens); ambas as páginas já abriam correctamente. Build actualizado aprovado. Inspecção visual nova indisponível por falha da ligação de controlo do navegador; não declarar nova revisão visual. Sem alterações de negócio, migração ou concessões. Retomar depois a transição dos pagamentos históricos.
**Menus renovados a partir do benchmark, 29/10/2026:** [entrega e continuação](menus-benchmark-20261029.md). Layout autenticado real `SgcShell` com menu lateral, pesquisa, secções por tarefa e adaptação móvel; consultas públicas preservadas. Novos conjuntos de requisições pessoais, aprovações elegíveis e decisões efectivas, sem alargar direitos. 44 testes/539 asserções, build, TypeScript, lint, PHPStan e verificação visual desktop/móvel aprovados. Sem novas migrações, concessões ou operações de negócio. Próximo trabalho: retomar transição do histórico de pagamentos factura → obrigação. Formulário completo do benchmark, contratos, catálogo, moedas e relatórios continuam incrementos próprios; não os declarar entregues por aparecerem no benchmark.
**Activação local posterior, 29/10/2026:** `compras_dev` activada com identidade persistente e três direitos auditados para a conta administrativa existente #1/empresa #1, após novo backup/restauro das 72 tabelas. Sem nova conta, mudança de palavra-passe/perfil, facturas/pagamentos/obrigações ou importação automática. Ver `obrigacoes-activacao-local.json` e [relatório](integracao-factura-obrigacao.md). Menu Finanças com ligação condicional pelo servidor; build/TypeScript/PHPStan e 39 testes dirigidos/152 asserções aprovados. Arranque extra de 8015 recusado pela revisão automática; demonstração 8005 preservada e disponível. Substitui a indicação histórica de activação desligada abaixo. Pagamentos históricos e utilização real continuam fora desta activação.
**Aplicação expressamente autorizada e concluída, 29/10/2026:** pedido «aplique» executado apenas para 000021 em `compras_dev`, lote 22. Backup privado/restauro comparado antes da aplicação; preservadas as 65 tabelas anteriores, registos, restrições e `.env`. Sete tabelas novas vazias e seis triggers append-only verificados. Ver `obrigacoes-migracao-000021.json` e [relatório](integracao-factura-obrigacao.md). A activação permanece desligada; nenhuma conta/concessão, incorporação de facturas ou obrigação automática. Demos e servidor público preservados. Este registo substitui apenas a indicação histórica de 000021 por aplicar, abaixo.
**Verificação final deste incremento:** regressão completa com 503 aprovados, 2 ignorados e 4 167 asserções; prova PostgreSQL nos dois hosts aprovada e ambientes descartáveis removidos; PHPStan aprovado. Não aplicar 000021 à instalação habitual por inferência, nem importar facturas com liquidações anteriores sem transição própria. A avaliação local continua disponível e preservada.
**Esquema de aplicação preparado, 29/10/2026:** migração 000021 usa `PayablesSchema` único no pacote comum (QA e aplicação), com histórico append-only; opt-in `COMMON_PAYABLES_ENABLED` desligado por omissão e identidade persistente da instalação. Não aplicada às bases habituais ou demos persistentes; sem novas concessões reais. Facturas com pagamentos preservam percurso/saldo anterior e recusam incorporação automática. Reconhecimento único, saldo, permissões, identidade e imutabilidade: 60 testes dirigidos/215 asserções aprovados. Ver [relatório](integracao-factura-obrigacao.md). A interface continua ensaio; transporte do histórico de pagamentos continua pendente.
**Continuação durante avaliação, 29/10/2026:** [ligação por factura e conferência da integração](integracao-factura-obrigacao.md). Cada factura elegível abre a sua origem/propostas/obrigação/histórico no núcleo comum, com regresso às facturas; filtro por empresa e identidade canónica. Sem reconhecimento ou pagamentos por navegação. Build e 56 testes dirigidos/195 asserções aprovados; prova HTTP da factura existente conserva histórico. A integração na instalação habitual e a transição de facturas com pagamentos continuam delimitadas no relatório, sem aplicação real automática.
**Acesso local de avaliação, 29/10/2026:** pedido do proprietário para retomar Compras e disponibilizar acesso semelhante ao SGF. Entrada http://127.0.0.1:8005/avaliar, proprietário administrativo e seis perfis sintéticos sem palavra-passe, ligação persistente acima da aplicação e directório de 20 páginas. Implementação em `routes/evaluation.php`, apenas no host QA local e base/role verificadas; contas reais/porta 8015 preservadas. Resultado HTTP em `proofs/evaluation-access.json`. Não remove segregação dos processos nem declara aceitação visual ou demonstração completa de todos os percursos. SSH e publicação adiados. Incremento funcional mais recente continua a obrigação comum de fornecedor abaixo; não avançar automaticamente para pagamentos/cancelamento de ordens sem delimitar o respectivo âmbito.
**Implementação sintética de obrigações, 29/10/2026:** [entrega nas duas composições](../../sgf-tempo/docs/SUPPLIER_PAYABLE_DELIVERY.md). Reutiliza `InvoiceWorkflow`/origem conferida; principal comum de 11 600 MZN com vencimento/dias nulos. `outstanding()` delega para o principal canónico apenas na origem QA reconhecida e recusa história/versão incompatível; pagamentos desse cenário ficam bloqueados. Fora do ensaio mantém-se o percurso anterior. Sem migração ou concessão real, novos pagamentos, stock/orçamento adicional ou lançamentos. Preservar avaliação orçamental em paralelo; aceitação/visual/checkpoint continuam separados.
**Regras comuns de produto, 29/10/2026:** referência nas [oito regras SGF/Compras](../../sgf-tempo/docs/SUPPLIER_PAYABLE_SCOPE.md#14-orientação-comum-de-produto-da-tempo--29102026). Documento, obrigação, liquidação, confirmação, reconciliação e contabilidade são factos distintos; núcleo único preserva origem, idempotência e histórico. Vencimento desconhecido gera pendência sem inventar data/apagar principal. Afectações parciais, excessos não afectados, correcções e cobrança configurada orientam a evolução, mas o incremento actual é apenas obrigação a fornecedor. Sem implementação ou alargamento a pagamentos/cobranças nesta actualização documental.
**Especificação completada em 29/10/2026, apenas documental:** [matriz e proposta de obrigações](../../sgf-tempo/docs/SUPPLIER_PAYABLE_SCOPE.md), secções 9–13. Inclui estados/funções, vencimento desconhecido, unicidade por origem independentemente da chave de pedido, transição preservando pagamentos confirmados/pendentes e exemplo 1 160,00 MZN para confirmação. Sem implementação, migração, alteração de pagamentos ou avaliação orçamental. A dívida implícita existente não desaparece por falta de vencimento; nenhum segundo saldo será tratado como autoridade.
**Ligação seguinte, 29/10/2026 — delimitação:** [factura conferida → obrigação a pagar](../../sgf-tempo/docs/SUPPLIER_PAYABLE_SCOPE.md). Reutilizar factura/fornecedor/valores/decisão existentes e preservar o saldo implícito de `ExternalPaymentWorkflow::outstanding`, sem segunda base de dívida. Falta vencimento estruturado e referência explícita da revisão de origem; não inventar versão nativa ou interpretar condições textuais como data. Apenas conferência/desenho nesta passagem; sem novo pagamento, stock, lançamento ou alterações às bases reais. Orçamento entregue e avaliação da equipa continuam independentes.
**Aceitação técnica de 29/10/2026:** a Tempo aceitou a extracção `COMMON-BUDGET-SYNTHETIC-v1` e autorizou checkpoint exclusivamente local nos dois repositórios, sem uso real/push/publicação. Ver [aceitação](../../sgf-tempo/docs/COMMON_BUDGET_ACCEPTANCE.md). Revisão visual Compras pendente; SGF apenas adaptador QA sem interface deste percurso; dois testes MFA ignorados continuam pendentes. Esta aceitação substitui apenas a pendência de encerramento técnico abaixo. Próximo passo: percurso sintético integrado SGF, sem bloquear desenvolvimento pela automação do navegador.
**Execução posterior em 29/10/2026:** `COMMON-BUDGET-SYNTHETIC-v1` implementado, com fonte única no pacote SGF, adaptadores locais e duas bases descartáveis. Ver [relatório técnico](../../sgf-tempo/docs/COMMON_BUDGET_DELIVERY.md). Conservar as alterações e os percursos; sem aceitação presumida, checkpoint de encerramento, migração/concessões/dados reais, publicação ou push. Não avançar para obrigações/pagamentos/contabilidade por arrasto.
+49
View File
@@ -0,0 +1,49 @@
# Passagem de histórico de pagamentos para obrigações
29/10/2026. Continuação autorizada pelo proprietário após a conferência do novo menu. Identificador técnico: `PAYABLE-HISTORY-IMPORT-v1`.
## Resultado e limites
Implementada uma passagem explícita em duas etapas: preparar a fotografia dos pagamentos existentes e validá-la por pessoa distinta. A factura conferida mantém o principal integral. A obrigação comum passa a fornecer o saldo, deduzindo uma única vez apenas os pagamentos anteriormente confirmados. Pendentes e rejeitados conservam montantes/estados e não são deduzidos.
Não é um novo pagamento, afectação arbitrária ou reconciliação bancária. Não executa transferências, confirma pendentes, cria stock, consome novamente orçamento ou produz lançamentos contabilísticos. O circuito normal continua a recusar incorporar pagamentos implicitamente; o percurso novo tem acções e activação próprias.
Exemplo de referência: principal 1 160,00 MZN, confirmado 300,00 e pendente 100,00 → saldo 860,00. Prova PostgreSQL integrada: principal 11 600,00, confirmado 3 000,00, pendente 1 000,00 e rejeitado 500,00 → saldo 8 600,00; pendente/rejeitado não reduzem o saldo. Vencimento e dias de atraso mantêm-se nulos.
## Responsabilidade e preservação
- Fonte única em `sgf-tempo/packages/payables`: `HistoricalPayments`, `HistorySchema` e extensão opcional de `Payables`; sem segundo motor de saldo no adaptador Compras.
- `principal()` conserva o principal documental; `balance()` consulta a posição do núcleo. `ExternalPaymentWorkflow::outstanding()` recebe o saldo comum já líquido, sem voltar a subtrair os mesmos pagamentos.
- A fotografia guarda IDs, empresa/documento/moeda, valor, estado, autor/data e referências das auditorias de registo/decisão. Não copia referências externas cifradas, comprovativos, contas bancárias ou instruções de pagamento.
- O adaptador confere que a auditoria corresponde ao pagamento, factura, valor, estado e interveniente. Confirmações/rejeições exigem decisão independente; a importação não reescreve decisões históricas em função dos perfis actuais desses antigos intervenientes.
- Identidade canónica por instância/empresa/documento, chave única por pagamento original e uma importação por obrigação/origem. Mudança de identidade não pode criar uma segunda obrigação para a mesma factura local.
- Mudança de pagamentos entre preparação e validação produz conflito e exige nova fotografia. Leitura posterior detecta origem/histórico divergente e deixa o saldo indisponível, conservando principal/originais. Desactivar a integração com obrigação incorporada não autoriza cair silenciosamente no saldo antigo.
- A gravação do principal, passagem, ligações, eventos e idempotência é transaccional. Falha intermédia não deixa incorporação parcial. A importação não altera as tabelas originais de factura, pagamentos, orçamento ou stock.
## Esquema, acesso e interface
Migração aditiva `2026_10_29_000022_create_payable_history_imports.php`, com duas tabelas append-only: `pa_history_imports` e `pa_imported_payments`. Reutiliza os direitos explícitos de preparação/validação existentes; a validação própria permanece recusada. Não cria contas nem concessões.
Activação separada `COMMON_PAYABLES_HISTORY_ENABLED=false` por omissão. Exige integração anterior activa, esquema completo e adaptador com fornecedor de histórico. O SGF mantém o percurso anterior sem pagamentos; esta entrega não configura automaticamente um fornecedor de pagamentos históricos no adaptador SGF.
Na página comum, quando activado e existindo pagamentos na factura, surge «Incorporar factura com pagamentos anteriores». A proposta apresenta confirmados/pendentes/rejeitados. A obrigação apresenta principal e saldo por liquidar separadamente. As fotografias antigas continuam consultáveis. Depois da passagem, novos pagamentos e confirmação de pendentes ficam para o incremento do circuito comum; não continuar a escrever pelo percurso antigo após transferir a responsabilidade.
## Estado de aplicação
Após orientação posterior «continue», **000022 aplicada em `compras_dev`, lote 23**, e `COMMON_PAYABLES_HISTORY_ENABLED=true` guardado no `.env` local. Novo backup privado de 367 910 bytes, SHA-256 `1f29bdaf0ea0379475c26e78f07cffe2ca31401ab216031b499c2562bafad7dc`, restaurado e comparado nas 72 tabelas antes da aplicação; base de restauro removida. Registos/restrições anteriores preservados na aplicação transaccional; duas tabelas novas vazias e dois triggers append-only conferidos.
Activação registada em `supplier_payables.history_local_activated`, sem criar conta ou alterar concessões. Projecções dos registos das 72 tabelas anteriores conferidas novamente, descontando apenas o registo da nova migração e a nova entrada de auditoria. Identidade da instalação e permissões anteriores preservadas. A configuração foi confirmada num processo novo: activação histórica verdadeira, uma conta, três direitos anteriores e zero facturas/pagamentos/obrigações. Nenhuma incorporação automática.
Evidências sanitizadas: `historico-migracao-000022.json` e `historico-activacao-local.json`. Backup/manifesto privados em `storage/app/private/backups/payable-history-000022-20261029-193307-f5d793/`.
As demonstrações persistentes e o servidor público não foram migrados/configurados nesta aplicação. Conferência só de leitura em 8005 confirmou activação histórica falsa e ausência das tabelas novas nesse ambiente. A demonstração não passa a ter histórico sintético por consequência da activação de `compras_dev`. Migração/activação anteriores 000021, menus e acessos do proprietário preservados. A interface nova foi conferida por resposta HTML nas provas PostgreSQL, sem uma nova prova visual no navegador.
## Provas
`PayableHistoryImportTest.php`: principal bruto/saldo, decisão independente, repetição da mesma chave e de chaves distintas, pagamentos pendentes/rejeitados, total liquidado, alteração entre proposta/validação, ausência de auditoria, falha/recuperação, imutabilidade, configuração desactivada, duplicações/omissões/incompatibilidades, mudança de identidade e exemplo 1 160−300=860.
`scripts/payable-history-proof.mjs`: cria bases/roles QA exclusivas, regista/conferencia os pagamentos sintéticos pelo circuito existente, injecta uma falha e valida simultaneamente em dois processos. Confere uma obrigação, uma importação, três ligações, saldo correcto, original de negócio intacto, protecções SQL e recusa de alteração histórica; elimina as bases/roles no final. Relatório sanitizado: `proofs/payable-history-pg.json`.
Regressão geral: **517 aprovados, 2 ignorados, 4 337 asserções** (519 testes). Após o reforço final da consulta de identidade, repetida a bateria específica: **11 aprovados, 64 asserções**. PHPStan dos adaptadores/controlador e Pint do âmbito aprovados. A prova antiga de obrigações sem pagamentos também passou em Compras e SGF, com concorrência/recuperação e efeitos de orçamento/stock/pagamentos preservados. As bases e roles de ambas as provas PostgreSQL foram removidas.
Leitura anterior à aplicação: 000022 não aplicada e importação histórica desactivada. A aplicação/activação posteriores estão registadas acima. Não há build novo neste incremento PHP/esquema; o frontend e os menus anteriores não foram alterados. As verificações técnicas e a activação local não constituem aceitação visual ou publicação, nem executam pagamentos.
+42
View File
@@ -0,0 +1,42 @@
{
"checked_at": "2026-10-29T18:55:02.410Z",
"base": "http://127.0.0.1:8005",
"owner_modules": [
"/dashboard",
"/empresa",
"/requisicoes",
"/planeamento",
"/fornecedores",
"/cotacoes",
"/ordens",
"/recepcoes",
"/stock",
"/stock/operacoes",
"/equipamentos",
"/orcamentos",
"/regras-investimento",
"/facturas",
"/pagamentos",
"/obrigacoes-fornecedores",
"/conflitos",
"/revisao-compras",
"/politica-admissao",
"/politica-recomendacao"
],
"roles": [
"requester",
"department_head",
"procurement",
"finance",
"warehouse",
"admin"
],
"csrf_enforced": true,
"arbitrary_role_refused": true,
"arbitrary_user_refused": true,
"logout": true,
"business_commands": 0,
"real_accounts_changed": false,
"operational_entry_status": "not running",
"visual_review": false
}
+11
View File
@@ -0,0 +1,11 @@
{
"checked_at": "2026-10-29T18:41:52.592Z",
"documents_checked": 1,
"per_invoice_entry": true,
"return_to_invoices": true,
"invalid_identifiers_refused": true,
"unknown_invoice_refused": true,
"full_listing_unchanged": true,
"business_commands": 0,
"visual_review": false
}
+155
View File
@@ -0,0 +1,155 @@
{
"checked_at": "2026-10-29T19:14:17.130Z",
"results": [
{
"role": "admin",
"checked_entries": 24,
"paths": [
"/requisicoes/criar",
"/requisicoes?view=mine",
"/requisicoes?view=mine&status=draft",
"/requisicoes?view=all",
"/planeamento",
"/requisicoes?view=approvals",
"/requisicoes?view=decided",
"/cotacoes",
"/ordens",
"/revisao-compras",
"/fornecedores",
"/recepcoes",
"/stock",
"/stock/operacoes",
"/equipamentos",
"/facturas",
"/obrigacoes-fornecedores",
"/pagamentos",
"/orcamentos",
"/regras-investimento",
"/empresa",
"/politica-admissao",
"/politica-recomendacao",
"/conflitos"
]
},
{
"role": "procurement",
"checked_entries": 17,
"paths": [
"/requisicoes/criar",
"/requisicoes?view=mine",
"/requisicoes?view=mine&status=draft",
"/requisicoes?view=all",
"/planeamento",
"/requisicoes?view=approvals",
"/requisicoes?view=decided",
"/cotacoes",
"/ordens",
"/revisao-compras",
"/fornecedores",
"/recepcoes",
"/stock",
"/stock/operacoes",
"/equipamentos",
"/regras-investimento",
"/conflitos"
]
},
{
"role": "finance",
"checked_entries": 21,
"paths": [
"/requisicoes/criar",
"/requisicoes?view=mine",
"/requisicoes?view=mine&status=draft",
"/requisicoes?view=all",
"/planeamento",
"/requisicoes?view=approvals",
"/requisicoes?view=decided",
"/cotacoes",
"/ordens",
"/revisao-compras",
"/fornecedores",
"/recepcoes",
"/stock",
"/stock/operacoes",
"/equipamentos",
"/facturas",
"/obrigacoes-fornecedores",
"/pagamentos",
"/orcamentos",
"/regras-investimento",
"/conflitos"
]
},
{
"role": "department_head",
"checked_entries": 14,
"paths": [
"/requisicoes/criar",
"/requisicoes?view=mine",
"/requisicoes?view=mine&status=draft",
"/requisicoes?view=all",
"/planeamento",
"/requisicoes?view=approvals",
"/requisicoes?view=decided",
"/cotacoes",
"/ordens",
"/revisao-compras",
"/fornecedores",
"/recepcoes",
"/equipamentos",
"/conflitos"
]
},
{
"role": "warehouse",
"checked_entries": 11,
"paths": [
"/requisicoes/criar",
"/requisicoes?view=mine",
"/requisicoes?view=mine&status=draft",
"/requisicoes?view=all",
"/planeamento",
"/revisao-compras",
"/recepcoes",
"/stock",
"/stock/operacoes",
"/equipamentos",
"/conflitos"
]
},
{
"role": "requester",
"checked_entries": 7,
"paths": [
"/requisicoes/criar",
"/requisicoes?view=mine",
"/requisicoes?view=mine&status=draft",
"/requisicoes?view=all",
"/planeamento",
"/revisao-compras",
"/conflitos"
]
}
],
"request_detail_checked": true,
"invoice_count": 1,
"process_links_checked": [
"/requisicoes/1",
"/orcamentos",
"/requisicoes/1/cotacoes",
"/requisicoes/1/adjudicacao",
"/ordens/1",
"/ordens/1/recepcoes",
"/facturas",
"/pagamentos",
"/requisicoes/1/relatorio",
"/requisicoes/1/admissao",
"/requisicoes/1/recomendacao",
"/recepcoes/1"
],
"canonical_query_order": true,
"active_subroutes": true,
"business_history_unchanged": true,
"business_commands": 0
}
+28
View File
@@ -0,0 +1,28 @@
{
"checked_at": "2026-10-29T19:41:38.688Z",
"passed": true,
"fault_recovery": true,
"concurrent_recognition": true,
"original_business_history_unchanged": true,
"new_transfers_executed": false,
"scenario": {
"principal_minor": 1160000,
"balance_minor": 860000,
"confirmed_minor": 300000,
"pending_minor": 100000,
"rejected_minor": 50000,
"obligations": 1,
"imports": 1,
"payment_links": 3,
"business_fingerprint": "fb66326abf7968aa818b0c6a214c2a1f59966fa11d3692e748fa0fa1aaa3cf1f",
"ui_balance_correct": true,
"due_on": null,
"days_overdue": null,
"append_only_tables": [
"pa_history_imports",
"pa_imported_payments"
],
"changed_history_refused": true
},
"visual_review": false
}
@@ -0,0 +1,19 @@
{
"checked_at": "2026-10-29T19:41:38.669Z",
"passed": true,
"atomic_rollback": true,
"concurrent_decision_once": true,
"original_import_preserved": true,
"new_transfers_executed": false,
"scenario": {
"balance_minor": 760000,
"confirmed_minor": 400000,
"pending_minor": 0,
"decisions": 1,
"events": 1,
"original_status": "confirmed",
"append_only": true,
"frozen": "4b5aa40f964c0709661449b4ab5bfd7cd8295eac9c4d186822d0a1e10aa63534"
},
"visual_review": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,131 @@
Compras: ecrãs e proposta de menu
Referências funcionais, estrutura de navegação e inspiração visual
Explorar os ecrãs do Oracle Purchasing
Referência: Oracle E-Business Suite 12.2, com foco no Procurement Command Center. Esta análise descreve funções documentadas; não é uma demonstração interactiva nem uma reprodução de capturas de ecrã.
Consultar o guia oficial do Procurement Command Center. Na página, procurar os títulos em inglês abaixo para localizar cada painel.
Ecrã Função documentada
Summary — Resumo Visão global das actividades e dos indicadores de procurement.
Requisitions — Requisições Priorizar requisições conforme as necessidades e apoiar a criação de encomendas.
Orders — Encomendas Acompanhar encomendas e o tempo de processamento do ciclo de compra.
Order Details — Detalhes da encomenda Painel de detalhe incluído no capítulo de operações de procurement.
Agreements — Acordos Rever acordos e identificar problemas que possam afectar as compras.
Strategic Sourcing — Compras estratégicas Analisar negociações, poupanças e negociações ainda não adjudicadas.
Indirect Procurement — Compras indirectas Identificar oportunidades de utilização de contratos e analisar compras fora de contrato.
Supplier Analysis — Análise de fornecedores Analisar entregas pontuais, qualidade e cumprimento dos preços.
Item Analysis — Análise de artigos Apoiar a selecção de artigos com base no histórico e em critérios de desempenho.
Employee Shopping Tracker — Pesquisa no catálogo Analisar pesquisas de artigos ou serviços para melhorar o catálogo.
Fonte das funções acima: guia oficial Oracle. Os painéis permitem pesquisa, filtros laterais, combinação de critérios e pesquisas guardadas. A disponibilidade depende da instalação e configuração do Command Center.
Percurso de exploração sugerido
1. Começar em Summary para compreender a visão global.
2. Passar a Requisitions e Orders para seguir o ciclo operacional.
3. Consultar Agreements e Strategic Sourcing para explorar contratos e negociações.
4. Terminar em Supplier Analysis e Item Analysis para analisar fornecedores e artigos.
Este percurso é uma recomendação de leitura, não uma sequência obrigatória
Propor um menu de procurement
Proposta conceptual para um software próprio. Os módulos, campos e acções seguintes são recomendações de desenho, não o menu oficial do Oracle.
Menu principal Submenus e acções propostas
Painel inicial Minhas tarefas; requisições pendentes; encomendas em aberto; alertas de entrega; contratos a expirar.
Requisições Nova requisição; minhas requisições; todas as requisições autorizadas; acompanhamento do estado.
Aprovações Pendentes; histórico; devolver para correcção; aprovar ou rejeitar com justificação.
Consultas e concursos Pedidos de cotação; concursos; propostas recebidas; mapa comparativo; adjudicações.
Ordens de compra Criar ordem; rascunhos; ordens aprovadas; alterações; acompanhamento de entregas.
Fornecedores Cadastro; documentos; categorias de fornecimento; qualificação; histórico de compras.
Contratos Contratos activos; acordos de preços; renovações; adendas; alertas de validade.
Recepções Receber bens; confirmar serviços; recepções parciais; divergências; devoluções.
Facturas Registo; conferência com ordem e recepção; divergências; envio à contabilidade; consulta do estado de pagamento.
Catálogo Artigos e serviços; categorias; unidades de medida; preços acordados.
Relatórios Despesa por fornecedor e categoria; tempos de aprovação; entregas em atraso; compras fora de contrato.
Administração Utilizadores e perfis; limites de aprovação; centros de custo; moedas; integrações; registo de auditoria.
Organização dos ecrãs
• Navegação: menu lateral por módulo e barra superior com pesquisa, notificações e perfil.
• Listagens: filtros por estado, período e fornecedor; tabela central; acção principal claramente identificada.
• Detalhe: separadores para dados gerais, linhas, anexos, aprovações e histórico.
• Requisição: departamento, centro de custo, descrição, artigo ou serviço, quantidade, unidade, data necessária e anexos.
• Comparação de propostas: fornecedor, preço, moeda, impostos, prazo de entrega, condições de pagamento e critérios de selecção.
• Ordem de compra: fornecedor, referência à requisição, linhas, entrega, condições comerciais e estado de aprovação.
Perfis e controlos propostos
Requisitante: cria e acompanha pedidos. Aprovador: decide sobre pedidos atribuídos. Comprador: gere consultas e ordens. Recepção: confirma entregas. Financeiro: confere facturas. Administrador: configura acessos e regras.
Recomenda-se separar criação, aprovação e recepção, restringir dados por perfil e registar alterações. O módulo Facturas deve integrar-se com a contabilidade, evitando duplicar a execução de pagamentos.
Fluxo de referência proposto: requisição → aprovação → consulta ou concurso, quando aplicável → adjudicação → ordem de compra → recepção → conferência da factura → processamento financeiro.
Especificação do ecrã Nova requisição
Proposta funcional para desenvolvimento do software próprio; não descreve um ecrã oficial do Oracle. As regras deverão ser confirmadas com a organização antes da implementação.
Objectivo e acesso
Permitir ao requisitante registar uma necessidade de bens ou serviços, guardar um rascunho e submetê-lo para aprovação. Acesso proposto: Requisições → Nova requisição. O utilizador só pode seleccionar departamentos e centros de custo para os quais tenha autorização.
Campos do cabeçalho
Campo Obrigatoriedade e comportamento Validação na submissão
Número da requisição Automático e só de leitura, atribuído ao guardar. Identificador único; não editável pelo requisitante.
Requisitante e data de criação Automáticos, a partir da sessão e do sistema. Utilizador autenticado; data e hora registadas pelo sistema.
Departamento Obrigatório; selecção da lista autorizada. Departamento activo e acessível ao utilizador.
Centro de custo Obrigatório; lista dependente do departamento. Activo, autorizado e compatível com o departamento.
Título e descrição da necessidade Obrigatórios; título breve e descrição do objectivo. Não aceitar campos vazios ou apenas espaços.
Tipo de pedido Obrigatório: bens, serviços ou misto. As linhas devem ser coerentes com o tipo seleccionado.
Data necessária Obrigatória; data pretendida para entrega ou início do serviço. Não anterior à data local de submissão. Avisar quando o prazo for inferior ao prazo de compra configurado.
Local de entrega ou execução Obrigatório; local autorizado e instruções complementares opcionais. Local activo; instruções não substituem a selecção do local.
Prioridade e justificação de urgência Prioridade normal por defeito; justificação obrigatória se urgente. Urgência não dispensa aprovação nem autoriza contornar o processo.
Moeda Obrigatória; única para toda a requisição. Moeda activa. Para comparação com orçamento noutra moeda, exigir taxa e data de conversão válidas.
Projecto ou actividade Opcional, salvo exigência do centro de custo. Quando indicado, deve estar activo e associado ao centro de custo.
Estado Só de leitura; rascunho ou submetido neste ecrã. Alterado exclusivamente pelas acções autorizadas.
Campos das linhas
Campo Obrigatoriedade e comportamento Validação na submissão
Artigo ou serviço e categoria Obrigatórios; escolher do catálogo ou introduzir descrição livre quando permitido. Entrada de catálogo activa. Fora do catálogo: descrição, categoria e justificação obrigatórias.
Especificação técnica Obrigatória para pedidos fora do catálogo; opcional se o catálogo já definir a necessidade. Indicar características necessárias; para serviços, âmbito e resultado esperado.
Quantidade Obrigatória; valor numérico positivo. Superior a zero; precisão compatível com a unidade de medida.
Unidade de medida Obrigatória; proveniente do catálogo ou da lista configurada. Unidade activa e compatível com o artigo ou serviço.
Preço unitário estimado Obrigatório na moeda do cabeçalho; não representa preço adjudicado. Superior a zero. Sem estimativa, permitir guardar rascunho, mas bloquear submissão até completar.
Tratamento fiscal e taxa estimada Obrigatórios; opções configuradas, incluindo isenção ou não aplicável. Taxa válida para o tratamento escolhido. Não assumir automaticamente uma taxa fiscal.
Subtotal, imposto e total estimado Calculados e só de leitura. Preço unitário proposto sem imposto. Subtotal = quantidade × preço unitário; imposto calculado pelo tratamento configurado; total = subtotal + imposto. Arredondamento conforme a moeda.
Fornecedor sugerido Opcional; selecção do cadastro e motivo da sugestão. Fornecedor activo. A sugestão não constitui adjudicação nem obriga à contratação.
Anexos e resumo
Permitir anexar especificações, fotografias ou documentos de suporte, sujeitos às extensões e ao tamanho máximo configurados. Verificar ficheiros no servidor, incluindo conteúdo potencialmente perigoso, e aplicar os mesmos acessos da requisição. Exigir anexos apenas nas categorias ou situações definidas pelas regras internas; mostrar essa exigência antes da submissão.
Apresentar o total estimado na moeda do pedido, a indicação de que não é um compromisso de compra e o percurso de aprovação previsto. Se houver controlo orçamental integrado, mostrar a disponibilidade e a data da consulta; sem integração, indicar “Orçamento não verificado”, sem simular disponibilidade.
Acções do ecrã
Acção Comportamento proposto
Adicionar linha Inserir artigo ou serviço mantendo a moeda e os dados do cabeçalho.
Remover linha Disponível em rascunho; pedir confirmação se a linha tiver dados e recalcular o resumo.
Anexar ou remover documento Disponível em rascunho; validar ficheiro e confirmar remoção.
Guardar rascunho Permitir dados incompletos; validar os valores já preenchidos e manter a requisição fora da fila de aprovação. Confirmar a gravação com número e data.
Pré-visualizar Mostrar cabeçalho, linhas, anexos e totais antes de submeter, sem alterar o estado.
Submeter para aprovação Validar o pedido completo, apresentar confirmação com total e percurso previsto, gravar e encaminhar uma única vez. Mostrar número e estado após sucesso.
Voltar à lista Se houver alterações não guardadas, oferecer guardar rascunho, descartar alterações ou continuar a editar.
Regras de submissão e mensagens
• Exigir pelo menos uma linha válida, todos os campos obrigatórios e os anexos condicionais.
• Confirmar no servidor os acessos, os dados activos, os cálculos e a regra de aprovação. Bloquear se não existir um percurso de aprovação aplicável.
• Quando o controlo orçamental for obrigatório, bloquear se não for possível verificá-lo. Insuficiência de orçamento deve seguir a regra de bloqueio ou excepção aprovada pela organização, nunca uma dispensa automática.
• Bloquear a auto-aprovação do requisitante; encaminhar para aprovador autorizado alternativo quando necessário.
• Detectar possíveis pedidos duplicados e apresentar aviso para revisão. Evitar submissões repetidas por duplo clique ou repetição da ligação.
• Destacar cada erro junto ao campo e apresentar um resumo navegável, por exemplo: “Seleccione um centro de custo” ou “A quantidade deve ser superior a zero”. Preservar os dados introduzidos.
• Após submissão, tornar os dados submetidos só de leitura. Correcções posteriores devem seguir um processo controlado de devolução ou revisão, com histórico.
• Registar autor, data, alterações e transições de estado. Detectar edição concorrente para não substituir silenciosamente alterações de outro utilizador.
• Em falha de gravação ou ligação, não apresentar sucesso. Preservar os dados no ecrã e permitir verificar o estado antes de tentar novamente.
Critérios de aceitação
1. Um pedido incompleto pode ser guardado em rascunho, mas não pode ser submetido.
2. Campos obrigatórios em falta, quantidade inválida ou preço sem estimativa impedem a submissão e mostram mensagens específicas.
3. Alterar quantidade, preço ou tratamento fiscal actualiza os totais segundo as regras configuradas.
4. Um pedido válido é gravado e encaminhado uma única vez, com número identificável e estado submetido.
5. O utilizador não consegue seleccionar centros de custo não autorizados nem editar dados de um pedido já submetido.
6. Ao abandonar alterações não guardadas, é apresentada uma confirmação; nenhuma perda de dados ocorre sem escolha explícita.
Decisões a confirmar antes do desenvolvimento: limites de aprovação, política orçamental, moedas e taxas de conversão, tratamentos fiscais, categorias com anexos obrigatórios, limites dos ficheiros e regras de urgência. Não foram assumidos limiares ou valores específicos da organização.
Comparar as referências visuais
A comparação distingue documentação de um sistema real de projectos de design. As recomendações de utilização são uma apreciação para este trabalho; não resultam de testes de usabilidade.
Referência Conteúdo disponível Utilização recomendada e limite
Oracle Purchasing
Documentação oficial de painéis operacionais e analíticos do Procurement Command Center. Validar funções e processos. A documentação não substitui uma demonstração da instalação concreta.
Figma — Purchase Management Dashboard
Projecto com ordens de compra, fornecedores, comparação de cotações, orçamento, entregas e aprovações hierárquicas. Explorar a estrutura de um projecto de interface. Um desenho não comprova funcionamento, integrações ou segurança de um sistema.
Dribbble — Procurement UI
Galeria de interfaces de procurement, painéis, fornecedores e concursos. Comparar rapidamente abordagens visuais. As imagens isoladas podem não mostrar o fluxo completo ou os estados de erro.
Behance — Procurement Dashboard
Galeria de projectos de painéis e plataformas de procurement. Procurar projectos que expliquem várias páginas e decisões de design. A profundidade e o carácter funcional variam por projecto.
Fontes consultadas: as referências ligadas em cada linha. Consulta realizada em 29 de Outubro de 2026. A comparação baseia-se nas descrições públicas disponíveis; não foi feita uma inspecção visual detalhada de cada ecrã nem um teste dos protótipos.
Recomendação para avançar
Usar o Oracle como referência funcional, o projecto Figma como ponto de partida para organização de interface e as galerias Dribbble e Behance para procurar alternativas visuais. Não copiar uma aparência sem confirmar que suporta o processo de compra pretendido.
Critérios para escolher uma interface
• Clareza dos estados: rascunho, submetido, aprovado, rejeitado e concluído.
• Facilidade de executar tarefas: criar uma requisição, comparar propostas e acompanhar uma encomenda.
• Visibilidade de prazos, responsáveis e próximos passos.
• Tratamento de anexos, comentários, divergências e histórico.
• Legibilidade, contraste e navegação por teclado.
• Adaptação à língua portuguesa e às moedas utilizadas pela organização.
Estes critérios são recomendações para avaliação futura; não representam resultados medidos nas referências.
+4
View File
@@ -26,6 +26,10 @@
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="COMMON_PAYABLES_ENABLED" value="false" force="true"/>
<env name="COMMON_PAYABLES_HISTORY_ENABLED" value="false" force="true"/>
<env name="COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED" value="false" force="true"/>
<env name="PAYABLE_SOURCE_INSTANCE" value="" force="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
+4 -1
View File
@@ -35,7 +35,7 @@ const generalNavItems: NavItem[] = [
];
export function AppSidebar() {
const { auth } = usePage().props;
const { auth, payablesAvailable } = usePage().props;
const canReadPurchases = [
'admin',
'procurement',
@@ -155,6 +155,9 @@ export function AppSidebar() {
href: '/facturas',
icon: FileCheck2,
},
...(payablesAvailable === true
? [{title: 'Obrigações a fornecedores', href: '/obrigacoes-fornecedores', icon: FileCheck2, fullPage: true}]
: []),
{
title: 'Pagamentos externos',
href: '/pagamentos',
+11 -4
View File
@@ -39,10 +39,17 @@ export function NavMain({
}
tooltip={{ children: item.title }}
>
<Link href={item.href} prefetch>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
{item.fullPage ? (
<a href={toUrl(item.href)}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</a>
) : (
<Link href={item.href} prefetch>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
+16 -1
View File
@@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UserMenuContent } from '@/components/user-menu-content';
import { SgcWorkspaceShell } from '@/components/sgc-workspace-shell';
type Entry = { label: string; path: string; demo: string; roles?: string[] };
const readers = ['admin', 'procurement', 'finance', 'department_head'];
@@ -159,7 +160,7 @@ const groups: { label: string; entries: Entry[] }[] = [
},
];
export function SgcShell({
function SgcPreviewShell({
children,
demo = false,
}: {
@@ -320,3 +321,17 @@ export function SgcShell({
</div>
);
}
export function SgcShell({
children,
demo = false,
}: {
children: ReactNode;
demo?: boolean;
}) {
return demo ? (
<SgcPreviewShell demo>{children}</SgcPreviewShell>
) : (
<SgcWorkspaceShell>{children}</SgcWorkspaceShell>
);
}
@@ -0,0 +1,369 @@
import { Link, usePage } from '@inertiajs/react';
import {
ChevronDown,
LayoutDashboard,
Menu,
Plus,
Search,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { UserMenuContent } from '@/components/user-menu-content';
import {
activeProcurementEntry,
procurementNavigation,
} from '@/lib/procurement-navigation';
import { roleLabels } from '@/types/procurement';
export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
const page = usePage();
const user = page.props.auth?.user;
const [mobileOpen, setMobileOpen] = useState(false);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const groups = procurementNavigation(
user?.purchasing_role ?? '',
page.props.payablesAvailable === true,
);
const activeId = activeProcurementEntry(groups, page.url);
const activeGroup = groups.find((group) =>
group.entries.some((entry) => entry.id === activeId),
);
const activeGroupId = activeGroup?.id;
useEffect(() => {
if (activeGroupId)
setExpanded((previous) =>
previous[activeGroupId]
? previous
: { ...previous, [activeGroupId]: true },
);
}, [activeId, activeGroupId]);
const normalize = (text: string) =>
text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase('pt');
const query = normalize(search.trim());
const visibleGroups = groups
.map((group) => ({
...group,
entries: group.entries.filter(
(entry) =>
!query ||
normalize(group.label + ' ' + entry.label).includes(query),
),
}))
.filter((group) => group.entries.length);
const navigate = () => {
setMobileOpen(false);
setSearch('');
};
const brand = (
<Link
href="/dashboard"
onClick={navigate}
className="flex items-center gap-3 px-5 py-6 focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-white"
>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-[#ed1c24] text-sm font-bold text-white">
SGC
</span>
<span>
<span className="block text-lg font-semibold tracking-tight text-white">
Compras
</span>
<span className="block text-xs text-slate-400">
Sistema de Gestão de Compras
</span>
</span>
</Link>
);
const searchField = (mobile = false) => (
<div
className={`relative ${mobile ? 'mx-4 mb-4' : 'hidden w-full max-w-sm lg:block'}`}
>
<Search
className="pointer-events-none absolute top-3 left-3 size-4 text-slate-400"
aria-hidden="true"
/>
<input
type="search"
aria-label="Procurar no menu"
placeholder="Procurar no menu…"
value={search}
onChange={(event) => setSearch(event.target.value)}
className={`h-10 w-full rounded-lg border pr-9 pl-9 text-sm outline-none focus:border-red-400 focus:ring-2 focus:ring-red-400/20 ${mobile ? 'border-white/15 bg-white/5 text-white placeholder:text-slate-400' : 'border-border bg-background text-foreground'}`}
/>
{search && (
<button
type="button"
aria-label="Limpar pesquisa do menu"
onClick={() => setSearch('')}
className="absolute top-2 right-2 rounded p-1 text-slate-400 hover:text-red-400"
>
<X className="size-4" />
</button>
)}
</div>
);
const navigation = (
<nav
aria-label="Navegação principal"
className="min-h-0 flex-1 overflow-y-auto px-3 pb-5"
>
<p className="px-3 pt-2 pb-3 text-[10px] font-semibold tracking-[0.18em] text-slate-500">
ESPAÇO DE TRABALHO
</p>
{(!query ||
normalize('Painel inicial Visão geral').includes(query)) && (
<Link
href="/dashboard"
onClick={navigate}
aria-current={
page.url.split('?')[0] === '/dashboard'
? 'page'
: undefined
}
className={`mb-3 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-white ${page.url.split('?')[0] === '/dashboard' ? 'bg-white/10 text-white' : 'text-slate-300 hover:bg-white/5 hover:text-white'}`}
>
<LayoutDashboard
className="size-4 shrink-0"
aria-hidden="true"
/>
Painel inicial
</Link>
)}
{visibleGroups.map((group) => (
<details
key={group.id}
open={
query
? true
: (expanded[group.id] ??
activeGroup?.id === group.id)
}
onToggle={(event) => {
if (!query) {
const open = event.currentTarget.open;
setExpanded((previous) =>
previous[group.id] === open
? previous
: { ...previous, [group.id]: open },
);
}
}}
className="group/menu mb-1"
>
<summary
className={`flex cursor-pointer list-none items-center gap-3 rounded-lg px-3 py-2.5 text-[13px] font-medium transition-colors hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-white [&::-webkit-details-marker]:hidden ${activeGroup?.id === group.id ? 'text-white' : 'text-slate-300'}`}
>
<group.icon
className={`size-4 shrink-0 ${activeGroup?.id === group.id ? 'text-red-400' : 'text-slate-500'}`}
aria-hidden="true"
/>
<span className="flex-1">{group.label}</span>
<ChevronDown
className="size-3.5 shrink-0 text-slate-500 transition-transform group-open/menu:rotate-180"
aria-hidden="true"
/>
</summary>
<ul className="mt-1 mb-3 ml-5 space-y-1 border-l border-white/10 pl-3">
{group.entries.map((entry) => {
const className = `block rounded-md px-3 py-2 text-[13px] leading-5 transition-colors focus-visible:outline-2 focus-visible:outline-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400 hover:bg-white/5 hover:text-white'}`;
return (
<li key={entry.id}>
{entry.fullPage ? (
<a
href={entry.path}
onClick={navigate}
className={className}
aria-current={
activeId === entry.id
? 'page'
: undefined
}
>
{entry.label}
</a>
) : (
<Link
href={entry.path}
onClick={navigate}
className={className}
aria-current={
activeId === entry.id
? 'page'
: undefined
}
>
{entry.label}
</Link>
)}
</li>
);
})}
</ul>
</details>
))}
{query &&
visibleGroups.length === 0 &&
!normalize('Painel inicial Visão geral').includes(query) && (
<p
role="status"
className="px-3 py-5 text-sm text-slate-400"
>
Nenhuma opção encontrada. Tenta outra palavra.
</p>
)}
</nav>
);
const sidebarFooter = (
<div className="border-t border-white/10 px-5 py-4">
<p className="text-[11px] text-slate-500">
SGC · Espaço de trabalho
</p>
<Link
href="/settings/appearance"
onClick={navigate}
className="mt-1 inline-block text-xs text-slate-400 hover:text-white"
>
Aparência
</Link>
<span className="mx-2 text-slate-700">·</span>
<Link
href="/"
onClick={navigate}
className="text-xs text-slate-400 hover:text-white"
>
Página inicial
</Link>
</div>
);
return (
<div className="min-h-screen bg-slate-50 text-foreground lg:grid lg:grid-cols-[17rem_minmax(0,1fr)] dark:bg-background print:block">
<a
href="#sgc-main"
className="sr-only z-50 rounded bg-white p-3 text-black focus:not-sr-only focus:fixed focus:top-2 focus:left-2"
>
Saltar para o conteúdo
</a>
<aside className="sticky top-0 hidden h-screen flex-col border-r border-white/5 bg-[#101715] text-white lg:flex print:hidden">
{brand}
{navigation}
{sidebarFooter}
</aside>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent
side="left"
className="flex w-[min(90vw,20rem)] max-w-none flex-col gap-0 border-white/10 bg-[#101715] p-0 text-white [&>button]:text-white"
>
<SheetHeader className="sr-only">
<SheetTitle>Menu de Compras</SheetTitle>
<SheetDescription>
Escolhe uma área da aplicação ou procura pelo nome.
</SheetDescription>
</SheetHeader>
{brand}
{searchField(true)}
{navigation}
{sidebarFooter}
</SheetContent>
</Sheet>
<div className="flex min-w-0 flex-col">
<header className="sticky top-0 z-20 flex min-h-18 items-center justify-between gap-4 border-b border-border bg-background/95 px-5 py-3 backdrop-blur lg:px-8 print:hidden">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="outline"
size="icon"
aria-label="Abrir menu de Compras"
onClick={() => setMobileOpen(true)}
className="shrink-0 lg:hidden"
>
<Menu className="size-4" />
</Button>
<div className="min-w-0">
<p className="text-[10px] font-semibold tracking-[0.15em] text-muted-foreground">
COMPRAS
</p>
<p className="truncate text-sm font-medium">
{page.url.split('?')[0] === '/dashboard'
? 'Painel inicial'
: (activeGroup?.label ??
'Espaço de trabalho')}
</p>
</div>
</div>
{searchField()}
<div className="flex shrink-0 items-center gap-2">
<Button
asChild
size="sm"
className="hidden bg-[#ed1c24] text-white hover:bg-[#d01720] sm:inline-flex"
>
<Link href="/requisicoes/criar">
<Plus className="size-4" />
Nova requisição
</Link>
</Button>
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
aria-label={`Conta de ${user.name}`}
className="h-auto max-w-52 gap-2 px-2 py-1.5"
>
<span
className="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-xs font-semibold"
aria-hidden="true"
>
{user.name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')}
</span>
<span className="hidden min-w-0 text-left xl:block">
<span className="block truncate text-xs font-medium">
{user.name}
</span>
<span className="block text-[10px] text-muted-foreground">
{roleLabels[
user.purchasing_role ?? ''
] ?? 'Utilizador'}
</span>
</span>
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<UserMenuContent user={user} />
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
<main id="sgc-main" className="min-w-0 flex-1" tabIndex={-1}>
{children}
</main>
<footer className="mt-auto border-t border-border px-5 py-4 text-xs text-muted-foreground lg:px-8 print:hidden">
SGC · Sistema de Gestão de Compras
</footer>
</div>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import {
BadgeCheck,
ClipboardList,
Scale,
ShieldCheck,
Users,
Wallet,
Warehouse,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
export type ProcurementEntry = {
id: string;
label: string;
path: string;
roles?: string[];
fullPage?: boolean;
};
export type ProcurementGroup = {
id: string;
label: string;
icon: LucideIcon;
entries: ProcurementEntry[];
};
const readers = ['admin', 'procurement', 'finance', 'department_head'];
const finance = ['admin', 'finance'];
const inventory = [...readers, 'warehouse'];
export function procurementNavigation(
role: string,
payables: boolean,
): ProcurementGroup[] {
const groups: ProcurementGroup[] = [
{
id: 'requests',
label: 'Requisições',
icon: ClipboardList,
entries: [
{
id: 'new_request',
label: 'Nova requisição',
path: '/requisicoes/criar',
},
{
id: 'my_requests',
label: 'Minhas requisições',
path: '/requisicoes?view=mine',
},
{
id: 'my_drafts',
label: 'Os meus rascunhos',
path: '/requisicoes?view=mine&status=draft',
},
{
id: 'all_requests',
label: 'Todas as autorizadas',
path: '/requisicoes?view=all',
},
{ id: 'planning', label: 'Planeamento', path: '/planeamento' },
],
},
{
id: 'approvals',
label: 'Aprovações',
icon: BadgeCheck,
entries: [
{
id: 'pending_approvals',
label: 'Pendentes para mim',
path: '/requisicoes?view=approvals',
roles: readers,
},
{
id: 'decisions',
label: 'Minhas decisões',
path: '/requisicoes?view=decided',
roles: readers,
},
],
},
{
id: 'sourcing',
label: 'Consultas e adjudicações',
icon: Scale,
entries: [
{
id: 'quotations',
label: 'Cotações e comparação',
path: '/cotacoes',
roles: readers,
},
{
id: 'orders',
label: 'Adjudicações e ordens',
path: '/ordens',
roles: readers,
},
{
id: 'purchase_review',
label: 'Revisão e cancelamento',
path: '/revisao-compras',
},
],
},
{
id: 'suppliers',
label: 'Fornecedores',
icon: Users,
entries: [
{
id: 'suppliers',
label: 'Cadastro e documentos',
path: '/fornecedores',
roles: readers,
},
],
},
{
id: 'receiving',
label: 'Recepções e armazém',
icon: Warehouse,
entries: [
{
id: 'receipts',
label: 'Recepções e divergências',
path: '/recepcoes',
roles: inventory,
},
{
id: 'stock',
label: 'Artigos e stock',
path: '/stock',
roles: ['admin', 'procurement', 'finance', 'warehouse'],
},
{
id: 'stock_operations',
label: 'Saídas e inventários',
path: '/stock/operacoes',
roles: ['admin', 'procurement', 'finance', 'warehouse'],
},
{
id: 'equipment',
label: 'Equipamentos e peças',
path: '/equipamentos',
roles: inventory,
},
],
},
{
id: 'finance',
label: 'Finanças',
icon: Wallet,
entries: [
{
id: 'invoices',
label: 'Facturas e conferência',
path: '/facturas',
roles: finance,
},
...(payables
? [
{
id: 'payables',
label: 'Obrigações a fornecedores',
path: '/obrigacoes-fornecedores',
roles: finance,
fullPage: true,
},
]
: []),
{
id: 'payments',
label: 'Pagamentos externos',
path: '/pagamentos',
roles: finance,
},
{
id: 'budgets',
label: 'Orçamentos e reforços',
path: '/orcamentos',
roles: finance,
},
{
id: 'investment',
label: 'Regras de investimento',
path: '/regras-investimento',
roles: ['admin', 'procurement', 'finance'],
},
],
},
{
id: 'administration',
label: 'Administração e controlo',
icon: ShieldCheck,
entries: [
{
id: 'company',
label: 'Empresa, equipa e centros',
path: '/empresa',
roles: ['admin'],
},
{
id: 'admission_policy',
label: 'Política de admissão',
path: '/politica-admissao',
roles: ['admin'],
},
{
id: 'recommendation_policy',
label: 'Política de recomendação',
path: '/politica-recomendacao',
roles: ['admin'],
},
{
id: 'conflicts',
label: 'Conflitos de interesses',
path: '/conflitos',
},
],
},
];
return groups
.map((group) => ({
...group,
entries: group.entries.filter(
(entry) => !entry.roles || entry.roles.includes(role),
),
}))
.filter((group) => group.entries.length);
}
export function activeProcurementEntry(
groups: ProcurementGroup[],
url: string,
): string | undefined {
const current = new URL(url, 'http://local');
if (/^\/ordens\/\d+\/recepcoes/.test(current.pathname)) return 'receipts';
if (/^\/requisicoes\/\d+\/adjudicacao(?:\/|$)/.test(current.pathname))
return 'orders';
if (
/^\/requisicoes\/\d+\/(cotacoes|abertura|qualificacao|admissao|recomendacao|relatorio)(?:\/|$)/.test(
current.pathname,
)
)
return 'quotations';
const matches = groups
.flatMap((group) => group.entries)
.filter((entry) => {
const target = new URL(entry.path, 'http://local');
if (
current.pathname !== target.pathname &&
!current.pathname.startsWith(target.pathname + '/')
)
return false;
return [...target.searchParams].every(
([key, value]) =>
(current.searchParams.get(key) ??
(key === 'view' ? 'all' : null)) === value,
);
});
const specificity = (path: string) => {
const target = new URL(path, 'http://local');
return target.pathname.length * 100 + target.searchParams.size;
};
matches.sort((a, b) => specificity(b.path) - specificity(a.path));
return matches[0]?.id;
}
+10
View File
@@ -48,10 +48,12 @@ export default function Invoices({
invoices,
orders,
canManage,
payablesAvailable = false,
}: {
invoices: Page<Invoice>;
orders: { id: number; number: string; supplier: string; items: Source[] }[];
canManage: boolean;
payablesAvailable?: boolean;
}) {
const { auth } = usePage().props;
const [orderId, setOrderId] = useState('');
@@ -69,6 +71,7 @@ export default function Invoices({
return (
<>
<Head title="Facturas" />
{payablesAvailable && <a className="m-6 inline-block underline" href="/obrigacoes-fornecedores">Obrigações a fornecedores — ensaio sintético</a>}
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
<PageTitle
eyebrow="SGC · Finanças"
@@ -282,6 +285,13 @@ export default function Invoices({
{money(invoice.tax_minor)} · Total{' '}
{money(invoice.total_minor)}
</p>
{payablesAvailable && (
<p className="my-3">
<a className="underline" href={`/obrigacoes-fornecedores?invoice=${invoice.id}`}>
Consultar ou preparar obrigação desta factura
</a>
</p>
)}
<ul className="my-3 space-y-2">
{invoice.items.map((l) => (
<li key={l.id}>
+46 -4
View File
@@ -20,21 +20,48 @@ type Props = {
next_page_url: string | null;
total: number;
};
filters: { search?: string; status?: string };
filters: { search?: string; status?: string; view?: string };
};
export default function Index({ requisitions, filters }: Props) {
const form = useForm({
search: filters.search ?? '',
status: filters.status ?? '',
view: filters.view ?? 'all',
});
const views: Record<string, { title: string; description: string }> = {
all: {
title: 'Requisições autorizadas',
description:
'Consulte os pedidos disponíveis para o seu perfil e acompanhe o estado de cada processo.',
},
mine: {
title:
filters.status === 'draft'
? 'Os meus rascunhos'
: 'Minhas requisições',
description:
'Pedidos criados por si, desde o rascunho até à decisão.',
},
approvals: {
title: 'Pendentes para mim',
description:
'Pedidos em que a próxima etapa pode ser decidida por si. A elegibilidade é conferida pelo servidor.',
},
decided: {
title: 'Minhas decisões',
description:
'Histórico dos pedidos em que registou uma aprovação ou rejeição.',
},
};
const view = views[filters.view ?? 'all'] ?? views.all;
return (
<>
<Head title="Requisições" />
<Head title={view.title} />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Compras"
title="Requisições"
description="Registe necessidades e acompanhe cada pedido até à aprovação."
title={view.title}
description={view.description}
action={
<Button asChild>
<Link href="/requisicoes/criar">
@@ -52,6 +79,21 @@ export default function Index({ requisitions, filters }: Props) {
form.get('/requisicoes', { preserveState: true });
}}
>
<select
className={`${controlClass} max-w-60`}
aria-label="Conjunto de pedidos"
value={form.data.view}
onChange={(e) =>
form.setData('view', e.target.value)
}
>
<option value="all">Todas as autorizadas</option>
<option value="mine">Minhas requisições</option>
<option value="approvals">
Pendentes para mim
</option>
<option value="decided">Minhas decisões</option>
</select>
<Input
className="max-w-xs"
aria-label="Pesquisar pela referência"
+1
View File
@@ -11,4 +11,5 @@ export type NavItem = {
href: NonNullable<InertiaLinkProps['href']>;
icon?: LucideIcon | null;
isActive?: boolean;
fullPage?: boolean;
};
+3
View File
@@ -43,6 +43,9 @@
</x-inertia::head>
</head>
<body class="font-sans antialiased">
@if(app()->environment('payable-testing'))
<nav aria-label="Avaliação local" style="padding:10px 20px;background:#e1f1eb;color:#183d35;font:16px system-ui"><a href="/avaliar">Explorar Compras / acesso de proprietário</a> · Ambiente de testes</nav>
@endif
<x-inertia::app />
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
// Only the existing isolated local Compras evaluation database may use this entry.
$evaluationScope = static function (Request $request): User {
abort_unless(app()->environment('payable-testing')
&& in_array($request->ip(), ['127.0.0.1', '::1'], true)
&& in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true), 404);
$db = DB::connection();
abort_unless($db->getDriverName() === 'pgsql' && $db->getConfig('host') === '127.0.0.1'
&& preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $db->getDatabaseName()), 404);
$identity = $db->selectOne('select current_database() as db, current_user as role');
abort_unless($identity->db === $db->getDatabaseName()
&& $identity->role === 'payable_qa_'.substr($identity->db, -12), 404);
return User::where('email', 'preparacao-obrigacoes@example.test')->firstOrFail();
};
$evaluationPages = [
'/dashboard' => 'Visão geral', '/empresa' => 'Empresa e equipa',
'/requisicoes' => 'Requisições', '/planeamento' => 'Planeamento',
'/fornecedores' => 'Fornecedores', '/cotacoes' => 'Cotações', '/ordens' => 'Ordens de compra',
'/recepcoes' => 'Recepções', '/stock' => 'Stock e movimentos', '/stock/operacoes' => 'Saídas e inventários',
'/equipamentos' => 'Equipamentos e peças', '/orcamentos' => 'Orçamentos',
'/regras-investimento' => 'Regras de investimento', '/facturas' => 'Facturas',
'/pagamentos' => 'Pagamentos externos', '/obrigacoes-fornecedores' => 'Obrigações a fornecedores',
'/conflitos' => 'Conflitos de interesses', '/revisao-compras' => 'Revisão e cancelamento',
'/politica-admissao' => 'Política de admissão', '/politica-recomendacao' => 'Política de recomendação',
];
Route::get('/avaliar', function (Request $request) use ($evaluationScope, $evaluationPages) {
$evaluationScope($request);
$token = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
$html = '<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Explorar Compras</title><style>body{font:18px system-ui;background:#f4f6f5;color:#183d35;margin:0}main{max-width:1000px;margin:auto;padding:28px}section{background:white;padding:24px;border-radius:12px;margin:20px 0}button{font:inherit;padding:14px;border:0;border-radius:8px;background:#125a4b;color:white;cursor:pointer}a{color:#125a4b}li{margin:12px 0}form{margin:12px 0}</style><main><h1>Explorar Compras</h1><p>Entrada local sem palavra-passe. Os exemplos e alterações ficam guardados nesta instalação de testes.</p><section><h2>Acesso de proprietário</h2><form method="post" action="/avaliar/entrar"><input type="hidden" name="_token" value="'.$token.'"><input type="hidden" name="role" value="admin"><button>Entrar como proprietário</button></form><p>Abre a administração e as páginas dos módulos abaixo. As regras dos processos e as funções operacionais continuam visíveis.</p></section>';
if ($request->user()) {
$html .= '<section><h2>Áreas da aplicação</h2><ul>';
foreach ($evaluationPages as $path => $label) {
$html .= '<li><a href="'.$path.'">'.$label.'</a></li>';
}
$html .= '</ul></section>';
}
$html .= '<section><h2>Experimentar funções operacionais</h2><p>Para preparar ou decidir passos específicos, podes entrar nestes perfis sintéticos sem pedir contas ou credenciais.</p>';
foreach (PurchasingRole::cases() as $role) {
if ($role === PurchasingRole::Admin) continue;
$html .= '<form method="post" action="/avaliar/entrar"><input type="hidden" name="_token" value="'.$token.'"><input type="hidden" name="role" value="'.$role->value.'"><button>'.$role->label().'</button></form>';
}
return response($html.'</section><p>Ambiente sintético partilhado. Sem transferências bancárias. Não altera a instalação habitual na porta 8015. Aprovações próprias e restantes regras de integridade não são dispensadas.</p></main></html>')->header('Cache-Control', 'no-store')->header('X-Robots-Tag', 'noindex, nofollow');
});
Route::post('/avaliar/entrar', function (Request $request) use ($evaluationScope) {
$source = $evaluationScope($request);
$data = $request->validate(['role' => ['required', \Illuminate\Validation\Rule::enum(PurchasingRole::class)]]);
abort_if(array_diff(array_keys($request->all()), ['role', '_token']) !== [], 422);
$role = PurchasingRole::from($data['role']);
$user = DB::transaction(function () use ($source, $role) {
DB::select('select pg_advisory_xact_lock(?)', [80502910]);
$user = User::firstOrCreate(['email' => 'avaliacao-'.$role->value.'@example.test'],
['name' => $role === PurchasingRole::Admin ? 'Proprietário — avaliação local' : $role->label().' — avaliação local', 'password' => bin2hex(random_bytes(32))]);
abort_if($user->tenant_id !== null && (int) $user->tenant_id !== (int) $source->tenant_id, 403);
$user->forceFill(['tenant_id' => $source->tenant_id, 'department_id' => $source->department_id,
'purchasing_role' => $role, 'email_verified_at' => $user->email_verified_at ?? now()])->save();
// The isolated payables workspace has its own explicit action grants.
foreach ($role === PurchasingRole::Admin ? ['view', 'prepare', 'validate'] : ['view'] as $action) {
DB::table('pa_access')->insertOrIgnore(['user_id' => $user->id, 'tenant_id' => $source->tenant_id, 'company_id' => 0, 'unit_id' => 0, 'person' => 'evaluation-person-'.$user->id, 'action' => $action]);
}
return $user;
});
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
Auth::login($user, false);
$request->session()->regenerate();
app(\App\Services\RequisitionWorkflow::class)->audit($user, 'evaluation.entered', null, null, ['role' => $role->value, 'synthetic' => true]);
return redirect('/dashboard');
})->middleware('throttle:30,1');
+7
View File
@@ -28,6 +28,10 @@ use App\Http\Controllers\SupplierDocumentController;
use App\Http\Middleware\EnsureCompanyIsConfigured;
use Illuminate\Support\Facades\Route;
if (app()->environment('payable-testing')) {
require __DIR__.'/evaluation.php';
}
Route::inertia('/', 'home', ['auth' => ['user' => null]])->name('home');
require __DIR__.'/preview.php';
@@ -37,6 +41,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('obrigacoes-fornecedores', [\App\Http\Controllers\SupplierPayablesController::class,'page']);
Route::get('api/supplier-payables', [\App\Http\Controllers\SupplierPayablesController::class,'index']);
Route::post('api/supplier-payables/{action}', [\App\Http\Controllers\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate','prepare_history','validate_history']);
Route::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy');
Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']);
Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show');
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
const base = 'http://127.0.0.1:8005';
const cookies = new Map();
async function request(path, method = 'GET', body) {
const response = await fetch(base + path, {method, redirect: 'manual', headers: {
Accept: 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '),
...(body ? {'Content-Type': 'application/x-www-form-urlencoded'} : {}),
}, ...(body ? {body: new URLSearchParams(body)} : {})});
for (const cookie of response.headers.getSetCookie()) {
const pair = cookie.split(';', 1)[0], index = pair.indexOf('=');
cookies.set(pair.slice(0,index),pair.slice(index+1));
}
return {status: response.status, text: await response.text(), location: response.headers.get('location')};
}
const token = text => text.match(/name="_token" value="([^"]+)"/)[1];
async function enter(role) {
const page = await request('/avaliar');
assert.equal(page.status, 200);
const entered = await request('/avaliar/entrar', 'POST', {role, _token: token(page.text)});
assert.equal(entered.status, 302, role);
assert.equal(new URL(entered.location).pathname, '/dashboard');
assert.equal((await request('/dashboard')).status, 200);
}
let page = await request('/avaliar');
assert.equal((await request('/avaliar/entrar','POST',{role:'admin'})).status,419);
assert.equal((await request('/avaliar/entrar','POST',{role:'root',_token:token(page.text)})).status,422);
assert.equal((await request('/avaliar/entrar','POST',{role:'admin',user_id:'1',_token:token(page.text)})).status,422);
await enter('admin');
const modules = ['/dashboard','/empresa','/requisicoes','/planeamento','/fornecedores','/cotacoes','/ordens',
'/recepcoes','/stock','/stock/operacoes','/equipamentos','/orcamentos','/regras-investimento','/facturas',
'/pagamentos','/obrigacoes-fornecedores','/conflitos','/revisao-compras','/politica-admissao','/politica-recomendacao'];
for (const path of modules) assert.equal((await request(path)).status,200,path);
const roles = ['requester','department_head','procurement','finance','warehouse','admin'];
for (const role of roles) await enter(role);
page = await request('/avaliar');
assert.equal((await request('/logout','POST',{_token:token(page.text)})).status,204);
assert.equal((await request('/dashboard')).status,401);
assert.equal((await request('/empresa')).status,401);
const operational = await fetch('http://127.0.0.1:8015/avaliar', {redirect:'manual'}).catch(() => null);
if (operational) assert.equal(operational.status,404,'Evaluation entry must not exist on the normal installation');
await fs.mkdir('docs/proofs',{recursive:true});
await fs.writeFile('docs/proofs/evaluation-access.json',JSON.stringify({checked_at:new Date().toISOString(),base,
owner_modules:modules,roles,csrf_enforced:true,arbitrary_role_refused:true,arbitrary_user_refused:true,logout:true,
business_commands:0,real_accounts_changed:false,operational_entry_status:operational?.status??'not running',
visual_review:false},null,2)+'\n');
console.log('Owner entry, 20 module pages, six roles, CSRF, rejected identifiers and logout passed.');
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
const cookies = new Map(), base = 'http://127.0.0.1:8005';
async function request(path, method = 'GET', body) {
const r = await fetch(base + path, {method, redirect:'manual', headers:{Accept:'application/json',
Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),
...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},
...(body?{body:new URLSearchParams(body)}:{})});
for(const c of r.headers.getSetCookie()) {const p=c.split(';',1)[0],i=p.indexOf('=');cookies.set(p.slice(0,i),p.slice(i+1));}
return {status:r.status,text:await r.text()};
}
const landing=await request('/avaliar');
const csrf=landing.text.match(/name="_token" value="([^"]+)"/)[1];
assert.equal((await request('/avaliar/entrar','POST',{role:'admin',_token:csrf})).status,302);
const before=JSON.parse((await request('/api/supplier-payables')).text);
assert(before.documents.length);
for(const doc of before.documents) {
const filtered=await request('/api/supplier-payables?invoice='+doc.local_id);
assert.equal(filtered.status,200);
const data=JSON.parse(filtered.text);
assert.deepEqual(data.documents,[doc]);
const page=await request('/obrigacoes-fornecedores?invoice='+doc.local_id);
assert.equal(page.status,200);
assert(page.text.includes('href="/facturas"'));
assert(page.text.includes('Voltar às facturas'));
assert(page.text.includes(doc.number));
}
for(const query of ['0','-1','abc','1.5']) {
assert.equal((await request('/api/supplier-payables?invoice='+query)).status,422);
assert.equal((await request('/obrigacoes-fornecedores?invoice='+query)).status,422);
}
assert.equal((await request('/api/supplier-payables?invoice=2147483647')).status,404);
const after=JSON.parse((await request('/api/supplier-payables')).text);
assert.deepEqual(after,before,'Read-only navigation must preserve all documents and histories');
await fs.writeFile('docs/proofs/invoice-payable-link.json',JSON.stringify({checked_at:new Date().toISOString(),
documents_checked:before.documents.length,per_invoice_entry:true,return_to_invoices:true,
invalid_identifiers_refused:true,unknown_invoice_refused:true,full_listing_unchanged:true,business_commands:0,
visual_review:false},null,2)+'\n');
console.log('Per-invoice payable navigation, invalid/unknown identifiers and unchanged history passed.');
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import {procurementNavigation, activeProcurementEntry} from '../resources/js/lib/procurement-navigation.ts';
const base = 'http://127.0.0.1:8005';
const cookies = new Map();
let version = '';
async function request(path, {method = 'GET', body, inertia = false} = {}) {
const response = await fetch(base + path, {method, redirect: 'manual', headers: {
Accept: 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '),
...(body ? {'Content-Type': 'application/x-www-form-urlencoded'} : {}),
...(inertia ? {'X-Inertia': 'true', 'X-Inertia-Version': version} : {}),
}, ...(body ? {body: new URLSearchParams(body)} : {})});
for (const cookie of response.headers.getSetCookie()) {
const pair = cookie.split(';', 1)[0], index = pair.indexOf('=');
cookies.set(pair.slice(0,index),pair.slice(index+1));
}
const text = await response.text();
let data;
try {data = JSON.parse(text);} catch {}
if (!data) {
const page = text.match(/<script data-page="app" type="application\/json">([\s\S]*?)<\/script>/);
if (page) data = JSON.parse(page[1]);
}
if (data?.version) version = data.version;
return {status:response.status, text, data};
}
async function enter(role) {
const landing = await request('/avaliar');
assert.equal(landing.status, 200);
const csrf = landing.text.match(/name="_token" value="([^"]+)"/)[1];
assert.equal((await request('/avaliar/entrar', {method:'POST',body:{role,_token:csrf}})).status,302);
}
await enter('admin');
await request('/dashboard');
const baseline = (await request('/api/supplier-payables')).data;
const results = [];
for (const role of ['admin','procurement','finance','department_head','warehouse','requester']) {
await enter(role);
const dashboard = await request('/dashboard', {inertia:true});
assert.equal(dashboard.status,200);
assert.equal(dashboard.data.props.auth.user.purchasing_role,role);
const groups = procurementNavigation(role,dashboard.data.props.payablesAvailable === true);
const entries = groups.flatMap(group=>group.entries);
for (const entry of entries) {
const response = await request(entry.path,{inertia:!entry.fullPage});
assert.equal(response.status,200,role+' '+entry.path);
if (!entry.fullPage) assert(response.data?.component,role+' Inertia '+entry.path);
assert.equal(activeProcurementEntry(groups,entry.path),entry.id,role+' active '+entry.path);
if (entry.id === 'my_drafts') {
assert.equal(response.data.props.filters.view,'mine');
assert.equal(response.data.props.filters.status,'draft');
}
}
for (const path of ['/settings/profile','/settings/appearance']) assert.equal((await request(path,{inertia:true})).status,200,role+' '+path);
// Hidden administration and finance pages remain enforced on the server.
if (role !== 'admin') assert.equal((await request('/empresa')).status,403,role+' company isolation');
if (!['admin','finance'].includes(role)) assert.equal((await request('/facturas')).status,403,role+' finance isolation');
results.push({role,checked_entries:entries.length,paths:entries.map(entry=>entry.path)});
}
await enter('admin');
const all = await request('/requisicoes?view=all',{inertia:true});
const requestId = all.data.props.requisitions.data[0]?.id;
const processLinks = [];
if (requestId) {
assert.equal((await request('/requisicoes/'+requestId,{inertia:true})).status,200);
const process = await request('/requisicoes/'+requestId+'/processo',{inertia:true});
assert.equal(process.status,200);
function collectLinks(value) {
if (!value || typeof value !== 'object') return;
if (typeof value.href === 'string' && value.href.startsWith('/') && !value.href.startsWith('//')) processLinks.push(value.href);
for (const child of Object.values(value)) if (typeof child === 'object') collectLinks(child);
}
collectLinks(process.data.props);
for (const href of new Set(processLinks)) assert.equal((await request(href)).status,200,'Process link '+href);
}
const invoices = await request('/facturas',{inertia:true});
for (const invoice of invoices.data.props.invoices.data) {
const page = await request('/obrigacoes-fornecedores?invoice='+invoice.id);
assert.equal(page.status,200);
assert(page.text.includes('href="/facturas"'));
}
assert.deepEqual((await request('/api/supplier-payables')).data,baseline,'Business history must remain unchanged');
const groups = procurementNavigation('admin',true);
assert.equal(activeProcurementEntry(groups,'/requisicoes?status=draft&view=mine'),'my_drafts');
assert.equal(activeProcurementEntry(groups,'/ordens/1/recepcoes'),'receipts');
assert.equal(activeProcurementEntry(groups,'/requisicoes/1/cotacoes'),'quotations');
assert.equal(activeProcurementEntry(groups,'/requisicoes/1/relatorio'),'quotations');
assert.equal(activeProcurementEntry(groups,'/requisicoes/1/adjudicacao'),'orders');
assert.equal(activeProcurementEntry(groups,'/stock/operacoes'),'stock_operations');
await fs.writeFile('docs/proofs/menu-links.json',JSON.stringify({checked_at:new Date().toISOString(),results,
request_detail_checked:!!requestId,invoice_count:invoices.data.props.invoices.data.length,
process_links_checked:[...new Set(processLinks)],
canonical_query_order:true,active_subroutes:true,business_history_unchanged:true,business_commands:0},null,2)+'\n');
console.log('All visible menu links passed for six roles, with active entries, query filters, detail links and unchanged business history.');
+195
View File
@@ -0,0 +1,195 @@
<?php
// Every job is confined to the fresh disposable payables proof database.
$settingsFile = 'C:/laragon/data/sgf-tempo/secrets/payables-proof-compras.json';
$settings = json_decode(file_get_contents($settingsFile), true, 64, JSON_THROW_ON_ERROR);
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $settings['DB_DATABASE'] ?? '') || ($settings['DB_HOST'] ?? '') !== '127.0.0.1') {
throw new LogicException('Disposable proof only.');
}
foreach ($settings as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
require getcwd().'/vendor/autoload.php';
$app = require getcwd().'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierBankWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\Payables;
use Tempo\Finance\Payables\Workspace;
$identity = DB::selectOne('select current_database() as db, current_user as role');
if ($identity->db !== $settings['DB_DATABASE'] || $identity->role !== 'payable_qa_'.substr($identity->db, -12)) {
throw new LogicException('Proof identity mismatch.');
}
config(['common-payables.history_enabled' => true]);
$preparer = User::where('email', 'preparacao-obrigacoes@example.test')->firstOrFail();
$validator = User::where('email', 'validacao-obrigacoes@example.test')->firstOrFail();
auth()->guard()->setUser($preparer);
$invoice = Invoice::query()->sole();
$service = app(SupplierPayables::class);
$payments = app(ExternalPaymentWorkflow::class);
$job = $argv[1] ?? '';
function historyProofFingerprint(): string
{
$all = [];
foreach (['invoices', 'invoice_items', 'external_payments', 'budget_entries', 'budget_consumptions', 'stock_movements'] as $table) {
$all[$table] = DB::table($table)->orderBy('id')->get()->map(fn ($r) => (array) $r)->all();
}
return hash('sha256', Payables::canonical($all));
}
if ($job === 'setup') {
if (DB::table('external_payments')->exists() || DB::table('pa_proposals')->exists()) {
throw new LogicException('Fresh scenario required.');
}
$order = PurchaseOrder::query()->findOrFail($invoice->purchase_order_id);
$issuer = User::findOrFail($order->issued_by);
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
auth()->guard()->setUser($issuer);
$bank = app(SupplierBankWorkflow::class)->propose($issuer, $supplier, ['previous_id' => 0, 'bank_name' => 'Banco sintético', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular sintético', 'account_identifier' => 'HISTORY-PROOF-ONLY', 'evidence_reference' => 'Documento sintético', 'reason' => 'Preparar histórico exclusivamente na base descartável']);
foreach ([$preparer, $validator] as $reviewer) {
auth()->guard()->setUser($reviewer);
app(SupplierBankWorkflow::class)->review($reviewer, $bank, 'approved', 'Conferência sintética independente');
}
foreach ([['3000.00', 'confirmed'], ['1000.00', 'pending'], ['500.00', 'rejected']] as [$amount,$status]) {
auth()->guard()->setUser($preparer);
$payment = $payments->register($preparer, $invoice, ['supplier_bank_detail_id' => $bank->id, 'amount' => $amount, 'paid_on' => now()->format('Y-m-d'), 'external_reference' => 'HISTORY-'.$status, 'evidence_reference' => 'Prova sintética '.$status]);
if ($status !== 'pending') {
auth()->guard()->setUser($validator);
$payments->review($validator, $payment, $status, 'Decisão original sintética');
}
}
auth()->guard()->setUser($preparer);
$proposal = $service->engine($preparer)->command('prepare_history', $invoice->id, 'Preparar passagem com histórico existente', (string) Str::uuid());
if ($proposal['state'] !== 'prepared') {
throw new RuntimeException('History proposal not ready.');
}
$result = ['invoice_id' => $invoice->id, 'proposal_id' => $proposal['proposal_id'], 'business_fingerprint' => historyProofFingerprint(), 'balance_before' => $payments->outstanding($invoice)];
} elseif ($job === 'validate' || $job === 'fault') {
auth()->guard()->setUser($validator);
$proposal = DB::table('pa_proposals')->orderByDesc('id')->first();
if ($job === 'fault') {
$fail = true;
DB::listen(function (QueryExecuted $event) use (&$fail) {
if ($fail && str_contains($event->sql, 'insert into "pa_imported_payments"')) {
$fail = false;
throw new RuntimeException('HISTORY_PROOF_INJECTED_FAULT');
}
});
try {
$service->engine($validator)->command('validate_history', $proposal->id, 'Prova de recuperação atómica', (string) Str::uuid());
throw new LogicException('Fault not injected.');
} catch (RuntimeException $e) {
if ($e->getMessage() !== 'HISTORY_PROOF_INJECTED_FAULT') {
throw $e;
}
}
$clean = DB::table('pa_obligations')->count() === 0 && DB::table('pa_history_imports')->count() === 0 && DB::table('pa_imported_payments')->count() === 0;
if (! $clean) {
throw new RuntimeException('Partial history import remained.');
}
$result = ['fault_rolled_back' => true, 'business_fingerprint' => historyProofFingerprint()];
} else {
$result = $service->engine($validator)->command('validate_history', $proposal->id, 'Validação concorrente independente', (string) Str::uuid());
}
} elseif ($job === 'inspect') {
auth()->guard()->setUser($validator);
$listing = $service->listing($validator);
$position = $listing['obligations'][0];
$html = Workspace::render($listing, ['host' => 'Prova PostgreSQL', 'actor' => 'Validador sintético', 'organisation' => 'Empresa sintética', 'api' => '/api/supplier-payables', 'csrf' => 'proof-only']);
$result = ['principal_minor' => $service->principal($invoice), 'balance_minor' => $payments->outstanding($invoice),
'confirmed_minor' => $position['confirmed_payments_minor'], 'pending_minor' => $position['pending_payments_minor'], 'rejected_minor' => $position['rejected_payments_minor'],
'obligations' => DB::table('pa_obligations')->count(), 'imports' => DB::table('pa_history_imports')->count(), 'payment_links' => DB::table('pa_imported_payments')->count(),
'business_fingerprint' => historyProofFingerprint(), 'ui_balance_correct' => str_contains($html, 'Saldo por liquidar: 8 600.00 MZN'),
'due_on' => $position['due_on'], 'days_overdue' => $position['days_overdue']];
$protected = [];
foreach (['pa_history_imports', 'pa_imported_payments'] as $table) {
try {
DB::transaction(fn () => DB::table($table)->delete());
throw new LogicException('History was deletable.');
} catch (QueryException $e) {
if (! str_contains($e->getMessage(), 'PA_APPEND_ONLY')) {
throw $e;
}$protected[] = $table;
}
}
$result['append_only_tables'] = $protected;
$db = DB::connection();
$db->beginTransaction();
try {
DB::table('external_payments')->where('status', 'pending')->increment('amount_minor');
try {
$service->balance($invoice);
throw new LogicException('Changed original history was accepted.');
} catch (HttpException $e) {
if ($e->getStatusCode() !== 409) {
throw $e;
}$result['changed_history_refused'] = true;
}
} finally {
$db->rollBack();
}
if (historyProofFingerprint() !== $result['business_fingerprint']) {
throw new RuntimeException('Original history not restored.');
}
} elseif (in_array($job, ['decision_fault', 'decision_confirm', 'decision_inspect'], true)) {
config(['common-payables.history_decisions_enabled' => true]);
auth()->guard()->setUser($validator);
$payment = \App\Models\ExternalPayment::query()->where('amount_minor', 100000)->sole();
$frozen = fn () => hash('sha256', Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'links' => DB::table('pa_imported_payments')->get()->toArray()]));
$original = $frozen();
if ($job === 'decision_fault') {
$fingerprint = historyProofFingerprint();
$auditCount = DB::table('audit_logs')->count();
$fail = true;
DB::listen(function (QueryExecuted $event) use (&$fail) {
if ($fail && str_contains($event->sql, 'insert into "pa_payment_decisions"')) {
$fail = false;
throw new RuntimeException('DECISION_PROOF_FAULT');
}
});
try {
$payments->review($validator, $payment, 'confirmed', 'Prova de reversão atómica');
throw new LogicException('Expected injected decision failure.');
} catch (RuntimeException $e) {
if ($e->getMessage() !== 'DECISION_PROOF_FAULT') {
throw $e;
}
}
if ($payment->fresh()->status !== 'pending' || DB::table('pa_payment_decisions')->count() !== 0 || $auditCount !== DB::table('audit_logs')->count() || $fingerprint !== historyProofFingerprint()) {
throw new LogicException('Partial decision remained.');
}
$result = ['rolled_back' => true, 'balance_minor' => $service->balance($invoice)];
} elseif ($job === 'decision_confirm') {
$payments->review($validator, $payment, 'confirmed', 'Conferência concorrente sintética');
$result = ['balance_minor' => $service->balance($invoice), 'frozen' => $frozen(), 'original' => $original];
} else {
$position = $service->listing($validator)['obligations'][0];
try {
DB::transaction(fn () => DB::table('pa_payment_decisions')->delete());
throw new LogicException('Decision was deletable.');
} catch (QueryException $e) {
if (! str_contains($e->getMessage(), 'PA_APPEND_ONLY')) {
throw $e;
}
}
$result = ['balance_minor' => $service->balance($invoice), 'confirmed_minor' => $position['confirmed_payments_minor'], 'pending_minor' => $position['pending_payments_minor'],
'decisions' => DB::table('pa_payment_decisions')->count(), 'events' => DB::table('pa_events')->where('kind', 'payable.imported_payment_decided')->count(),
'original_status' => $payment->fresh()->status, 'append_only' => true, 'frozen' => $original];
}
} else {
throw new LogicException('Unknown proof job.');
}
echo json_encode($result,JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE).PHP_EOL;
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
const run=promisify(execFile);
const php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';
const sgf='C:/laragon/www/sgf-tempo';
for(const host of ['compras','sgf']) {
const exists=await fs.access(`C:/laragon/data/sgf-tempo/secrets/payables-proof-${host}.json`).then(()=>true,()=>false);
assert(!exists,'An existing proof must be reconciled before creating a new one.');
}
async function job(name) {
const {stdout}=await run(php,['scripts/payable-history-pg.php',name],{cwd:process.cwd(),windowsHide:true,timeout:90000,maxBuffer:100000});
return JSON.parse(stdout);
}
try {
await run(php,[`${sgf}/scripts/payables-provision.php`,'--proof'],{cwd:sgf,windowsHide:true,timeout:180000,maxBuffer:100000});
const before=await job('setup');
const fault=await job('fault');
assert(fault.fault_rolled_back);assert.equal(fault.business_fingerprint,before.business_fingerprint);
const recognised=await Promise.all([job('validate'),job('validate')]);
assert(recognised.every(result=>result.state==='recognised'));
assert.equal(recognised[0].obligation_id,recognised[1].obligation_id);
const after=await job('inspect');
assert.equal(after.principal_minor,1160000);assert.equal(after.balance_minor,860000);
assert.equal(after.confirmed_minor,300000);assert.equal(after.pending_minor,100000);assert.equal(after.rejected_minor,50000);
assert.equal(after.obligations,1);assert.equal(after.imports,1);assert.equal(after.payment_links,3);
assert.equal(after.business_fingerprint,before.business_fingerprint);
assert(after.ui_balance_correct);assert(after.changed_history_refused);
assert.equal(after.due_on,null);assert.equal(after.days_overdue,null);assert.equal(after.append_only_tables.length,2);
if(process.argv.includes('--decisions')) {
const fault=await job('decision_fault');
assert(fault.rolled_back);assert.equal(fault.balance_minor,860000);
const concurrent=await Promise.all([job('decision_confirm'),job('decision_confirm')]);
assert(concurrent.every(r=>r.balance_minor===760000 && r.frozen===r.original));
const final=await job('decision_inspect');
assert.equal(final.balance_minor,760000);assert.equal(final.confirmed_minor,400000);assert.equal(final.pending_minor,0);
assert.equal(final.decisions,1);assert.equal(final.events,1);assert.equal(final.original_status,'confirmed');assert(final.append_only);
assert(concurrent.every(r=>r.frozen===final.frozen));
await fs.writeFile('docs/proofs/payable-payment-decisions-pg.json',JSON.stringify({checked_at:new Date().toISOString(),passed:true,atomic_rollback:true,
concurrent_decision_once:true,original_import_preserved:true,new_transfers_executed:false,scenario:final,visual_review:false},null,2)+'\n');
}
await fs.writeFile('docs/proofs/payable-history-pg.json',JSON.stringify({checked_at:new Date().toISOString(),passed:true,
fault_recovery:true,concurrent_recognition:true,original_business_history_unchanged:true,new_transfers_executed:false,
scenario:after,visual_review:false},null,2)+'\n');
console.log('PostgreSQL historical import passed: gross principal, single deduction, pending/rejected preservation, concurrency, rollback and immutable history.');
} finally {
await run(php,[`${sgf}/scripts/payables-provision.php`,'--cleanup-proof'],{cwd:sgf,windowsHide:true,timeout:90000,maxBuffer:100000});
}
+14
View File
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierBankWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -204,3 +205,16 @@ test('financial role is reloaded after the company lock rather than trusting a s
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', $this->data)->assertForbidden();
expect(ExternalPayment::query()->count())->toBe(0);
});
test('payables trial stays unavailable outside its isolated database even with a source configured', function () {
config(['common-payables.source_instance' => 'synthetic-source']);
expect(SupplierPayables::enabled())->toBeFalse();
$this->get('/obrigacoes-fornecedores')->assertForbidden();
$this->getJson('/api/supplier-payables')->assertForbidden();
$this->postJson('/api/supplier-payables/prepare', ['id' => $this->f['invoice']->id, 'reason' => 'Ensaio isolado'])->assertForbidden();
$this->postJson('/api/supplier-payables/validate', ['id' => $this->f['invoice']->id, 'reason' => 'Ensaio isolado'])->assertForbidden();
confirmTestPayment($this, registerTestPayment($this));
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000);
});
+200
View File
@@ -0,0 +1,200 @@
<?php
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\HistoricalPayments;
use Tempo\Finance\Payables\Payables;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
config(['common-payables.enabled' => true, 'common-payables.history_enabled' => true, 'common-payables.source_instance' => 'history-fixture']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
foreach (['view', 'prepare', 'validate'] as $action) {
DB::table('pa_access')->insert(['tenant_id' => $user->tenant_id, 'company_id' => 0, 'unit_id' => 0, 'user_id' => $user->id, 'person' => 'history-person-'.$user->id, 'action' => $action]);
}
}
$this->actingAs($this->f['author']);
});
function originalHistoryPayment(object $test, string $amount, string $suffix, string $status = 'pending')
{
$test->actingAs($test->f['author']);
$workflow = app(ExternalPaymentWorkflow::class);
$payment = $workflow->register($test->f['author'], $test->f['invoice'], [...$test->f['payment_data'], 'amount' => $amount, 'external_reference' => 'HISTORY-'.$suffix]);
if ($status !== 'pending') {
$test->actingAs($test->f['validator']);
$workflow->review($test->f['validator'], $payment, $status, 'Decisão histórica sintética');
}
return $payment;
}
function prepareOriginalHistory(object $test): array
{
$test->actingAs($test->f['author']);
return app(SupplierPayables::class)->engine($test->f['author'])->command('prepare_history', $test->f['invoice']->id, 'Passagem sintética de histórico original', (string) Str::uuid());
}
function businessHistoryFingerprint(): string
{
$rows = [];
foreach (['invoices', 'invoice_items', 'external_payments', 'budget_entries', 'budget_consumptions', 'stock_movements'] as $table) {
$rows[$table] = DB::table($table)->orderBy('id')->get()->map(fn ($row) => (array) $row)->all();
}
return hash('sha256', Payables::canonical($rows));
}
test('historical import keeps gross principal and subtracts confirmed payments exactly once', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
originalHistoryPayment($this, '10.00', 'pending');
originalHistoryPayment($this, '5.00', 'rejected', 'rejected');
$before = businessHistoryFingerprint();
$proposal = prepareOriginalHistory($this);
expect($proposal['state'])->toBe('prepared');
$service = app(SupplierPayables::class);
expect(fn () => $service->engine($this->f['author'])->command('validate_history', $proposal['proposal_id'], 'Não validar proposta própria', (string) Str::uuid()))->toThrow(DomainException::class);
$this->actingAs($this->f['validator']);
$key = (string) Str::uuid();
$result = $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validação distinta do histórico', $key);
expect($result['state'])->toBe('recognised');
expect($service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validação distinta do histórico', $key)['replayed'])->toBeTrue();
$service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Outra chave, mesma responsabilidade', (string) Str::uuid());
expect(DB::table('pa_obligations')->count())->toBe(1)->and(DB::table('pa_history_imports')->count())->toBe(1)
->and(DB::table('pa_imported_payments')->count())->toBe(3)
->and($service->principal($this->f['invoice']))->toBe(6000)->and($service->balance($this->f['invoice']))->toBe(3000)
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(3000)
->and(businessHistoryFingerprint())->toBe($before);
$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()
->assertJsonPath('obligations.0.principal_minor', 6000)->assertJsonPath('obligations.0.balance_minor', 3000)
->assertJsonPath('obligations.0.pending_payments_minor', 1000)->assertJsonPath('obligations.0.rejected_payments_minor', 500)
->assertJsonPath('obligations.0.due_on', null)->assertJsonPath('obligations.0.history_imported', true);
$this->get('/obrigacoes-fornecedores')->assertOk()->assertSee('Saldo por liquidar: 30.00 MZN', false)->assertSee('sem abatimento');
$snapshots = DB::table('pa_history_imports')->value('snapshot');
expect($snapshots)->not->toContain('account_identifier')->not->toContain('external_reference')->not->toContain('TEST-PAY-ACCOUNT');
$recovered = prepareOriginalHistory($this);
expect($recovered['obligation_id'])->toBe($result['obligation_id'])->and(DB::table('pa_obligations')->count())->toBe(1);
});
test('a decision between proposal and validation invalidates the captured history', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
$pending = originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $pending, 'confirmed', 'Confirmado antes da passagem');
$service = app(SupplierPayables::class);
$result = $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Rever fotografia anterior', (string) Str::uuid());
expect($result['state'])->toBe('conflict')->and(DB::table('pa_obligations')->count())->toBe(0)
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000);
$new = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
$service->engine($this->f['validator'])->command('validate_history', $new['proposal_id'], 'Validar histórico actualizado', (string) Str::uuid());
expect($service->balance($this->f['invoice']))->toBe(2000);
});
test('pending-only and fully paid histories retain the correct balances', function (string $amount, string $status, int $balance) {
originalHistoryPayment($this, $amount, 'case', $status);
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
$service = app(SupplierPayables::class);
$service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Conferência de saldo extremo', (string) Str::uuid());
expect($service->principal($this->f['invoice']))->toBe(6000)->and($service->balance($this->f['invoice']))->toBe($balance);
})->with([['10.00', 'pending', 6000], ['60.00', 'confirmed', 0]]);
test('import does not enable new payments or confirmations of pending history', function () {
$pending = originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Transferir sem executar pagamentos', (string) Str::uuid());
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $pending, 'confirmed', 'Não confirmar depois da passagem'))->toThrow(HttpException::class);
$this->actingAs($this->f['author']);
expect(fn () => app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'external_reference' => 'NEW-PAYMENT']))->toThrow(HttpException::class);
expect(DB::table('external_payments')->value('status'))->toBe('pending');
});
test('missing audit and unapproved endpoints cannot import history', function () {
originalHistoryPayment($this, '10.00', 'pending');
DB::table('audit_logs')->where('action', 'payment.registered')->update(['action' => 'unverified.registration']);
expect(fn () => prepareOriginalHistory($this))->toThrow(DomainException::class);
config(['common-payables.history_enabled' => false]);
$this->postJson('/api/supplier-payables/prepare_history', ['id' => $this->f['invoice']->id, 'reason' => 'Não activado'])->assertForbidden();
expect(DB::table('pa_obligations')->count())->toBe(0);
});
test('failure during capture rolls back obligation and all imported facts', function () {
originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$fail = true;
DB::listen(function (QueryExecuted $event) use (&$fail) {
if ($fail && str_contains($event->sql, 'insert into "pa_imported_payments"')) {
$fail = false;
throw new RuntimeException('Injected historical link failure');
}
});
$this->actingAs($this->f['validator']);
expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Teste de atomicidade', (string) Str::uuid()))->toThrow(RuntimeException::class);
expect(DB::table('pa_obligations')->count())->toBe(0)->and(DB::table('pa_history_imports')->count())->toBe(0)->and(DB::table('pa_imported_payments')->count())->toBe(0);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Retoma após falha', (string) Str::uuid());
expect(DB::table('pa_obligations')->count())->toBe(1);
});
test('imported ledger is append-only and disabling the history cannot silently restore the old balance', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Preservar responsabilidade única', (string) Str::uuid());
expect(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0]))->toThrow(QueryException::class);
expect(fn () => DB::table('pa_imported_payments')->delete())->toThrow(QueryException::class);
config(['common-payables.history_enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class);
config(['common-payables.enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class);
});
test('normalizer refuses duplicated omitted or incompatible payment facts', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$snapshot = json_decode(DB::table('pa_proposals')->where('id', $proposal['proposal_id'])->value('snapshot'), true);
$source = app(SupplierPayables::class)->document($this->f['author'], $this->f['invoice']->id);
$rows = $snapshot['_history_import']['payments'];
expect(fn () => HistoricalPayments::capture($source, [$rows[0]]))->toThrow(DomainException::class);
expect(fn () => HistoricalPayments::capture($source, [$rows[0], $rows[0]]))->toThrow(DomainException::class);
foreach (['currency' => 'USD', 'document' => 999, 'amount_minor' => 7000, 'reviewed_by' => $rows[0]['created_by'], 'decision_audit_id' => null, 'status' => 'unknown'] as $field => $value) {
$changed = $rows;
$changed[0][$field] = $value;
expect(fn () => HistoricalPayments::capture($source, $changed))->toThrow(DomainException::class);
}
});
test('two installation identities cannot recognise two obligations for the same local invoice', function () {
originalHistoryPayment($this, '10.00', 'pending');
$first = prepareOriginalHistory($this);
config(['common-payables.source_instance' => 'changed-before-recognition']);
$second = prepareOriginalHistory($this);
config(['common-payables.source_instance' => 'history-fixture']);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $first['proposal_id'], 'Primeira identidade conferida', (string) Str::uuid());
config(['common-payables.source_instance' => 'changed-before-recognition']);
expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $second['proposal_id'], 'Não duplicar após mudança de configuração', (string) Str::uuid()))->toThrow(DomainException::class);
expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->positionForSource($this->f['invoice']->id))->toThrow(DomainException::class);
expect(DB::table('pa_obligations')->count())->toBe(1)->and(DB::table('pa_imported_payments')->count())->toBe(1);
});
test('the original 1160 example keeps 300 confirmed and 100 pending separate', function () {
$source = ['instance' => 'example', 'company' => 1, 'document' => 1, 'currency' => 'MZN', 'total_minor' => 116000, 'payment_count' => 2];
$payment = ['id' => 1, 'company' => 1, 'document' => 1, 'amount_minor' => 30000, 'currency' => 'MZN', 'status' => 'confirmed', 'created_by' => 2,
'paid_on' => '2026-10-29', 'registration_audit_id' => 1, 'reviewed_by' => 3, 'reviewed_at' => '2026-10-29 12:00:00', 'decision_audit_id' => 2];
$pending = [...$payment, 'id' => 2, 'amount_minor' => 10000, 'status' => 'pending', 'reviewed_by' => null, 'reviewed_at' => null, 'decision_audit_id' => null];
$capture = HistoricalPayments::capture($source, [$payment, $pending]);
expect($capture['confirmed_minor'])->toBe(30000)->and($capture['pending_minor'])->toBe(10000)
->and($source['total_minor'] - $capture['confirmed_minor'])->toBe(86000);
});
@@ -0,0 +1,109 @@
<?php
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\Payables;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
config(['common-payables.enabled' => true, 'common-payables.history_enabled' => true, 'common-payables.history_decisions_enabled' => true, 'common-payables.source_instance' => 'decision-fixture']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
foreach (['view', 'prepare', 'validate'] as $action) {
DB::table('pa_access')->insert(['tenant_id' => $user->tenant_id, 'company_id' => 0, 'unit_id' => 0, 'user_id' => $user->id, 'person' => 'decision-person-'.$user->id, 'action' => $action]);
}
}
$this->actingAs($this->f['author']);
$this->payment = app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'amount' => '10.00']);
$this->otherPayment = app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'amount' => '60.00', 'external_reference' => 'SECOND-PENDING']);
$service = app(SupplierPayables::class);
$proposal = $service->engine($this->f['author'])->command('prepare_history', $this->f['invoice']->id, 'Preparar histórico sintético', (string) Str::uuid());
$this->actingAs($this->f['validator']);
$service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validar histórico sintético', (string) Str::uuid());
$this->frozen = Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()]);
});
test('imported pending decisions change the balance once and preserve the import', function (string $decision, int $balance) {
$workflow = app(ExternalPaymentWorkflow::class);
$workflow->review($this->f['validator'], $this->payment, $decision, 'Conferência posterior à importação');
$workflow->review($this->f['validator'], $this->payment, $decision, 'Repetição sem novo efeito');
expect($this->payment->fresh()->status)->toBe($decision)
->and(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe($balance)
->and($workflow->outstanding($this->f['invoice']))->toBe($balance)
->and(DB::table('pa_payment_decisions')->count())->toBe(1)
->and(DB::table('pa_events')->where('kind', 'payable.imported_payment_decided')->count())->toBe(1)
->and(Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()]))->toBe($this->frozen);
$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonPath('obligations.0.balance_minor', $balance)->assertJsonPath('obligations.0.pending_payments_minor', 6000);
expect(fn () => $workflow->review($this->f['validator'], $this->payment, $decision === 'confirmed' ? 'rejected' : 'confirmed', 'Tentativa de reescrita'))->toThrow(ValidationException::class);
})->with([['confirmed', 5000], ['rejected', 6000]]);
test('decision failure rolls back original payment audit and ledger together', function () {
$audits = DB::table('audit_logs')->count();
$fail = true;
DB::listen(function (QueryExecuted $query) use (&$fail) {
if ($fail && str_contains($query->sql, 'insert into "pa_payment_decisions"')) {
$fail = false;
throw new RuntimeException('Synthetic decision failure');
}
});
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Teste de atomicidade'))->toThrow(RuntimeException::class);
expect($this->payment->fresh()->status)->toBe('pending')->and(DB::table('audit_logs')->count())->toBe($audits)
->and(DB::table('pa_payment_decisions')->count())->toBe(0)->and(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(6000);
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment->fresh(), 'confirmed', 'Recuperar após falha');
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(5000);
});
test('activation and independent eligible reviewer remain mandatory', function () {
config(['common-payables.history_decisions_enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Desactivado'))->toThrow(HttpException::class);
config(['common-payables.history_decisions_enabled' => true]);
$this->actingAs($this->f['author']);
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['author'], $this->payment, 'confirmed', 'Não conferir próprio registo'))->toThrow(HttpException::class);
$this->actingAs($this->f['validator']);
DB::table('pa_access')->where('user_id', $this->f['validator']->id)->where('action', 'validate')->delete();
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Direito retirado'))->toThrow(HttpException::class);
expect($this->payment->fresh()->status)->toBe('pending')->and(DB::table('pa_payment_decisions')->count())->toBe(0);
});
test('deciding historical pending does not allow new payments', function () {
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Conferir pendente');
config(['common-payables.history_decisions_enabled' => false]);
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(5000);
$this->actingAs($this->f['author']);
expect(fn () => app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'external_reference' => 'NEW']))->toThrow(HttpException::class);
});
test('unauthorised original changes invalidate balance and prevent decisions', function () {
DB::table('external_payments')->where('id', $this->payment->id)->update(['amount_minor' => 2000]);
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'rejected', 'Não adoptar alteração'))->toThrow(HttpException::class);
expect(DB::table('pa_payment_decisions')->count())->toBe(0)->and($this->payment->fresh()->status)->toBe('pending');
});
test('decision facts cannot be updated or deleted', function () {
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Conferir antes de testar imutabilidade');
expect(fn () => DB::table('pa_payment_decisions')->update(['actor_id' => 999]))->toThrow(QueryException::class);
expect(fn () => DB::table('pa_payment_decisions')->delete())->toThrow(QueryException::class);
});
test('confirmation rechecks available balance and rejection does not deduct', function () {
$workflow = app(ExternalPaymentWorkflow::class);
$workflow->review($this->f['validator'], $this->payment, 'confirmed', 'Primeiro pagamento confirmado');
expect(fn () => $workflow->review($this->f['validator'], $this->otherPayment, 'confirmed', 'Excede o saldo actualizado'))->toThrow(ValidationException::class);
expect($this->otherPayment->fresh()->status)->toBe('pending')->and(DB::table('pa_payment_decisions')->count())->toBe(1);
$workflow->review($this->f['validator'], $this->otherPayment, 'rejected', 'Rejeitar sem abater saldo');
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(5000)->and(DB::table('pa_payment_decisions')->count())->toBe(2);
});
test('a full confirmation leaves zero balance and prevents another confirmation', function () {
$workflow = app(ExternalPaymentWorkflow::class);
$workflow->review($this->f['validator'], $this->otherPayment, 'confirmed', 'Conferência integral');
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(0);
expect(fn () => $workflow->review($this->f['validator'], $this->payment, 'confirmed', 'Saldo já liquidado'))->toThrow(ValidationException::class);
});
+30
View File
@@ -24,6 +24,36 @@ function procurementPayload(int $center, string $price = '100.00', string $tax =
];
}
test('request navigation separates own requests from the complete authorised list', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
$this->actingAs($admin)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$own = Requisition::query()->where('requester_id', $admin->id)->sole();
$this->get('/requisicoes?view=all')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 2));
$this->get('/requisicoes?view=mine&status=draft')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $own->id)->where('filters.view', 'mine'));
$foreign = Tenant::query()->create(['name' => 'Empresa externa']);
$department = Department::query()->create(['tenant_id' => $foreign->id, 'name' => 'Outro departamento']);
$outsider = procurementUser($foreign, $department, PurchasingRole::Admin);
$this->actingAs($outsider)->get('/requisicoes?view=all')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
});
test('approval navigation uses eligibility and keeps personal decisions separate', function () {
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$request = Requisition::query()->sole();
$this->post('/requisicoes/'.$request->id.'/submeter')->assertRedirect();
$this->actingAs($head)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$own = Requisition::query()->where('requester_id', $head->id)->sole();
$this->post('/requisicoes/'.$own->id.'/submeter')->assertRedirect();
$this->get('/requisicoes?view=approvals')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $request->id));
$this->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->post('/requisicoes/'.$request->id.'/decidir', ['decision' => 'approved'])->assertRedirect();
$this->get('/requisicoes?view=approvals')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $request->id));
$this->actingAs($this->requester)->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->getJson('/requisicoes?view=unrestricted')->assertUnprocessable();
});
beforeEach(function () {
$this->tenant = Tenant::query()->create(['name' => 'Empresa A']);
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
@@ -0,0 +1,81 @@
<?php
use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\PayablesSchema;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
config(['common-payables.enabled' => true, 'common-payables.source_instance' => 'integration-fixture-instance']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
foreach (['view', 'prepare', 'validate'] as $action) {
DB::table('pa_access')->insert(['tenant_id' => $user->tenant_id, 'company_id' => 0, 'unit_id' => 0,
'user_id' => $user->id, 'person' => 'fixture-person-'.$user->id, 'action' => $action]);
}
}
$this->actingAs($this->f['author']);
});
test('explicit activation recognises a confirmed invoice once on the normal application schema', function () {
expect(SupplierPayables::enabled())->toBeTrue();
$service = app(SupplierPayables::class);
$prepared = $service->engine($this->f['author'])->command('prepare', $this->f['invoice']->id, 'Conferência de integração sintética', (string) Str::uuid());
expect($prepared['state'])->toBe('prepared');
$this->actingAs($this->f['validator']);
$recognised = $service->engine($this->f['validator'])->command('validate', $prepared['proposal_id'], 'Validação independente de teste', (string) Str::uuid());
expect($recognised['state'])->toBe('recognised');
$service->engine($this->f['validator'])->command('validate', $prepared['proposal_id'], 'Validação repetida de teste', (string) Str::uuid());
expect(DB::table('pa_obligations')->count())->toBe(1)
->and(DB::table('pa_obligations')->value('principal_minor'))->toBe(6000)
->and(DB::table('pa_obligations')->value('due_on'))->toBeNull()
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(6000);
$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonCount(1, 'obligations');
config(['common-payables.source_instance' => 'different-installation']);
expect(fn () => $service->principal($this->f['invoice']))->toThrow(HttpException::class);
});
test('legacy payments keep their balance and prevent automatic import of a second debt', function () {
$payments = app(ExternalPaymentWorkflow::class);
$payment = $payments->register($this->f['author'], $this->f['invoice'], $this->f['payment_data']);
$this->actingAs($this->f['validator']);
$payments->review($this->f['validator'], $payment, 'confirmed', 'Conferência sintética do histórico');
expect($payments->outstanding($this->f['invoice']))->toBe(2000);
$this->actingAs($this->f['author']);
$prepared = app(SupplierPayables::class)->engine($this->f['author'])->command('prepare', $this->f['invoice']->id, 'Tentativa com pagamento anterior', (string) Str::uuid());
expect($prepared['state'])->toBe('pending_source');
$this->actingAs($this->f['validator']);
$result = app(SupplierPayables::class)->engine($this->f['validator'])->command('validate', $prepared['proposal_id'], 'Rever histórico sem nova dívida', (string) Str::uuid());
expect($result['state'])->toBe('conflict')->and(DB::table('pa_obligations')->count())->toBe(0)
->and($payments->outstanding($this->f['invoice']))->toBe(2000);
});
test('activation does not grant permissions and source queries remain tenant scoped', function () {
DB::table('pa_access')->where('user_id', $this->f['author']->id)->delete();
$this->getJson('/api/supplier-payables')->assertForbidden();
$foreign = User::factory()->create(['tenant_id' => null]);
$this->actingAs($foreign);
expect(fn () => app(SupplierPayables::class)->principal($this->f['invoice']))->toThrow(HttpException::class);
config(['common-payables.enabled' => false]);
expect(SupplierPayables::enabled())->toBeFalse();
});
test('the shared schema preserves histories and refuses repeated or partial installation', function () {
expect(fn () => PayablesSchema::install())->toThrow(LogicException::class);
$service = app(SupplierPayables::class);
$service->engine($this->f['author'])->command('prepare', $this->f['invoice']->id, 'Criar histórico imutável de teste', (string) Str::uuid());
expect(fn () => DB::table('pa_events')->delete())->toThrow(QueryException::class);
});
test('navigation exposes the workspace only while the current actor has explicit view access', function () {
$this->get('/dashboard')->assertOk()->assertInertia(fn ($page) => $page->where('payablesAvailable', true));
DB::table('pa_access')->where('user_id', $this->f['author']->id)->where('action', 'view')->delete();
$this->get('/dashboard')->assertOk()->assertInertia(fn ($page) => $page->where('payablesAvailable', false));
$this->get('/obrigacoes-fornecedores')->assertForbidden();
});