diff --git a/.env.example b/.env.example index 1125a04..561ba5a 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,9 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Activação deliberada, depois de aplicar/verificar o esquema e conceder acessos. +COMMON_PAYABLES_ENABLED=false +COMMON_PAYABLES_HISTORY_ENABLED=false +COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED=false +PAYABLE_SOURCE_INSTANCE= diff --git a/.gitignore b/.gitignore index 77b2461..a37150b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ yarn-error.log /boost.json /opencode.json /opencode.jsonc +/storage/framework/payables/ diff --git a/README.md b/README.md index 2720ad6..ecf1fd3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existent ## Executar localmente +Conferência dos pagamentos pendentes após importação: [entrega e provas](docs/conferencia-pendentes-importados.md). Implementada em testes, com migração 000023 e activação ainda por aplicar; demonstração actual preservada. + +### Explorar a aplicação sem palavra-passe + +Na demonstração local isolada, abrir **http://127.0.0.1:8005/avaliar** e escolher **Entrar como proprietário**. A página «Explorar Compras», acessível também pela ligação acima da aplicação, reúne os 20 módulos/páginas principais. Disponibiliza ainda os seis perfis operacionais sintéticos para experimentar os respectivos passos sem procurar credenciais. Os dados e alterações desta demonstração persistem entre visitas. + +A entrada pertence exclusivamente à base QA local existente de obrigações, com empresa e exemplos sintéticos; não altera contas/dados da instalação normal em 8015. As regras de aprovação independente e de integridade permanecem. O proprietário tem o perfil administrativo e as três acções do ensaio de obrigações; os passos reservados a Procurement, Finanças ou Armazém usam os perfis de avaliação disponíveis. Não se declara que todos os percursos já tenham exemplos completos nesta base. + +Retomar o servidor existente, sem apagar dados: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\laragon\www\sgf-tempo\scripts\payables-demo.ps1 start`. Se já estiver activo, basta abrir a ligação. Verificação HTTP: `scripts/evaluation-access-check.mjs`; resultados em `docs/proofs/evaluation-access.json` (20 páginas, seis perfis, CSRF, recusa de identificadores arbitrários e saída; sem operações de negócio). A revisão visual do utilizador é separada. + Endereço local deste projecto: **http://127.0.0.1:8015**. Use `SERVER_PORT=8015`, `APP_URL=http://127.0.0.1:8015` e `SESSION_COOKIE=procuraflow_session` no `.env` para evitar colisões com outros projectos. O comando `php artisan serve` respeita `SERVER_PORT`. As dependências e o `.env` devem estar configurados. O projecto suporta a ligação PostgreSQL existente; os testes usam SQLite em memória. @@ -23,6 +33,8 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co ## Entrega actual +- Passagem explícita de pagamentos históricos para a obrigação comum: principal integral, confirmados deduzidos uma vez, pendentes/rejeitados preservados e validação distinta. Migração 000022 aplicada e activação histórica concluída em `compras_dev`, após backup/restauro e preservação conferida; sem incorporação automática. A demonstração em 8005 mantém o seu cenário anterior. Não executa pagamentos nem confirma pendentes após a passagem. Ver [entrega, aplicação e provas](docs/passagem-historico-pagamentos.md). + - Regras de investimento próprias por empresa: tecto por processo, base de IVA e vigência explícitos, com duas aprovações administrativas distintas do autor. Equipamento exige também validação técnica, Finanças e reserva em dotação de diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 1e68396..2b49e53 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -6,6 +6,7 @@ use App\Enums\PurchasingRole; use App\Models\Invoice; use App\Models\PurchaseOrder; use App\Services\InvoiceWorkflow; +use App\Services\SupplierPayables; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -17,7 +18,7 @@ class InvoiceController extends Controller { abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403); - return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]); + return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]); } public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php index 7dcb12f..dbe9133 100644 --- a/app/Http/Controllers/RequisitionController.php +++ b/app/Http/Controllers/RequisitionController.php @@ -25,8 +25,17 @@ class RequisitionController extends Controller { public function index(Request $request): Response { - $filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])]]); + $filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]); $query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']); + if (($filters['view'] ?? 'all') === 'mine') { + $query->where('requester_id', $request->user()->id); + } elseif (($filters['view'] ?? 'all') === 'approvals') { + $eligible = (clone $query)->where('status', 'pending')->get() + ->filter(fn (Requisition $requisition) => $requisition->canDecide($request->user()))->modelKeys(); + $query->whereIn('id', $eligible); + } elseif (($filters['view'] ?? 'all') === 'decided') { + $query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $request->user()->id)->whereIn('decision', ['approved', 'rejected'])); + } if (! empty($filters['search'])) { $query->where('number', 'like', '%'.$filters['search'].'%'); } diff --git a/app/Http/Controllers/SupplierPayablesController.php b/app/Http/Controllers/SupplierPayablesController.php new file mode 100644 index 0000000..5652ca6 --- /dev/null +++ b/app/Http/Controllers/SupplierPayablesController.php @@ -0,0 +1,43 @@ +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()); + } + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..618c5b6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Services\SupplierPayables; use Illuminate\Http\Request; use Inertia\Middleware; @@ -41,6 +42,7 @@ class HandleInertiaRequests extends Middleware 'auth' => [ 'user' => $request->user(), ], + 'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'), 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2741457..f008a02 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,11 @@ class AppServiceProvider extends ServiceProvider $package = base_path('../sgf-tempo/packages/budget-control'); } require_once $package.'/bootstrap.php'; + $payables = config('common-payables.path'); + if (! is_file($payables.'/bootstrap.php') && ! $this->app->environment('production')) { + $payables = base_path('../sgf-tempo/packages/payables'); + } + require_once $payables.'/bootstrap.php'; } /** diff --git a/app/Services/ExternalPaymentWorkflow.php b/app/Services/ExternalPaymentWorkflow.php index d96ca88..4fa99db 100644 --- a/app/Services/ExternalPaymentWorkflow.php +++ b/app/Services/ExternalPaymentWorkflow.php @@ -30,6 +30,11 @@ class ExternalPaymentWorkflow public function outstanding(Invoice $invoice): int { + $canonical = app(SupplierPayables::class)->balance($invoice); + if ($canonical !== null) { + return $canonical; + } + return (int) $invoice->total_minor - (int) ExternalPayment::query()->where('invoice_id', $invoice->id)->where('status', 'confirmed')->sum('amount_minor'); } @@ -59,6 +64,7 @@ class ExternalPaymentWorkflow /** @param array $data */ public function register(User $user, Invoice $invoice, array $data): ExternalPayment { + abort_if(app(SupplierPayables::class)->principal($invoice) !== null, 403, 'Pagamento de obrigação do novo ensaio fora do âmbito.'); $this->actor($user, $invoice->tenant_id); return DB::transaction(function () use ($user, $invoice, $data) { @@ -66,6 +72,7 @@ class ExternalPaymentWorkflow $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); $this->actor($user, $invoice->tenant_id); $invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id); + abort_if(app(SupplierPayables::class)->principal($invoice) !== null, 403, 'Pagamento de obrigação do novo ensaio fora do âmbito.'); $reference = Str::upper(Str::squish($data['external_reference'])); $amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt(); $canonical = ['invoice_id' => $invoice->id, 'bank_id' => (int) $data['supplier_bank_detail_id'], 'amount_minor' => $amount, 'paid_on' => $data['paid_on'], 'evidence_reference' => trim($data['evidence_reference'])]; @@ -102,7 +109,9 @@ class ExternalPaymentWorkflow $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); $this->actor($user, $payment->tenant_id); $invoice = Invoice::query()->lockForUpdate()->findOrFail($payment->invoice_id); + $historical = app(SupplierPayables::class)->assertHistoricalReview($user, $invoice); $payment = ExternalPayment::query()->lockForUpdate()->findOrFail($payment->id); + abort_if($payment->created_by === $user->id, 403); if ($payment->status !== 'pending') { if ($payment->status !== $decision) { $this->invalid('Conferência já decidida; não pode ser reescrita.'); @@ -122,8 +131,19 @@ class ExternalPaymentWorkflow $this->invalid('O autor do registo deixou de ser elegível.'); } } - $payment->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => $reason, 'reviewed_at' => now()]); - $this->audit($user, $invoice, $payment, 'payment.'.$decision); + $write = function () use ($payment, $decision, $user, $reason, $invoice) { + $payment->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => $reason, 'reviewed_at' => now()]); + $this->audit($user, $invoice, $payment, 'payment.'.$decision); + }; + if ($historical) { + try { + app(SupplierPayables::class)->engine($user)->decideImportedPayment($invoice->id, $payment->id, $write); + } catch (\DomainException $e) { + abort(409, $e->getMessage()); + } + } else { + $write(); + } }); } diff --git a/app/Services/SupplierPayables.php b/app/Services/SupplierPayables.php new file mode 100644 index 0000000..ac8b110 --- /dev/null +++ b/app/Services/SupplierPayables.php @@ -0,0 +1,196 @@ +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 */ + 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> + */ + 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 */ + 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 */ + 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|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; + } +} diff --git a/config/common-payables.php b/config/common-payables.php new file mode 100644 index 0000000..79fd4cd --- /dev/null +++ b/config/common-payables.php @@ -0,0 +1,3 @@ + 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)]; diff --git a/database/migrations/2026_10_29_000021_create_supplier_payables.php b/database/migrations/2026_10_29_000021_create_supplier_payables.php new file mode 100644 index 0000000..259e79d --- /dev/null +++ b/database/migrations/2026_10_29_000021_create_supplier_payables.php @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index bae551d..367a776 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -35,7 +35,7 @@ const generalNavItems: NavItem[] = [ ]; export function AppSidebar() { - const { auth } = usePage().props; + const { auth, payablesAvailable } = usePage().props; const canReadPurchases = [ 'admin', 'procurement', @@ -155,6 +155,9 @@ export function AppSidebar() { href: '/facturas', icon: FileCheck2, }, + ...(payablesAvailable === true + ? [{title: 'Obrigações a fornecedores', href: '/obrigacoes-fornecedores', icon: FileCheck2, fullPage: true}] + : []), { title: 'Pagamentos externos', href: '/pagamentos', diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx index 80fbd29..a465aa4 100644 --- a/resources/js/components/nav-main.tsx +++ b/resources/js/components/nav-main.tsx @@ -39,10 +39,17 @@ export function NavMain({ } tooltip={{ children: item.title }} > - - {item.icon && } - {item.title} - + {item.fullPage ? ( + + {item.icon && } + {item.title} + + ) : ( + + {item.icon && } + {item.title} + + )} ))} diff --git a/resources/js/components/sgc-shell.tsx b/resources/js/components/sgc-shell.tsx index caf7494..caf87e7 100644 --- a/resources/js/components/sgc-shell.tsx +++ b/resources/js/components/sgc-shell.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { UserMenuContent } from '@/components/user-menu-content'; +import { SgcWorkspaceShell } from '@/components/sgc-workspace-shell'; type Entry = { label: string; path: string; demo: string; roles?: string[] }; const readers = ['admin', 'procurement', 'finance', 'department_head']; @@ -159,7 +160,7 @@ const groups: { label: string; entries: Entry[] }[] = [ }, ]; -export function SgcShell({ +function SgcPreviewShell({ children, demo = false, }: { @@ -320,3 +321,17 @@ export function SgcShell({ ); } + +export function SgcShell({ + children, + demo = false, +}: { + children: ReactNode; + demo?: boolean; +}) { + return demo ? ( + {children} + ) : ( + {children} + ); +} diff --git a/resources/js/components/sgc-workspace-shell.tsx b/resources/js/components/sgc-workspace-shell.tsx new file mode 100644 index 0000000..64b3e92 --- /dev/null +++ b/resources/js/components/sgc-workspace-shell.tsx @@ -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>({}); + 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 = ( + + + SGC + + + + Compras + + + Sistema de Gestão de Compras + + + + ); + const searchField = (mobile = false) => ( +
+
+ ); + const navigation = ( + + ); + const sidebarFooter = ( +
+

+ SGC · Espaço de trabalho +

+ + Aparência + + · + + Página inicial + +
+ ); + return ( +
+ + Saltar para o conteúdo + + + + + + Menu de Compras + + Escolhe uma área da aplicação ou procura pelo nome. + + + {brand} + {searchField(true)} + {navigation} + {sidebarFooter} + + +
+
+
+ +
+

+ COMPRAS +

+

+ {page.url.split('?')[0] === '/dashboard' + ? 'Painel inicial' + : (activeGroup?.label ?? + 'Espaço de trabalho')} +

+
+
+ {searchField()} +
+ + {user && ( + + + + + + + + + )} +
+
+
+ {children} +
+
+ SGC · Sistema de Gestão de Compras +
+
+
+ ); +} diff --git a/resources/js/lib/procurement-navigation.ts b/resources/js/lib/procurement-navigation.ts new file mode 100644 index 0000000..636b77d --- /dev/null +++ b/resources/js/lib/procurement-navigation.ts @@ -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; +} diff --git a/resources/js/pages/invoices/index.tsx b/resources/js/pages/invoices/index.tsx index 38f163c..5d61652 100644 --- a/resources/js/pages/invoices/index.tsx +++ b/resources/js/pages/invoices/index.tsx @@ -48,10 +48,12 @@ export default function Invoices({ invoices, orders, canManage, + payablesAvailable = false, }: { invoices: Page; orders: { id: number; number: string; supplier: string; items: Source[] }[]; canManage: boolean; + payablesAvailable?: boolean; }) { const { auth } = usePage().props; const [orderId, setOrderId] = useState(''); @@ -69,6 +71,7 @@ export default function Invoices({ return ( <> + {payablesAvailable && Obrigações a fornecedores — ensaio sintético}
+ {payablesAvailable && ( +

+ + Consultar ou preparar obrigação desta factura + +

+ )}
    {invoice.items.map((l) => (
  • diff --git a/resources/js/pages/requisitions/index.tsx b/resources/js/pages/requisitions/index.tsx index 667bcd8..9e39c4d 100644 --- a/resources/js/pages/requisitions/index.tsx +++ b/resources/js/pages/requisitions/index.tsx @@ -20,21 +20,48 @@ type Props = { next_page_url: string | null; total: number; }; - filters: { search?: string; status?: string }; + filters: { search?: string; status?: string; view?: string }; }; export default function Index({ requisitions, filters }: Props) { const form = useForm({ search: filters.search ?? '', status: filters.status ?? '', + view: filters.view ?? 'all', }); + const views: Record = { + 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 ( <> - +
    @@ -52,6 +79,21 @@ export default function Index({ requisitions, filters }: Props) { form.get('/requisicoes', { preserveState: true }); }} > + ; icon?: LucideIcon | null; isActive?: boolean; + fullPage?: boolean; }; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 6987b35..df33cb3 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -43,6 +43,9 @@ + @if(app()->environment('payable-testing')) + + @endif diff --git a/routes/evaluation.php b/routes/evaluation.php new file mode 100644 index 0000000..d231712 --- /dev/null +++ b/routes/evaluation.php @@ -0,0 +1,80 @@ +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 = 'Explorar Compras

    Explorar Compras

    Entrada local sem palavra-passe. Os exemplos e alterações ficam guardados nesta instalação de testes.

    Acesso de proprietário

    Abre a administração e as páginas dos módulos abaixo. As regras dos processos e as funções operacionais continuam visíveis.

    '; + if ($request->user()) { + $html .= '

    Áreas da aplicação

      '; + foreach ($evaluationPages as $path => $label) { + $html .= '
    • '.$label.'
    • '; + } + $html .= '
    '; + } + $html .= '

    Experimentar funções operacionais

    Para preparar ou decidir passos específicos, podes entrar nestes perfis sintéticos sem pedir contas ou credenciais.

    '; + foreach (PurchasingRole::cases() as $role) { + if ($role === PurchasingRole::Admin) continue; + $html .= '
    '; + } + return response($html.'

    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.

    ')->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'); diff --git a/routes/web.php b/routes/web.php index 3cc9d13..7e6425f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,6 +28,10 @@ use App\Http\Controllers\SupplierDocumentController; use App\Http\Middleware\EnsureCompanyIsConfigured; use Illuminate\Support\Facades\Route; +if (app()->environment('payable-testing')) { + require __DIR__.'/evaluation.php'; +} + Route::inertia('/', 'home', ['auth' => ['user' => null]])->name('home'); require __DIR__.'/preview.php'; @@ -37,6 +41,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store'); Route::middleware(EnsureCompanyIsConfigured::class)->group(function () { + Route::get('obrigacoes-fornecedores', [\App\Http\Controllers\SupplierPayablesController::class,'page']); + Route::get('api/supplier-payables', [\App\Http\Controllers\SupplierPayablesController::class,'index']); + Route::post('api/supplier-payables/{action}', [\App\Http\Controllers\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate','prepare_history','validate_history']); Route::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy'); Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']); Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show'); diff --git a/scripts/evaluation-access-check.mjs b/scripts/evaluation-access-check.mjs new file mode 100644 index 0000000..607392a --- /dev/null +++ b/scripts/evaluation-access-check.mjs @@ -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.'); diff --git a/scripts/invoice-payable-link-check.mjs b/scripts/invoice-payable-link-check.mjs new file mode 100644 index 0000000..b95e808 --- /dev/null +++ b/scripts/invoice-payable-link-check.mjs @@ -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.'); diff --git a/scripts/menu-links-check.mjs b/scripts/menu-links-check.mjs new file mode 100644 index 0000000..e7cd66a --- /dev/null +++ b/scripts/menu-links-check.mjs @@ -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(/