@@ -65,3 +65,9 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
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=
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ yarn-error.log
|
|||||||
/boost.json
|
/boost.json
|
||||||
/opencode.json
|
/opencode.json
|
||||||
/opencode.jsonc
|
/opencode.jsonc
|
||||||
|
/storage/framework/payables/
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existent
|
|||||||
|
|
||||||
## Executar localmente
|
## 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`.
|
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.
|
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
|
## 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
|
- 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.
|
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
|
Equipamento exige também validação técnica, Finanças e reserva em dotação de
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Enums\PurchasingRole;
|
|||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Models\PurchaseOrder;
|
use App\Models\PurchaseOrder;
|
||||||
use App\Services\InvoiceWorkflow;
|
use App\Services\InvoiceWorkflow;
|
||||||
|
use App\Services\SupplierPayables;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -17,7 +18,7 @@ class InvoiceController extends Controller
|
|||||||
{
|
{
|
||||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
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
|
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
|
||||||
|
|||||||
@@ -25,8 +25,17 @@ class RequisitionController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): Response
|
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']);
|
$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'])) {
|
if (! empty($filters['search'])) {
|
||||||
$query->where('number', 'like', '%'.$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;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\SupplierPayables;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
|
'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'),
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$package = base_path('../sgf-tempo/packages/budget-control');
|
$package = base_path('../sgf-tempo/packages/budget-control');
|
||||||
}
|
}
|
||||||
require_once $package.'/bootstrap.php';
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class ExternalPaymentWorkflow
|
|||||||
|
|
||||||
public function outstanding(Invoice $invoice): int
|
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');
|
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 */
|
/** @param array<string,mixed> $data */
|
||||||
public function register(User $user, Invoice $invoice, array $data): ExternalPayment
|
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);
|
$this->actor($user, $invoice->tenant_id);
|
||||||
|
|
||||||
return DB::transaction(function () use ($user, $invoice, $data) {
|
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);
|
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
||||||
$this->actor($user, $invoice->tenant_id);
|
$this->actor($user, $invoice->tenant_id);
|
||||||
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->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']));
|
$reference = Str::upper(Str::squish($data['external_reference']));
|
||||||
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
|
$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'])];
|
$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);
|
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
||||||
$this->actor($user, $payment->tenant_id);
|
$this->actor($user, $payment->tenant_id);
|
||||||
$invoice = Invoice::query()->lockForUpdate()->findOrFail($payment->invoice_id);
|
$invoice = Invoice::query()->lockForUpdate()->findOrFail($payment->invoice_id);
|
||||||
|
$historical = app(SupplierPayables::class)->assertHistoricalReview($user, $invoice);
|
||||||
$payment = ExternalPayment::query()->lockForUpdate()->findOrFail($payment->id);
|
$payment = ExternalPayment::query()->lockForUpdate()->findOrFail($payment->id);
|
||||||
|
abort_if($payment->created_by === $user->id, 403);
|
||||||
if ($payment->status !== 'pending') {
|
if ($payment->status !== 'pending') {
|
||||||
if ($payment->status !== $decision) {
|
if ($payment->status !== $decision) {
|
||||||
$this->invalid('Conferência já decidida; não pode ser reescrita.');
|
$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.');
|
$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()]);
|
$write = function () use ($payment, $decision, $user, $reason, $invoice) {
|
||||||
$this->audit($user, $invoice, $payment, 'payment.'.$decision);
|
$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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
# Desenvolvimento do SGC
|
# 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
|
## Estado consolidado após abertura e actas — 28 de Outubro de 2026
|
||||||
|
|
||||||
Referência: commit `07e775c` e [validação técnica](validacao-tecnica.md).
|
Referência: commit `07e775c` e [validação técnica](validacao-tecnica.md).
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,5 +1,37 @@
|
|||||||
# Continuidade do desenvolvimento do SGC
|
# 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.
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="DB_URL" value=""/>
|
<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="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const generalNavItems: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { auth } = usePage().props;
|
const { auth, payablesAvailable } = usePage().props;
|
||||||
const canReadPurchases = [
|
const canReadPurchases = [
|
||||||
'admin',
|
'admin',
|
||||||
'procurement',
|
'procurement',
|
||||||
@@ -155,6 +155,9 @@ export function AppSidebar() {
|
|||||||
href: '/facturas',
|
href: '/facturas',
|
||||||
icon: FileCheck2,
|
icon: FileCheck2,
|
||||||
},
|
},
|
||||||
|
...(payablesAvailable === true
|
||||||
|
? [{title: 'Obrigações a fornecedores', href: '/obrigacoes-fornecedores', icon: FileCheck2, fullPage: true}]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
title: 'Pagamentos externos',
|
title: 'Pagamentos externos',
|
||||||
href: '/pagamentos',
|
href: '/pagamentos',
|
||||||
|
|||||||
@@ -39,10 +39,17 @@ export function NavMain({
|
|||||||
}
|
}
|
||||||
tooltip={{ children: item.title }}
|
tooltip={{ children: item.title }}
|
||||||
>
|
>
|
||||||
<Link href={item.href} prefetch>
|
{item.fullPage ? (
|
||||||
{item.icon && <item.icon />}
|
<a href={toUrl(item.href)}>
|
||||||
<span>{item.title}</span>
|
{item.icon && <item.icon />}
|
||||||
</Link>
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link href={item.href} prefetch>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { UserMenuContent } from '@/components/user-menu-content';
|
import { UserMenuContent } from '@/components/user-menu-content';
|
||||||
|
import { SgcWorkspaceShell } from '@/components/sgc-workspace-shell';
|
||||||
|
|
||||||
type Entry = { label: string; path: string; demo: string; roles?: string[] };
|
type Entry = { label: string; path: string; demo: string; roles?: string[] };
|
||||||
const readers = ['admin', 'procurement', 'finance', 'department_head'];
|
const readers = ['admin', 'procurement', 'finance', 'department_head'];
|
||||||
@@ -159,7 +160,7 @@ const groups: { label: string; entries: Entry[] }[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SgcShell({
|
function SgcPreviewShell({
|
||||||
children,
|
children,
|
||||||
demo = false,
|
demo = false,
|
||||||
}: {
|
}: {
|
||||||
@@ -320,3 +321,17 @@ export function SgcShell({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -48,10 +48,12 @@ export default function Invoices({
|
|||||||
invoices,
|
invoices,
|
||||||
orders,
|
orders,
|
||||||
canManage,
|
canManage,
|
||||||
|
payablesAvailable = false,
|
||||||
}: {
|
}: {
|
||||||
invoices: Page<Invoice>;
|
invoices: Page<Invoice>;
|
||||||
orders: { id: number; number: string; supplier: string; items: Source[] }[];
|
orders: { id: number; number: string; supplier: string; items: Source[] }[];
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
|
payablesAvailable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { auth } = usePage().props;
|
const { auth } = usePage().props;
|
||||||
const [orderId, setOrderId] = useState('');
|
const [orderId, setOrderId] = useState('');
|
||||||
@@ -69,6 +71,7 @@ export default function Invoices({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Facturas" />
|
<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">
|
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="SGC · Finanças"
|
eyebrow="SGC · Finanças"
|
||||||
@@ -282,6 +285,13 @@ export default function Invoices({
|
|||||||
{money(invoice.tax_minor)} · Total{' '}
|
{money(invoice.tax_minor)} · Total{' '}
|
||||||
{money(invoice.total_minor)}
|
{money(invoice.total_minor)}
|
||||||
</p>
|
</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">
|
<ul className="my-3 space-y-2">
|
||||||
{invoice.items.map((l) => (
|
{invoice.items.map((l) => (
|
||||||
<li key={l.id}>
|
<li key={l.id}>
|
||||||
|
|||||||
@@ -20,21 +20,48 @@ type Props = {
|
|||||||
next_page_url: string | null;
|
next_page_url: string | null;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
filters: { search?: string; status?: string };
|
filters: { search?: string; status?: string; view?: string };
|
||||||
};
|
};
|
||||||
export default function Index({ requisitions, filters }: Props) {
|
export default function Index({ requisitions, filters }: Props) {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
search: filters.search ?? '',
|
search: filters.search ?? '',
|
||||||
status: filters.status ?? '',
|
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 (
|
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">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="Compras"
|
eyebrow="Compras"
|
||||||
title="Requisições"
|
title={view.title}
|
||||||
description="Registe necessidades e acompanhe cada pedido até à aprovação."
|
description={view.description}
|
||||||
action={
|
action={
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/requisicoes/criar">
|
<Link href="/requisicoes/criar">
|
||||||
@@ -52,6 +79,21 @@ export default function Index({ requisitions, filters }: Props) {
|
|||||||
form.get('/requisicoes', { preserveState: true });
|
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
|
<Input
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
aria-label="Pesquisar pela referência"
|
aria-label="Pesquisar pela referência"
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export type NavItem = {
|
|||||||
href: NonNullable<InertiaLinkProps['href']>;
|
href: NonNullable<InertiaLinkProps['href']>;
|
||||||
icon?: LucideIcon | null;
|
icon?: LucideIcon | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
fullPage?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
</x-inertia::head>
|
</x-inertia::head>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<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 />
|
<x-inertia::app />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -28,6 +28,10 @@ use App\Http\Controllers\SupplierDocumentController;
|
|||||||
use App\Http\Middleware\EnsureCompanyIsConfigured;
|
use App\Http\Middleware\EnsureCompanyIsConfigured;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
if (app()->environment('payable-testing')) {
|
||||||
|
require __DIR__.'/evaluation.php';
|
||||||
|
}
|
||||||
|
|
||||||
Route::inertia('/', 'home', ['auth' => ['user' => null]])->name('home');
|
Route::inertia('/', 'home', ['auth' => ['user' => null]])->name('home');
|
||||||
require __DIR__.'/preview.php';
|
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::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||||
|
|
||||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
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::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy');
|
||||||
Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']);
|
Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']);
|
||||||
Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show');
|
Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show');
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -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.');
|
||||||
@@ -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.');
|
||||||
@@ -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;
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
|||||||
use App\Services\ExternalPaymentWorkflow;
|
use App\Services\ExternalPaymentWorkflow;
|
||||||
use App\Services\RequisitionWorkflow;
|
use App\Services\RequisitionWorkflow;
|
||||||
use App\Services\SupplierBankWorkflow;
|
use App\Services\SupplierBankWorkflow;
|
||||||
|
use App\Services\SupplierPayables;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
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();
|
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', $this->data)->assertForbidden();
|
||||||
expect(ExternalPayment::query()->count())->toBe(0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 () {
|
beforeEach(function () {
|
||||||
$this->tenant = Tenant::query()->create(['name' => 'Empresa A']);
|
$this->tenant = Tenant::query()->create(['name' => 'Empresa A']);
|
||||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
|
$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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user