commit15
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\HistoricalPayments;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads explicitly reconciled immutable receipts; never imports or decides payments. */
|
||||
final class SupplierHistoryReceipts
|
||||
{
|
||||
public const VERSION = 'SGF-PAYABLE-HISTORY-RECEIPT-SIM-v1';
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
SupplierPayables::guard();
|
||||
|
||||
return config('supplier_payable_history.synthetic_enabled') === true
|
||||
&& DB::getSchemaBuilder()->hasTable('pa_source_histories')
|
||||
&& (new HistoricalPayments(DB::connection(), []))->available();
|
||||
}
|
||||
|
||||
public function payments(User $user, int $localId): array
|
||||
{
|
||||
abort_unless($this->available(), 503, 'Recibos históricos não ligados neste ensaio.');
|
||||
$adapter = app(SupplierPayables::class);
|
||||
$scope = $adapter->scope($user);
|
||||
abort_unless($adapter->can($user, 'view'), 403);
|
||||
$source = $adapter->document($user, $localId);
|
||||
$receipt = DB::table('pa_source_histories')->where($scope)->where('local_source_id', $localId)->orderByDesc('version')->first();
|
||||
|
||||
return $this->decode($source, $receipt);
|
||||
}
|
||||
|
||||
/** Local read snapshots only; no command obtains its source through this batch. */
|
||||
public function page(User $user, array $documents): array
|
||||
{
|
||||
abort_unless($this->available(), 503);
|
||||
$adapter = app(SupplierPayables::class); $scope = $adapter->scope($user);
|
||||
abort_unless($adapter->can($user, 'view'), 403);
|
||||
$receipts = DB::table('pa_source_histories')->where($scope)->whereIn('local_source_id', array_keys($documents))
|
||||
->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id');
|
||||
$result = [];
|
||||
foreach ($documents as $id => $source) {
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id)); }
|
||||
catch (\DomainException $e) { $result[$id] = $e; }
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function decode(array $source, ?object $receipt): array
|
||||
{
|
||||
if (! $receipt) {
|
||||
throw new \DomainException('Histórico não reconciliado para esta origem e contexto.');
|
||||
}
|
||||
$payload = json_decode($receipt->payload, true, 64, JSON_THROW_ON_ERROR);
|
||||
if (! is_array($payload) || ! hash_equals($receipt->receipt_hash, hash('sha256', Payables::canonical($payload)))) {
|
||||
throw new \DomainException('Recibo histórico divergente.');
|
||||
}
|
||||
if (!(($payload['version'] ?? null) === self::VERSION
|
||||
&& ($payload['source_key'] ?? null) === Payables::identity($source)
|
||||
&& ($payload['source_revision'] ?? null) === Payables::revision($source)
|
||||
&& ($payload['receipt_version'] ?? null) === (int) $receipt->version
|
||||
&& is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) {
|
||||
throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.');
|
||||
}
|
||||
$capture = HistoricalPayments::capture($source, $payload['payments']);
|
||||
if (($payload['history_hash'] ?? null) !== $capture['history_hash']) {
|
||||
throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.');
|
||||
}
|
||||
|
||||
return $capture['payments'];
|
||||
}
|
||||
}
|
||||
@@ -38,36 +38,88 @@ final class SupplierPayables
|
||||
public function document(User $u, int $id): array
|
||||
{
|
||||
$row = DB::table('pa_imported_sources')->where($this->scope($u))->where('id', $id)->first() ?? abort(403, 'Origem não mapeada neste contexto.');
|
||||
|
||||
return $this->receiptDocument($row);
|
||||
}
|
||||
|
||||
private function receiptDocument(object $row): array
|
||||
{
|
||||
$s = json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($row->receipt_hash, hash('sha256', Payables::canonical($s))), 409, 'Recibo de origem divergente.');
|
||||
$s['local_id'] = $id;
|
||||
$s['local_id'] = (int) $row->id;
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function engine(User $u): Payables
|
||||
{
|
||||
return $this->buildEngine($u);
|
||||
}
|
||||
|
||||
private function buildEngine(User $u, ?array $documents = null, ?array $histories = null): Payables
|
||||
{
|
||||
$scope = $this->scope($u);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless($person, 403);
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, function (string $a) use ($u, $scope, $person) {
|
||||
$authorize = function (string $a, bool $lock = true) use ($u, $scope, $person) {
|
||||
self::guard();
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
|
||||
if ($lock) {
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.');
|
||||
abort_unless($this->scope($u) === $scope, 409, 'Contexto mudou.');
|
||||
app(CompanyContext::class)->selectFor($u->id, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
abort_unless($this->can($u, 'view') && $this->can($u, $a), 403);
|
||||
}, fn (int $id) => $this->document($u, $id));
|
||||
};
|
||||
$source = $documents === null ? fn (int $id) => $this->document($u, $id) : fn (int $id) => $documents[$id] ?? abort(403);
|
||||
$history = $histories === null ? fn (int $id) => app(SupplierHistoryReceipts::class)->payments($u, $id) : function (int $id) use ($histories) {
|
||||
$result = $histories[$id] ?? new \DomainException('Histórico não reconciliado nesta página.');
|
||||
if ($result instanceof \DomainException) { throw $result; }
|
||||
return $result;
|
||||
};
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source,
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false));
|
||||
}
|
||||
|
||||
public function listing(User $u): array
|
||||
public function listing(User $u, array $options = []): array
|
||||
{
|
||||
$d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all());
|
||||
$own = DB::transactionLevel() === 0;
|
||||
return DB::transaction(function () use ($u, $options, $own) {
|
||||
if ($own) { DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); }
|
||||
$scope = $this->scope($u); abort_unless($this->can($u, 'view'), 403);
|
||||
$page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25);
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
$pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = [];
|
||||
foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); }
|
||||
$provider = app(SupplierHistoryReceipts::class);
|
||||
$histories = $provider->available() ? $provider->page($u, $documents) : [];
|
||||
$d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage);
|
||||
$d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
|
||||
$d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
$d['history_import_available'] = app(SupplierHistoryReceipts::class)->available();
|
||||
$d['history_decisions_available'] = false;
|
||||
if ($d['history_import_available']) {
|
||||
$d['history_receipt_notice'] = 'Recibo histórico de ensaio: a origem é uma fotografia conferida. Não há leitura histórica em tempo real nem passagem operacional de responsabilidade validada.';
|
||||
}
|
||||
$d['payments_available'] = app()->environment('payable-testing') && DB::connection()->getSchemaBuilder()->hasTable('ps_payments') && $this->can($u, 'payments_view');
|
||||
|
||||
return $d;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,24 @@ use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
private function options(Request $r): array
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100',
|
||||
'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user()))->header('Cache-Control', 'no-store');
|
||||
return response()->json($s->listing($r->user(), $this->options($r)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user());
|
||||
$data = $s->listing($r->user(), $this->options($r));
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
|
||||
Reference in New Issue
Block a user