Files
compras/app/Services/SupplierPayablePage.php
T
2026-11-03 11:51:44 +02:00

154 lines
9.2 KiB
PHP

<?php
namespace App\Services;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Payables;
/** Native sources, bounded by the document page. Financial positions belong to the common core. */
final class SupplierPayablePage
{
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
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<int,Invoice> $invoices
* @return array{array<int,array<string,mixed>>,array<int,list<array<string,mixed>>|\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);
}
}