258 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|