@@ -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
|
||||
|
||||
@@ -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'].'%');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\SupplierPayables;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
public function index(Request $r, SupplierPayables $s): JsonResponse
|
||||
{
|
||||
$filter = $r->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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string,mixed> $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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Invoice;
|
||||
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
|
||||
{
|
||||
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[] = ['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<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 ['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<string,mixed> */
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user