This commit is contained in:
2026-11-02 20:29:34 +02:00
parent 340f3bb5fa
commit fcf31e8d48
181 changed files with 33383 additions and 51 deletions
+4 -2
View File
@@ -93,9 +93,11 @@ final class HistoricalPayments
}
}
public function matches(object $import, array $capture): bool
public function matches(object $import, array $capture, ?array $preloaded = null): bool
{
$rows = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get();
$rows = $preloaded === null
? $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get()
: collect($preloaded);
if ($import->history_hash !== $capture['history_hash'] || $import->source_key !== $capture['source_key']
|| Payables::canonical(json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($capture) || $rows->count() !== count($capture['payments'])) {
return false;
+53 -8
View File
@@ -10,7 +10,7 @@ final class Payables
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
public function __construct(private ConnectionInterface $db, private int $actor, private string $person, private array $scope,
private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null) {}
private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null, private ?\Closure $authorizeRead = null) {}
public static function canonical(array $data): string
{
@@ -123,9 +123,9 @@ final class Payables
}
}
private function history(array $source): array
private function history(array $source, bool $availabilityChecked = false): array
{
if ($this->loadHistory === null || ! (new HistoricalPayments($this->db, $this->scope))->available()) {
if ($this->loadHistory === null || (! $availabilityChecked && ! (new HistoricalPayments($this->db, $this->scope))->available())) {
throw new \DomainException('Passagem de histórico não activada nesta instalação.');
}
@@ -141,10 +141,10 @@ final class Payables
return self::missing($source);
}
private function position(object $obligation, ?array $source): array
private function position(object $obligation, ?array $source, ?array $page = null): array
{
$store = new HistoricalPayments($this->db, $this->scope);
$import = $store->imported((int) $obligation->id);
$import = $page === null ? $store->imported((int) $obligation->id) : ($page['imports'][$obligation->id] ?? null);
$current = null;
$compatible = false;
if ($source) {
@@ -152,15 +152,17 @@ final class Payables
&& $this->eligibility($source, $import !== null) === [];
if ($import) {
try {
$current = (new PaymentDecisions($this->db, $this->scope))->current($import);
$compatible = $compatible && self::canonical($current) === self::canonical($this->history($source));
$current = (new PaymentDecisions($this->db, $this->scope))->current($import,
$page === null ? null : ($page['payments'][$obligation->id] ?? []),
$page === null ? null : ($page['decisions'][$obligation->id] ?? []));
$compatible = $compatible && self::canonical($current) === self::canonical($this->history($source, $page !== null));
} catch (\DomainException $e) {
$compatible = false;
}
}
}
$confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0);
$allocated = SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id);
$allocated = $page === null ? SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id) : ($page['allocated'][$obligation->id] ?? 0);
$balance = (int) $obligation->principal_minor - $confirmed - $allocated;
if ($balance < 0) {
$compatible = false;
@@ -342,6 +344,49 @@ final class Payables
return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
}
/** Bounded source window with independent proposal/event pages; commands never use these snapshots. */
public function listingPage(array $localSources, int $proposalPage = 1, int $eventPage = 1, int $perPage = 25): array
{
if (count($localSources) > 100 || ! in_array($perPage, [25, 100], true) || $proposalPage < 1 || $eventPage < 1
|| array_filter($localSources, fn ($id) => ! is_int($id) || $id < 1)) {
throw new \InvalidArgumentException('Janela de leitura financeira inválida.');
}
return $this->db->transaction(function () use ($localSources, $proposalPage, $eventPage, $perPage) {
$this->authorizeRead === null ? ($this->authorize)('view') : ($this->authorizeRead)();
$documents = []; $current = [];
foreach ($localSources as $id) {
$s = $this->source($id);
$doc = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
$documents[] = $doc; $current[self::identity($doc)] = $doc;
}
$sources = fn () => $this->q('sources')->whereIn('local_source_id', $localSources)->select('id');
$obligations = $this->q('obligations')->whereIn('source_id', $sources())->orderBy('id')->get();
$origins = $this->q('sources')->whereIn('id', $obligations->pluck('source_id'))->get()->keyBy('id');
$ids = $obligations->pluck('id')->map(fn ($id) => (int) $id)->all();
$page = ['imports' => [], 'payments' => [], 'decisions' => [], 'allocated' => SupplierSettlements::allocatedPage($this->db, $this->scope, $ids)];
if ((new HistoricalPayments($this->db, $this->scope))->available()) {
$page['imports'] = $this->q('history_imports')->whereIn('obligation_id', $ids)->get()->keyBy('obligation_id')->all();
$page['payments'] = $this->q('imported_payments')->whereIn('obligation_id', $ids)->orderBy('legacy_payment_id')->get()->groupBy('obligation_id')->map(fn ($rows) => $rows->all())->all();
if ($this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
$page['decisions'] = $this->q('payment_decisions')->whereIn('obligation_id', $ids)->orderBy('sequence')->get()->groupBy('obligation_id')->map(fn ($rows) => $rows->all())->all();
}
}
$positions = $obligations->map(function ($obligation) use ($origins, $current, $page) {
$origin = $origins->get($obligation->source_id);
return $this->position($obligation, $origin ? ($current[$origin->source_key] ?? null) : null, $page);
})->all();
$proposals = $this->q('proposals')->whereIn('source_id', $sources())->orderByDesc('id')->paginate($perPage, ['*'], 'proposal_page', $proposalPage);
$events = $this->q('events')->whereIn('source_id', $sources())->orderByDesc('id')->paginate($perPage, ['*'], 'event_page', $eventPage);
$meta = fn ($p) => ['page' => $p->currentPage(), 'last_page' => $p->lastPage(), 'total' => $p->total(), 'per_page' => $p->perPage()];
return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $positions,
'proposals' => $proposals->getCollection(), 'events' => $events->getCollection(),
'pagination' => ['proposals' => $meta($proposals), 'events' => $meta($events)],
'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
});
}
public function listing(array $localSources): array
{
return $this->db->transaction(function () use ($localSources) {
+6 -5
View File
@@ -51,20 +51,21 @@ final class PaymentDecisions
throw new \DomainException('Pagamento ausente do histórico importado.');
}
public function current(object $import): array
public function current(object $import, ?array $payments = null, ?array $decisions = null): array
{
$capture = json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR);
if (Payables::canonical($this->rebuild($capture)) !== Payables::canonical($capture)
|| ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture)) {
|| ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture, $payments)) {
throw new \DomainException('Fotografia original da importação incompatível.');
}
if (! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
if ($decisions === null && ! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
return $capture;
}
$sequence = 0;
foreach ($this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) {
$links = $payments === null ? null : collect($payments)->keyBy('id');
foreach ($decisions ?? $this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) {
$next = json_decode($decision->payment_snapshot, true, 64, JSON_THROW_ON_ERROR);
$link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first();
$link = $links === null ? $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first() : $links->get($decision->imported_payment_id);
if ((int) $decision->sequence !== ++$sequence || $decision->previous_hash !== $capture['history_hash'] || ! $link || (int) $link->legacy_payment_id !== $next['id']) {
throw new \DomainException('Cadeia de decisões históricas incompatível.');
}
@@ -142,6 +142,25 @@ final class SupplierSettlements
return $allocated - $reversed;
}
/** Same allocation/reversal aggregation, restricted to the current read page. */
public static function allocatedPage(ConnectionInterface $db, array $scope, array $obligations): array
{
$result = array_fill_keys($obligations, 0);
if (! $db->getSchemaBuilder()->hasTable('ps_allocations')) {
return $result;
}
foreach (['ps_allocations' => 1, 'ps_reversals' => -1] as $table => $sign) {
if ($table !== 'ps_allocations' && ! $db->getSchemaBuilder()->hasTable($table)) {
continue;
}
foreach ($db->table($table)->where($scope)->whereIn('obligation_id', $obligations)->selectRaw('obligation_id, sum(amount_minor) AS amount')->groupBy('obligation_id')->get() as $row) {
$result[$row->obligation_id] += $sign * (int) $row->amount;
}
}
return $result;
}
private function unallocated(\stdClass $payment): int
{
return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor')
+6 -3
View File
@@ -2,19 +2,22 @@
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Obrigações a fornecedores — ensaio</title>
<style>body{font:16px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;margin:18px 0;border:1px solid #d5e0dc;border-radius:12px}h1{font-size:28px}h2{font-size:20px}.notice{padding:16px;background:#fff2cc}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}label{display:block;margin:12px 0}select,textarea,button{font:inherit;padding:10px;max-width:100%}textarea{display:block;width:90%}button{background:#125a4b;color:white;border:0;border-radius:6px;cursor:pointer}button:disabled{opacity:.5}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.scroll{overflow:auto}.amount{font-size:26px;font-weight:bold}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
<header><?php if(session()->get('payables_demo_entry')): ?><p><a href="/demo">Trocar função ou sair da demonstração</a></p><?php endif ?><a href="<?= $e($settings['return_url']??'/') ?>">← <?= $e($settings['return_label']??'Voltar à aplicação') ?></a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso reconhece o principal e, quando activado, preserva o histórico existente.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
<?php if(isset($data['history_receipt_notice'])): ?><p class="notice"><?= $e($data['history_receipt_notice']) ?></p><?php endif ?>
<?php $pageNav=function(string $key)use($data,$settings,$e){if(!isset($data['pagination'][$key]))return;$p=$data['pagination'][$key];$param=['documents'=>'page','proposals'=>'proposal_page','events'=>'event_page'][$key];echo '<nav aria-label="Paginação '.$e($key).'"><p>Página '.$e($p['page']).' de '.$e($p['last_page']).' · '.$e($p['total']).' registos</p>';foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label){$target=$p['page']+$delta;if($target<1||$target>$p['last_page'])continue;$query=array_replace($data['listing_query'],[$param=>$target]);if($key==='documents'){$query['proposal_page']=1;$query['event_page']=1;}$url=($settings['page_url']??'/supplier-payables').'?'.http_build_query($query,'','&',PHP_QUERY_RFC3986);echo '<a href="'.$e($url).'">'.$label.'</a> ';}echo '</nav>';}; ?>
<?php if(isset($data['pagination'])): ?><section><h2>Filtrar documentos</h2><form method="get" action="<?= $e($settings['page_url']??'/supplier-payables') ?>"><label>Número ou fornecedor<input name="q" maxlength="100" value="<?= $e($data['listing_query']['q']) ?>"></label><label>Reconhecimento<select name="recognition"><?php foreach(['all'=>'Todos','recognised'=>'Reconhecidos','unrecognised'=>'Por reconhecer'] as $v=>$label): ?><option value="<?= $e($v) ?>" <?= $data['listing_query']['recognition']===$v?'selected':'' ?>><?= $e($label) ?></option><?php endforeach ?></select></label><label>Documentos por página<select name="per_page"><?php foreach([25,100] as $size): ?><option value="<?= $size ?>" <?= $data['listing_query']['per_page']===$size?'selected':'' ?>><?= $size ?></option><?php endforeach ?></select></label><button>Aplicar filtros</button></form><p>Obrigações, propostas e eventos abaixo pertencem aos documentos desta página. A pesquisa e o estado são aplicados antes da paginação.</p></section><?php endif ?>
<?php if($data['payments_available']??false): ?><p><a href="/supplier-payments">Pagamentos e afectações sintéticas — sem execução bancária</a></p><?php endif ?><p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<section><h2>Facturas de origem</h2><div class="scroll"><table><thead><tr><th>Documento</th><th>Fornecedor</th><th>Base</th><th>Imposto documental</th><th>Total</th><th>Conferência / revisão</th></tr></thead><tbody>
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?></td><td><?= $e($d['supplier_name']??'Indeterminado') ?></td><td><?= isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?></td><td><?= isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?></td><td><?= isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?></td><td><?= $e($d['status']) ?><details><summary>Origem e pendências</summary><pre><?= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></td></tr><?php endforeach ?>
</tbody></table></div></section>
</tbody></table></div><?php $pageNav('documents'); ?></section>
<?php if($data['permissions']['prepare']): ?><section><h2>Preparar a obrigação</h2><form data-action="prepare"><label>Factura existente<select name="id"><?php foreach($data['documents'] as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Proposta sintética com base na factura conferida.</textarea></label><p>Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.</p><button>Preparar da origem</button></form></section><?php endif ?>
<?php $historyDocuments=array_filter($data['documents'],fn($d)=>($d['payment_count']??0)>0); if(($data['history_import_available']??false)&&$data['permissions']['prepare']&&$historyDocuments): ?><section><h2>Incorporar factura com pagamentos anteriores</h2><p>Preserva o principal integral e liga os pagamentos originais uma única vez. Só os confirmados reduzem o saldo; pendentes e rejeitados mantêm o seu estado. Não executa nem confirma pagamentos.</p><form data-action="prepare_history"><label>Factura com histórico<select name="id"><?php foreach($historyDocuments as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Passagem de responsabilidade com preservação dos pagamentos históricos.</textarea></label><button>Preparar passagem com histórico</button></form></section><?php endif ?>
<section><h2>Propostas e decisões</h2><?php if(!$data['proposals']->count()): ?><p>Ainda não há propostas.</p><?php endif ?>
<?php foreach($data['proposals'] as $p): ?><article><h3>Proposta #<?= $e($p->id) ?> · captura <?= $e($p->capture_version) ?></h3><p>Preparador #<?= $e($p->prepared_by) ?> · <?= $e($p->created_at) ?></p><p><?= $e($p->reason) ?></p><details><summary>Fotografia e pendências</summary><pre><?= $e($p->snapshot) ?></pre><pre><?= $e($p->missing) ?></pre></details>
<?php $history=json_decode($p->snapshot,true)['_history_import']??null; if($history): ?><p>Histórico proposto: confirmados <?= $money($history['confirmed_minor']) ?> · pendentes <?= $money($history['pending_minor']) ?> · rejeitados <?= $money($history['rejected_minor']) ?>. Os pendentes e rejeitados não reduzem o saldo.</p><?php endif ?>
<?php if($data['permissions']['validate']&&(!$history||($data['history_import_available']??false))): ?><form data-action="<?= $history?'validate_history':'validate' ?>"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button><?= $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?></button></form><?php endif ?></article><?php endforeach ?></section>
<?php if($data['permissions']['validate']&&(!$history||($data['history_import_available']??false))): ?><form data-action="<?= $history?'validate_history':'validate' ?>"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button><?= $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?></button></form><?php endif ?></article><?php endforeach ?><?php $pageNav('proposals'); ?></section>
<section><h2>Obrigações reconhecidas</h2><?php if(!$data['obligations']): ?><p>Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.</p><?php endif ?><div class="grid">
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><?php if($o['history_imported']??false): ?><p>Pagamentos históricos confirmados: <?= $money($o['confirmed_payments_minor']) ?></p><p>Pendentes: <?= $money($o['pending_payments_minor']) ?> · Rejeitados: <?= $money($o['rejected_payments_minor']) ?> — sem abatimento.</p><p><strong>Saldo por liquidar: <?= $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?></strong></p><?php if($data['history_decisions_available']??false): ?><p>Importação original preservada. A conferência independente dos pendentes actualiza este saldo uma única vez.</p><p><a href="/pagamentos">Conferir pagamentos pendentes →</a></p><?php else: ?><p>Importação original preservada. Conferência de pendentes não activada nesta instalação.</p><?php endif ?><?php endif ?><?php if(!($o['history_imported']??false)): ?><p>Afectações confirmadas neste percurso: <?= $money($o['allocated_payments_minor']??0) ?></p><p><strong>Saldo por liquidar: <?= $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?></strong></p><?php endif ?><?php if($o['source_conflict']??false): ?><p role="alert">Origem ou histórico divergente; saldo não presumido. O principal original permanece preservado.</p><?php endif ?><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?><?php $pageNav('events'); ?></section>
<p>Sem execução de novos pagamentos, cobrança, efeitos de stock, novo consumo orçamental ou lançamento automático. A passagem de histórico não confirma pagamentos pendentes.</p>
</main><script>
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;