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

258 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\InvoiceItem;
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
{
if (app(OrganizationAccess::class)->active($u->tenant_id) && ! $u->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
return false;
}
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[] = self::paymentFacts($payment, $registration, $decision);
}
return $result;
}
/** @return array<string,mixed> */
public static function paymentFacts(\stdClass $payment, ?\stdClass $registration, ?\stdClass $decision): array
{
if (! $registration || ($payment->status !== 'pending' && ! $decision)) {
throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.');
}
return ['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 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 self::sourceSnapshot($invoice, $order, $supplier, $audit, array_values($invoice->items()->orderBy('id')->get()->map(fn ($l) => self::lineSnapshot($l))->all()),
DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count());
}
/** @return array<string,mixed> */
public static function lineSnapshot(InvoiceItem $line): array
{
return ['id' => $line->id, 'order_line' => $line->purchase_order_item_id, 'quantity_milli' => (int) $line->quantity_milli,
'subtotal_minor' => (int) $line->subtotal_minor, 'tax_minor' => (int) $line->tax_minor, 'snapshot' => $line->snapshot];
}
/** @param list<array<string,mixed>> $lines
* @return array<string,mixed>
*/
public static function sourceSnapshot(Invoice $invoice, ?\stdClass $order, ?\stdClass $supplier, ?\stdClass $audit, array $lines, int $paymentCount): array
{
return ['local_id' => $invoice->id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $invoice->tenant_id, 'document' => $invoice->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' => $lines, 'payment_count' => $paymentCount];
}
/** Fresh read authorization; unlike commands, it never takes writer locks.
* @return array{actor:User,scope:array<string,int>,person:string}
*/
public function readContext(User $u): array
{
abort_unless(self::enabled() && auth()->id() === $u->id && $u->tenant_id !== null, 403);
$fresh = User::query()->where('tenant_id', $u->tenant_id)->findOrFail($u->id);
$access = app(OrganizationAccess::class);
$contexts = app(WorkContext::class);
abort_if($access->maintenanceOnly($fresh), 403);
if ($contexts->enforced($fresh)) {
abort_unless(app()->bound('request') && request()->hasSession() && $contexts->active(request()) === 'procurement.internal', 403, 'Seleccione Compras internas para consultar obrigações.');
abort_unless(array_intersect($access->permissions($fresh, 'procurement.internal'), ['finance', 'admin']) !== [], 403);
}
abort_unless($fresh->hasFunctions([PurchasingRole::Finance, PurchasingRole::Admin]) && $this->can($fresh, 'view'), 403);
$scope = $this->scope($fresh);
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $fresh->id, 'action' => 'view'])->value('person');
abort_unless(is_string($person) && $person !== '', 403);
return ['actor' => $fresh, 'scope' => $scope, 'person' => $person];
}
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);
}
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function listing(User $u, ?int $invoiceId = null, array $options = []): array
{
if (config('common-payables.paged_read_enabled') === true) {
abort_unless(in_array('listingPage', get_class_methods(Payables::class), true), 503, 'Pacote financeiro sem leitura paginada; composição explícita pendente.');
return app(SupplierPayablePage::class)->listing($u, $invoiceId, $options);
}
abort_if($options !== [], 503, 'Consulta paginada ainda não activada nesta composição.');
$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['payments_available'] = app()->environment('payable-testing') && Schema::hasTable('ps_payments') && $this->can($u, 'payments_view');
$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;
}
}