$options * @return array */ public function listing(User $user, ?int $invoiceId, array $options): array { abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Leitura paginada requer a composição PostgreSQL conferida.'); $own = DB::transactionLevel() === 0; return DB::transaction(function () use ($user, $invoiceId, $options, $own) { if ($own) { DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); } $adapter = app(SupplierPayables::class); $context = $adapter->readContext($user); $actor = $context['actor']; $scope = $context['scope']; $page = (int) ($options['page'] ?? 1); $size = (int) ($options['per_page'] ?? 25); $text = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all'; abort_unless($page >= 1 && $page <= 100000 && in_array($size, [25, 100], true) && mb_strlen($text) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422); $query = Invoice::query()->where('tenant_id', $actor->tenant_id); if ($invoiceId !== null) { (clone $query)->whereKey($invoiceId)->firstOrFail(); $query->whereKey($invoiceId); } if ($text !== '') { $needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $text).'%'; $query->where(fn ($q) => $q->where('number', 'ilike', $needle)->orWhereRaw("snapshot->'supplier'->>'name' ILIKE ?", [$needle])); } if ($recognition !== 'all') { $method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists'; $query->$method(fn ($q) => $q->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id') ->whereColumn('s.local_source_id', 'invoices.id')->whereColumn('s.tenant_id', 'invoices.tenant_id')->where('s.company_id', 0)->where('s.unit_id', 0) ->whereColumn('o.tenant_id', 'invoices.tenant_id')->where('o.company_id', 0)->where('o.unit_id', 0)); } $pager = $query->orderByDesc('id')->paginate($size, ['*'], 'page', $page); [$documents, $histories] = $this->sources($actor, $pager->items()); $read = function () use ($adapter, $user, $context) { $now = $adapter->readContext($user); abort_unless($now['scope'] === $context['scope'] && $now['person'] === $context['person'], 409, 'Identidade/contexto alterado; reabra a consulta.'); }; $engine = new Payables(DB::connection(), $actor->id, $context['person'], $scope, static function () { throw new \LogicException('Snapshots de consulta nunca executam comandos.'); }, fn (int $id) => $documents[$id] ?? abort(403), SupplierPayables::historyEnabled() ? function (int $id) use ($histories) { $history = $histories[$id] ?? new \DomainException('Histórico ausente nesta página.'); if ($history instanceof \DomainException) { throw $history; } return $history; } : null, $read); $data = $engine->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $size); $data['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()]; $data['listing_query'] = ['page' => $page, 'per_page' => $size, 'q' => $text, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)]; if ($invoiceId !== null) { $data['listing_query']['invoice'] = $invoiceId; } $data['permissions'] = ['prepare' => $adapter->can($actor, 'prepare'), 'validate' => $adapter->can($actor, 'validate')]; $data['payments_available'] = app()->environment('payable-testing') && DB::getSchemaBuilder()->hasTable('ps_payments') && $adapter->can($actor, 'payments_view'); $data['history_import_available'] = SupplierPayables::historyEnabled(); $data['history_decisions_available'] = SupplierPayables::historyDecisionsEnabled(); return $data; }); } /** @param array $invoices * @return array{array>,array>|\DomainException>} */ private function sources(User $actor, array $invoices): array { $rows = collect($invoices); $ids = $rows->pluck('id')->all(); $orders = DB::table('purchase_orders')->where('tenant_id', $actor->tenant_id)->whereIn('id', $rows->pluck('purchase_order_id'))->get(['id'])->keyBy('id'); $suppliers = DB::table('suppliers')->where('tenant_id', $actor->tenant_id)->whereIn('id', $rows->pluck('supplier_id'))->get(['id'])->keyBy('id'); $lines = InvoiceItem::query()->where('tenant_id', $actor->tenant_id)->whereIn('invoice_id', $ids)->orderBy('id')->get()->groupBy('invoice_id'); $validations = DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)->where('action', 'invoice.validated')->whereIn('after->invoice_id', array_map('strval', $ids))->orderByDesc('id')->get(['id', 'after']); $audits = []; foreach ($validations as $audit) { $after = json_decode($audit->after, true, 64, JSON_THROW_ON_ERROR); $audits[(int) $after['invoice_id']] ??= $audit; } $historical = SupplierPayables::historyEnabled(); $payments = collect(); $counts = []; $paymentQuery = DB::table('external_payments')->where('tenant_id', $actor->tenant_id)->whereIn('invoice_id', $ids); if ($historical) { $payments = $paymentQuery->orderBy('id')->get(['id', 'tenant_id', 'invoice_id', 'amount_minor', 'currency', 'status', 'created_by', 'paid_on', 'reviewed_by', 'reviewed_at']); $counts = $payments->groupBy('invoice_id')->map(fn ($p) => $p->count())->all(); } else { $counts = $paymentQuery->selectRaw('invoice_id, count(*) AS n')->groupBy('invoice_id')->pluck('n', 'invoice_id')->all(); } $paymentAudits = []; if ($historical) { $auditRows = DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)->whereIn('action', ['payment.registered', 'payment.confirmed', 'payment.rejected'])->whereIn('after->payment_id', $payments->pluck('id')->map(fn ($id) => (string) $id)->all())->orderByDesc('id')->get(['id', 'user_id', 'action', 'after']); foreach ($auditRows as $audit) { $a = json_decode($audit->after, true, 64, JSON_THROW_ON_ERROR); $key = $this->auditKey($audit->action, $audit->user_id, $a['payment_id'] ?? '', $a['invoice_id'] ?? '', $a['amount_minor'] ?? '', $a['status'] ?? ''); $paymentAudits[$key] ??= $audit; } } $histories = []; foreach ($payments as $payment) { $registration = $paymentAudits[$this->auditKey('payment.registered', $payment->created_by, $payment->id, $payment->invoice_id, $payment->amount_minor, 'pending')] ?? null; $decision = $payment->status === 'pending' ? null : ($paymentAudits[$this->auditKey('payment.'.$payment->status, $payment->reviewed_by, $payment->id, $payment->invoice_id, $payment->amount_minor, $payment->status)] ?? null); try { $fact = SupplierPayables::paymentFacts($payment, $registration, $decision); $current = $histories[$payment->invoice_id] ?? []; if (! ($current instanceof \DomainException)) { $current[] = $fact; $histories[$payment->invoice_id] = $current; } } catch (\DomainException $e) { $histories[$payment->invoice_id] = $e; } } $documents = []; foreach ($invoices as $invoice) { $documents[$invoice->id] = SupplierPayables::sourceSnapshot($invoice, $orders->get($invoice->purchase_order_id), $suppliers->get($invoice->supplier_id), $audits[$invoice->id] ?? null, array_values(($lines->get($invoice->id) ?? collect())->map(fn ($l) => SupplierPayables::lineSnapshot($l))->all()), (int) ($counts[$invoice->id] ?? 0)); } return [$documents, $histories]; } private function auditKey(mixed ...$values): string { foreach ($values as $value) { if (! is_scalar($value) && $value !== null) { return 'INVALID-AUDIT'; } } return json_encode(array_map('strval', $values), JSON_THROW_ON_ERROR); } }