Files
2026-11-03 11:51:44 +02:00

133 lines
6.4 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\GeneralLedger\LedgerSchema;
use Tempo\Finance\GeneralLedger\PayableJournal;
use Tempo\Finance\GeneralLedger\ReportsWorkspace;
use Tempo\Finance\Payables\Payables;
/** Standalone adapter; same local package, native finance authority, no SGF remote service. */
final class GeneralLedger
{
/** @return array{actor:User,scope:array<string,int>,person:string} */
private function context(User $user): array
{
abort_unless(app()->environment('payable-testing') && config('general-ledger.synthetic_enabled') === true, 403);
$package = config('general-ledger.path');
abort_unless(is_string($package) && is_file($package.'/bootstrap.php'), 503, 'Pacote contabilístico não composto.');
require_once $package.'/bootstrap.php';
LedgerSchema::assertSynthetic(DB::connection());
$context = app(SupplierPayables::class)->readContext($user);
abort_unless(DB::table('pa_access')->where($context['scope'])->where(['user_id' => $user->id, 'action' => 'general_ledger.view', 'person' => $context['person']])->exists(), 403);
return $context;
}
public function can(User $user, string $action): bool
{
$c = $this->context($user);
return DB::table('pa_access')->where($c['scope'])->where(['user_id' => $user->id, 'action' => 'general_ledger.'.$action, 'person' => $c['person']])->exists();
}
public function engine(User $user, ?\Closure $fault = null): PayableJournal
{
$c = $this->context($user);
$scope = $c['scope'];
$person = $c['person'];
$authorize = function (string $action, bool $lock = true) use ($user, $scope, $person) {
if ($lock) {
app(ConflictGuard::class)->lockCompany($user);
app(CurrentActor::class)->lock($user, [PurchasingRole::Finance, PurchasingRole::Admin]);
}
$fresh = $this->context($user);
abort_unless($fresh['scope'] === $scope && $fresh['person'] === $person, 409, 'Pessoa ou contexto alterados.');
abort_unless($this->can($user, $action), 403);
};
return new PayableJournal(DB::connection(), $scope, $user->id, $person, fn ($a) => $authorize($a), fn () => $authorize('view', false), fn ($id) => $this->source($user, $scope, $id), $fault);
}
/** @param array<string,int> $scope
* @return array<string,mixed>
*/
private function source(User $user, array $scope, int $id): array
{
$o = DB::table('pa_obligations')->where($scope)->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação indisponível nesta organização.');
$origin = DB::table('pa_sources')->where($scope)->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.');
$finance = app(SupplierPayables::class);
$doc = $finance->document($user, (int) $origin->local_source_id);
$position = $finance->engine($user)->positionForSource((int) $origin->local_source_id);
if (! $position || (int) $position['id'] !== $id || $position['source_conflict']) {
throw new \DomainException('Origem financeira mudou; nova conferência necessária.');
}
$p = DB::table('pa_proposals')->where($scope)->where('id', $o->proposal_id)->first() ?? throw new \DomainException('Conferência original ausente.');
return ['effect_type' => 'supplier_payable.recognised', 'origin_key' => $origin->source_key, 'obligation_id' => $id, 'local_source_id' => (int) $origin->local_source_id, 'document_revision' => $o->source_revision, 'recognition_revision' => hash('sha256', Payables::canonical(['obligation' => (array) $o, 'recognition_snapshot' => $p->snapshot])), 'principal_minor' => (int) $o->principal_minor, 'currency' => $o->currency, 'due_on' => $o->due_on, 'tax_minor' => $doc['tax_minor'], 'compatible' => true, 'history_imported' => $position['history_imported']];
}
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function report(User $user, string $kind, array $options): array
{
if (! in_array($kind, ['trial-balance', 'account-ledger'], true)) {
throw new \InvalidArgumentException('Relatório desconhecido.');
}
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $kind, $options, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$engine = $this->engine($user);
abort_unless(class_exists(ReportsWorkspace::class), 503, 'Relatórios não incluídos no pacote composto.');
foreach (LedgerSchema::TABLES as $table) {
abort_unless(DB::getSchemaBuilder()->hasTable($table), 503, 'Fonte contabilística indisponível neste ensaio.');
}
return $kind === 'trial-balance' ? $engine->trialBalance($options) : $engine->accountLedger($options);
});
}
/** @return array<string,mixed> */
public function workspace(User $user, int $page = 1): array
{
abort_unless(config('general-ledger.cycle_enabled') === true, 403);
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $page, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$engine = $this->engine($user);
$data = $engine->cycleListing($page);
$data['book'] = $engine->listing(['page' => $page]);
$actions = ['prepare', 'validate', 'post', 'correct_prepare', 'correct_validate', 'correct_post', 'period_prepare', 'period_validate', 'period_close'];
$data['permissions'] = array_combine($actions, array_map(fn ($a) => $this->can($user, $a), $actions));
return $data;
});
}
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function listing(User $user, array $options = []): array
{
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $options, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->engine($user)->listing($options);
});
}
}