commit3
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__.'/src/Payables.php';
|
||||
require_once __DIR__.'/src/Workspace.php';
|
||||
require_once __DIR__.'/src/PayablesSchema.php';
|
||||
require_once __DIR__.'/src/HistoricalPayments.php';
|
||||
require_once __DIR__.'/src/HistorySchema.php';
|
||||
require_once __DIR__.'/src/PaymentDecisions.php';
|
||||
require_once __DIR__.'/src/PaymentDecisionSchema.php';
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<?php
|
||||
|
||||
// Explicitly called by disposable/new QA setup only, never registered in host migrations.
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$db=DB::connection();
|
||||
if($db->getDriverName()!=='pgsql'||!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$db->getDatabaseName())throw new LogicException('Payables schema refused outside isolated QA.');
|
||||
$scope=function(Blueprint $t){foreach(['tenant_id','company_id','unit_id'] as $k)$t->unsignedBigInteger($k);};
|
||||
Schema::create('pa_access',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('user_id');$t->string('person');$t->string('action');$t->unique(['tenant_id','company_id','unit_id','user_id','action']);});
|
||||
Schema::create('pa_sources',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->string('source_key',64)->unique();$t->unsignedBigInteger('local_source_id');$t->timestamp('created_at');});
|
||||
Schema::create('pa_proposals',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->string('source_revision',64);$t->integer('capture_version');$t->jsonb('snapshot');$t->jsonb('missing');$t->unsignedBigInteger('prepared_by');$t->string('prepared_person');$t->text('reason');$t->timestamp('created_at');$t->unique(['source_id','capture_version']);});
|
||||
Schema::create('pa_obligations',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->unique()->constrained('pa_sources');$t->foreignId('proposal_id')->constrained('pa_proposals');$t->string('source_revision',64);$t->bigInteger('principal_minor');$t->string('currency',3);$t->date('due_on')->nullable();$t->unsignedBigInteger('recognised_by');$t->string('recognised_person');$t->text('reason');$t->timestamp('created_at');});
|
||||
Schema::create('pa_commands',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('actor_id');$t->uuid('command_key');$t->string('request_hash',64);$t->jsonb('result');$t->timestamp('created_at');$t->unique(['tenant_id','company_id','unit_id','actor_id','command_key']);});
|
||||
Schema::create('pa_events',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->unsignedBigInteger('actor_id');$t->string('kind');$t->jsonb('payload');$t->timestamp('created_at');});
|
||||
Schema::create('pa_outbox',function(Blueprint $t){$t->foreignId('event_id')->primary()->constrained('pa_events');$t->string('state');});
|
||||
DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$");
|
||||
foreach(['pa_sources','pa_proposals','pa_obligations','pa_events','pa_outbox','pa_commands'] as $t)DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$t} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tempo\Finance\Payables\PayablesSchema;
|
||||
|
||||
$db = DB::connection();
|
||||
if ($db->getDriverName() !== 'pgsql' || ! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->selectOne('select current_database() as d')->d !== $db->getDatabaseName()) {
|
||||
throw new LogicException('Payables schema refused outside isolated QA.');
|
||||
}
|
||||
// Compras migrations now install the same shared schema before QA fixtures.
|
||||
if (Schema::hasTable('pa_sources')) {
|
||||
foreach (['pa_access', 'pa_sources', 'pa_proposals', 'pa_obligations', 'pa_commands', 'pa_events', 'pa_outbox'] as $table) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
throw new LogicException('Incomplete payables schema; no automatic repair.');
|
||||
}
|
||||
}
|
||||
$triggers = DB::selectOne("select count(*) as n from pg_trigger where tgname='pa_history_immutable' and tgrelid in ('pa_sources'::regclass,'pa_proposals'::regclass,'pa_obligations'::regclass,'pa_commands'::regclass,'pa_events'::regclass,'pa_outbox'::regclass)");
|
||||
if ((int) $triggers->n !== 6) {
|
||||
throw new LogicException('Payables append-only protection missing.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
require_once __DIR__.'/src/PayablesSchema.php';
|
||||
PayablesSchema::install();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** Imports existing facts only; never executes or confirms a payment. */
|
||||
final class HistoricalPayments
|
||||
{
|
||||
public const VERSION = 'PAYABLE-HISTORY-IMPORT-v1';
|
||||
|
||||
public function __construct(private ConnectionInterface $db, private array $scope) {}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return $this->db instanceof Connection
|
||||
&& $this->db->getSchemaBuilder()->hasTable('pa_history_imports')
|
||||
&& $this->db->getSchemaBuilder()->hasTable('pa_imported_payments');
|
||||
}
|
||||
|
||||
public static function capture(array $source, array $payments): array
|
||||
{
|
||||
if (! is_int($source['payment_count'] ?? null) || $source['payment_count'] < 1 || count($payments) !== $source['payment_count']) {
|
||||
throw new \DomainException('Histórico completo de pagamentos obrigatório; sem omissões ou importação vazia.');
|
||||
}
|
||||
$rows = [];
|
||||
$totals = ['confirmed' => 0, 'pending' => 0, 'rejected' => 0];
|
||||
foreach ($payments as $payment) {
|
||||
foreach (['id', 'company', 'document', 'amount_minor', 'created_by', 'registration_audit_id'] as $key) {
|
||||
if (! is_int($payment[$key] ?? null) || $payment[$key] < 1) {
|
||||
throw new \DomainException('Referência ou montante de pagamento inválido: '.$key);
|
||||
}
|
||||
}
|
||||
$id = $payment['id'];
|
||||
$status = $payment['status'] ?? '';
|
||||
if (isset($rows[$id]) || $payment['company'] !== $source['company'] || $payment['document'] !== $source['document']
|
||||
|| ($payment['currency'] ?? null) !== ($source['currency'] ?? null) || ! isset($totals[$status])) {
|
||||
throw new \DomainException('Pagamento duplicado ou incompatível com empresa, factura, moeda ou estado.');
|
||||
}
|
||||
$date = $payment['paid_on'] ?? '';
|
||||
if (! is_string($date) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)
|
||||
|| ! checkdate((int) substr($date, 5, 2), (int) substr($date, 8, 2), (int) substr($date, 0, 4))) {
|
||||
throw new \DomainException('Data original do pagamento inválida.');
|
||||
}
|
||||
if ($status !== 'pending' && (! is_int($payment['reviewed_by'] ?? null) || $payment['reviewed_by'] < 1
|
||||
|| $payment['reviewed_by'] === $payment['created_by'] || ! is_int($payment['decision_audit_id'] ?? null)
|
||||
|| $payment['decision_audit_id'] < 1 || empty($payment['reviewed_at']))) {
|
||||
throw new \DomainException('Confirmação ou rejeição sem decisão independente e auditada.');
|
||||
}
|
||||
if ($status === 'pending' && (($payment['reviewed_by'] ?? null) !== null || ($payment['decision_audit_id'] ?? null) !== null || ($payment['reviewed_at'] ?? null) !== null)) {
|
||||
throw new \DomainException('Pagamento pendente com decisão incompatível.');
|
||||
}
|
||||
if ($totals[$status] > PHP_INT_MAX - $payment['amount_minor']) {
|
||||
throw new \DomainException('Total histórico fora dos limites suportados.');
|
||||
}
|
||||
$totals[$status] += $payment['amount_minor'];
|
||||
$rows[$id] = ['id' => $id, 'company' => $payment['company'], 'document' => $payment['document'],
|
||||
'amount_minor' => $payment['amount_minor'], 'currency' => $payment['currency'], 'status' => $status,
|
||||
'created_by' => $payment['created_by'], 'paid_on' => $date, 'registration_audit_id' => $payment['registration_audit_id'],
|
||||
'reviewed_by' => $payment['reviewed_by'] ?? null, 'reviewed_at' => $payment['reviewed_at'] ?? null,
|
||||
'decision_audit_id' => $payment['decision_audit_id'] ?? null,
|
||||
'payment_key' => hash('sha256', Payables::canonical([$source['instance'], $source['company'], $id]))];
|
||||
}
|
||||
if (! is_int($source['total_minor'] ?? null) || $totals['confirmed'] > $source['total_minor']) {
|
||||
throw new \DomainException('Pagamentos confirmados superiores ao principal documental.');
|
||||
}
|
||||
ksort($rows);
|
||||
$capture = ['version' => self::VERSION, 'source_key' => Payables::identity($source), 'payments' => array_values($rows),
|
||||
'confirmed_minor' => $totals['confirmed'], 'pending_minor' => $totals['pending'], 'rejected_minor' => $totals['rejected']];
|
||||
|
||||
return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))];
|
||||
}
|
||||
|
||||
public function imported(int $obligation): ?object
|
||||
{
|
||||
return $this->available() ? $this->db->table('pa_history_imports')->where($this->scope)->where('obligation_id', $obligation)->first() : null;
|
||||
}
|
||||
|
||||
public function record(int $obligation, int $source, int $actor, array $capture): void
|
||||
{
|
||||
if (! $this->available() || $this->db->transactionLevel() === 0) {
|
||||
throw new \LogicException('Esquema e transacção de importação obrigatórios.');
|
||||
}
|
||||
$this->db->table('pa_history_imports')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source,
|
||||
'source_key' => $capture['source_key'], 'history_hash' => $capture['history_hash'],
|
||||
'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor'],
|
||||
'snapshot' => Payables::canonical($capture), 'imported_by' => $actor, 'created_at' => date('Y-m-d H:i:s')]);
|
||||
foreach ($capture['payments'] as $payment) {
|
||||
$this->db->table('pa_imported_payments')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source,
|
||||
'payment_key' => $payment['payment_key'], 'legacy_payment_id' => $payment['id'], 'amount_minor' => $payment['amount_minor'],
|
||||
'status' => $payment['status'], 'snapshot' => Payables::canonical($payment), 'created_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
}
|
||||
|
||||
public function matches(object $import, array $capture): bool
|
||||
{
|
||||
$rows = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get();
|
||||
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;
|
||||
}
|
||||
foreach ($rows as $index => $row) {
|
||||
$payment = $capture['payments'][$index];
|
||||
if ($row->payment_key !== $payment['payment_key'] || (int) $row->amount_minor !== $payment['amount_minor']
|
||||
|| $row->status !== $payment['status'] || Payables::canonical(json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($payment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) $import->confirmed_minor === $capture['confirmed_minor'] && (int) $import->pending_minor === $capture['pending_minor'] && (int) $import->rejected_minor === $capture['rejected_minor'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class HistorySchema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_obligations')
|
||||
|| Schema::hasTable('pa_history_imports') || Schema::hasTable('pa_imported_payments')) {
|
||||
throw new \LogicException('Esquema anterior obrigatório; não adoptar nem reparar tabelas históricas implicitamente.');
|
||||
}
|
||||
$scope = function (Blueprint $table) {
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $key) {
|
||||
$table->unsignedBigInteger($key);
|
||||
}
|
||||
};
|
||||
Schema::create('pa_history_imports', function (Blueprint $table) use ($scope) {
|
||||
$table->id();
|
||||
$scope($table);
|
||||
$table->foreignId('obligation_id')->unique()->constrained('pa_obligations');
|
||||
$table->foreignId('source_id')->unique()->constrained('pa_sources');
|
||||
$table->string('source_key', 64);
|
||||
$table->string('history_hash', 64);
|
||||
$table->bigInteger('confirmed_minor');
|
||||
$table->bigInteger('pending_minor');
|
||||
$table->bigInteger('rejected_minor');
|
||||
$table->jsonb('snapshot');
|
||||
$table->unsignedBigInteger('imported_by');
|
||||
$table->timestamp('created_at');
|
||||
});
|
||||
Schema::create('pa_imported_payments', function (Blueprint $table) use ($scope) {
|
||||
$table->id();
|
||||
$scope($table);
|
||||
$table->foreignId('obligation_id')->constrained('pa_obligations');
|
||||
$table->foreignId('source_id')->constrained('pa_sources');
|
||||
$table->string('payment_key', 64)->unique();
|
||||
$table->unsignedBigInteger('legacy_payment_id');
|
||||
$table->bigInteger('amount_minor');
|
||||
$table->string('status');
|
||||
$table->jsonb('snapshot');
|
||||
$table->timestamp('created_at');
|
||||
$table->unique(['source_id', 'legacy_payment_id']);
|
||||
});
|
||||
foreach (['pa_history_imports', 'pa_imported_payments'] as $table) {
|
||||
if ($driver === 'pgsql') {
|
||||
DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
|
||||
} else {
|
||||
foreach (['UPDATE', 'DELETE'] as $operation) {
|
||||
$trigger = $table.'_immutable_'.strtolower($operation);
|
||||
DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null) {}
|
||||
|
||||
public static function canonical(array $data): string
|
||||
{
|
||||
@@ -21,7 +21,7 @@ final class Payables
|
||||
ksort($v);
|
||||
}
|
||||
|
||||
return array_map($sort, $v);
|
||||
return array_map($sort, $v);
|
||||
};
|
||||
|
||||
return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
@@ -65,7 +65,7 @@ return array_map($sort, $v);
|
||||
$m[] = 'Linhas conferidas em falta.';
|
||||
}
|
||||
if (! array_key_exists('payment_count', $s) || $s['payment_count'] !== 0) {
|
||||
$m[] = 'Histórico de pagamentos existente ou desconhecido; migração fora do âmbito.';
|
||||
$m[] = 'Histórico de pagamentos existente ou desconhecido; exige passagem de responsabilidade própria.';
|
||||
}
|
||||
|
||||
return $m;
|
||||
@@ -107,18 +107,116 @@ return array_map($sort, $v);
|
||||
throw new \DomainException('Origem já associada a outro contexto.');
|
||||
}
|
||||
}
|
||||
if ((int) $row->local_source_id !== (int) $s['local_id']) {
|
||||
throw new \DomainException('Origem canónica já ligada a outro documento local.');
|
||||
}
|
||||
$this->singleResponsibility($row, (int) $s['local_id']);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function singleResponsibility(object $source, int $local): void
|
||||
{
|
||||
$otherSources = $this->q('sources')->where('local_source_id', $local)->where('id', '<>', $source->id)->pluck('id')->all();
|
||||
if ($this->q('obligations')->whereIn('source_id', $otherSources)->exists()) {
|
||||
throw new \DomainException('Documento local já incorporado com outra identidade; reconciliar sem segunda obrigação.');
|
||||
}
|
||||
}
|
||||
|
||||
private function history(array $source): array
|
||||
{
|
||||
if ($this->loadHistory === null || ! (new HistoricalPayments($this->db, $this->scope))->available()) {
|
||||
throw new \DomainException('Passagem de histórico não activada nesta instalação.');
|
||||
}
|
||||
|
||||
return HistoricalPayments::capture($source, ($this->loadHistory)($source['local_id']));
|
||||
}
|
||||
|
||||
private function eligibility(array $source, bool $historical): array
|
||||
{
|
||||
if ($historical) {
|
||||
$source['payment_count'] = 0;
|
||||
}
|
||||
|
||||
return self::missing($source);
|
||||
}
|
||||
|
||||
private function position(object $obligation, ?array $source): array
|
||||
{
|
||||
$store = new HistoricalPayments($this->db, $this->scope);
|
||||
$import = $store->imported((int) $obligation->id);
|
||||
$current = null;
|
||||
$compatible = false;
|
||||
if ($source) {
|
||||
$compatible = ($source['revision'] ?? self::revision($source)) === $obligation->source_revision
|
||||
&& $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));
|
||||
} catch (\DomainException $e) {
|
||||
$compatible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
$confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0);
|
||||
|
||||
return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $obligation->principal_minor - $confirmed : null,
|
||||
'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed,
|
||||
'pending_payments_minor' => $current['pending_minor'] ?? ($import ? (int) $import->pending_minor : 0), 'rejected_payments_minor' => $current['rejected_minor'] ?? ($import ? (int) $import->rejected_minor : 0),
|
||||
'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'],
|
||||
$compatible ? [] : ['Origem ou histórico divergente; saldo não presumido.'])];
|
||||
}
|
||||
|
||||
public function positionForSource(int $local): ?array
|
||||
{
|
||||
return $this->db->transaction(function () use ($local) {
|
||||
($this->authorize)('view');
|
||||
$source = $this->source($local);
|
||||
$row = $this->q('sources')->where('source_key', self::identity($source))->first();
|
||||
if (! $row) {
|
||||
$ids = $this->q('sources')->where('local_source_id', $local)->pluck('id')->all();
|
||||
if ($this->q('obligations')->whereIn('source_id', $ids)->exists()) {
|
||||
throw new \DomainException('Identidade da instalação alterada; reconciliar origem antes de calcular saldo.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
$this->singleResponsibility($row, $local);
|
||||
$obligation = $this->q('obligations')->where('source_id', $row->id)->first();
|
||||
|
||||
return $obligation ? $this->position($obligation, $source) : null;
|
||||
});
|
||||
}
|
||||
|
||||
public function decideImportedPayment(int $local, int $payment, \Closure $writeOriginal): void
|
||||
{
|
||||
$this->db->transaction(function () use ($local, $payment, $writeOriginal) {
|
||||
($this->authorize)('validate');
|
||||
$source = $this->source($local);
|
||||
$row = $this->locked($source);
|
||||
$obligation = $this->q('obligations')->where('source_id', $row->id)->first();
|
||||
$import = $obligation ? (new HistoricalPayments($this->db, $this->scope))->imported((int) $obligation->id) : null;
|
||||
if (! $import || $this->position($obligation, $source)['source_conflict']) {
|
||||
throw new \DomainException('Obrigação histórica ausente ou divergente.');
|
||||
}
|
||||
$writeOriginal();
|
||||
$after = $this->history($this->source($local));
|
||||
(new PaymentDecisions($this->db, $this->scope))->record($import, $after, $payment, $this->actor);
|
||||
$this->event('payable.imported_payment_decided', (int) $row->id, ['obligation_id' => $obligation->id, 'payment_id' => $payment, 'history_hash' => $after['history_hash']]);
|
||||
});
|
||||
}
|
||||
|
||||
public function command(string $action, int $id, string $reason, string $key): array
|
||||
{
|
||||
if (! in_array($action, ['prepare', 'validate'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
|
||||
if (! in_array($action, ['prepare', 'validate', 'prepare_history', 'validate_history'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
|
||||
throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.');
|
||||
}
|
||||
|
||||
return $this->db->transaction(function () use ($action, $id, $reason, $key) {
|
||||
($this->authorize)($action);
|
||||
$historical = str_ends_with($action, '_history');
|
||||
$operation = $historical ? substr($action, 0, -8) : $action;
|
||||
($this->authorize)($operation);
|
||||
$hash = hash('sha256', self::canonical([$action, $id, $reason]));
|
||||
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
|
||||
if ($old) {
|
||||
@@ -130,25 +228,32 @@ return array_map($sort, $v);
|
||||
$local = $result['local_source_id'];
|
||||
$s = $this->source($local);
|
||||
$row = $this->locked($s);
|
||||
$capture = $historical ? $this->history($s) : null;
|
||||
$material = self::revision($s);
|
||||
if (($result['source_revision'] ?? null) !== $material || self::missing($s) !== ($result['missing'] ?? [])) {
|
||||
if (($result['source_revision'] ?? null) !== $material || $this->eligibility($s, $historical) !== ($result['missing'] ?? [])
|
||||
|| ($result['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
|
||||
return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.');
|
||||
}
|
||||
|
||||
return $result + ['replayed' => true];
|
||||
}
|
||||
if ($action === 'prepare') {
|
||||
if ($operation === 'prepare') {
|
||||
$s = $this->source($id);
|
||||
$row = $this->locked($s);
|
||||
$capture = $historical ? $this->history($s) : null;
|
||||
$revision = self::revision($s);
|
||||
$missing = self::missing($s);
|
||||
$missing = $this->eligibility($s, $historical);
|
||||
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
|
||||
if ($ob) {
|
||||
$result = $ob->source_revision === $revision && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento alterado ou pagamentos associados; nenhuma dívida adicional.');
|
||||
$position = $this->position($ob, $s);
|
||||
$result = ! $position['source_conflict'] && $position['history_imported'] === $historical && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento/histórico alterado ou obrigação já incorporada por outro percurso; nenhuma dívida adicional.');
|
||||
} else {
|
||||
$candidate = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
|
||||
if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing) {
|
||||
$pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($s), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
|
||||
$oldCapture = $candidate ? (json_decode($candidate->snapshot, true)['_history_import'] ?? null) : null;
|
||||
if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing
|
||||
|| ($oldCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
|
||||
$snapshot = $s + ($capture ? ['_history_import' => $capture] : []);
|
||||
$pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($snapshot), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
|
||||
$this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]);
|
||||
} else {
|
||||
$pid = $candidate->id;
|
||||
@@ -165,21 +270,37 @@ return array_map($sort, $v);
|
||||
}
|
||||
$row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first();
|
||||
$local = (int) $row->local_source_id;
|
||||
$this->singleResponsibility($row, $local);
|
||||
$s = $this->source($local);
|
||||
$capture = $historical ? $this->history($s) : null;
|
||||
$preparedCapture = json_decode($p->snapshot, true)['_history_import'] ?? null;
|
||||
if (($preparedCapture !== null) !== $historical) {
|
||||
throw new \DomainException('Usar o percurso de validação correspondente à proposta.');
|
||||
}
|
||||
$revision = self::revision($s);
|
||||
$missing = self::missing($s);
|
||||
$missing = $this->eligibility($s, $historical);
|
||||
$latest = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
|
||||
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
|
||||
if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []) {
|
||||
if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []
|
||||
|| ($preparedCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
|
||||
$result = $this->conflict($row, $local, 'Revisão/decisão/pagamentos incompatíveis ou informação insuficiente.');
|
||||
} elseif ($ob) {
|
||||
$result = ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
|
||||
$position = $this->position($ob, $s);
|
||||
$result = ! $position['source_conflict'] && $position['history_imported'] === $historical ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $local, 'Responsabilidade existente incompatível; nenhuma segunda obrigação.');
|
||||
} else {
|
||||
$oid = $this->db->table('pa_obligations')->insertGetId($this->scope + ['source_id' => $row->id, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'currency' => 'MZN', 'due_on' => null, 'recognised_by' => $this->actor, 'recognised_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
|
||||
if ($capture) {
|
||||
(new HistoricalPayments($this->db, $this->scope))->record((int) $oid, (int) $row->id, $this->actor, $capture);
|
||||
$this->event('payable.legacy_history_imported', $row->id, ['obligation_id' => $oid, 'history_hash' => $capture['history_hash'],
|
||||
'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor']]);
|
||||
}
|
||||
$this->event('payable.recognised', $row->id, ['obligation_id' => $oid, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'due_on' => null]);
|
||||
$result = ['state' => 'recognised', 'obligation_id' => $oid, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
|
||||
}
|
||||
}
|
||||
if ($capture && ($result['state'] ?? '') !== 'conflict') {
|
||||
$result['history_hash'] = $capture['history_hash'];
|
||||
}
|
||||
$this->db->table('pa_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => self::canonical($result), 'created_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return $result + ['replayed' => false];
|
||||
@@ -209,10 +330,8 @@ return array_map($sort, $v);
|
||||
$obs = $this->q('obligations')->orderBy('id')->get()->map(function ($o) use ($current) {
|
||||
$origin = $this->q('sources')->where('id', $o->source_id)->first();
|
||||
$doc = $current[$origin->source_key] ?? null;
|
||||
// Additional UI-derived revision/missing fields are not part of the material source hash.
|
||||
$compatible = $doc && $doc['revision'] === $o->source_revision && $doc['missing'] === [];
|
||||
|
||||
return (array) $o + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $o->principal_minor : null, 'source_conflict' => ! $compatible, 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], $compatible ? [] : ['Origem/eligibilidade divergente; saldo não presumido.'])];
|
||||
return $this->position($o, $doc);
|
||||
})->all();
|
||||
|
||||
return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $obs, 'proposals' => $this->q('proposals')->orderBy('id')->get(), 'events' => $this->q('events')->orderBy('id')->get(), 'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Shared additive schema; installation never grants access or imports documents. */
|
||||
final class PayablesSchema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
if (! in_array($driver, ['pgsql', 'sqlite'], true)) {
|
||||
throw new \LogicException('Unsupported payables database.');
|
||||
}
|
||||
$tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_outbox', 'pa_commands'];
|
||||
$all = [...$tables, 'pa_access'];
|
||||
$existing = array_filter($all, fn ($table) => Schema::hasTable($table));
|
||||
if ($existing) {
|
||||
throw new \LogicException('Payables tables already exist; explicit schema reconciliation required.');
|
||||
}
|
||||
$scope = function (Blueprint $t) {
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $k) {
|
||||
$t->unsignedBigInteger($k);
|
||||
}
|
||||
};
|
||||
Schema::create('pa_access', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->unsignedBigInteger('user_id');
|
||||
$t->string('person');
|
||||
$t->string('action');
|
||||
$t->unique(['tenant_id', 'company_id', 'unit_id', 'user_id', 'action']);
|
||||
});
|
||||
Schema::create('pa_sources', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->string('source_key', 64)->unique();
|
||||
$t->unsignedBigInteger('local_source_id');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('pa_proposals', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('source_id')->constrained('pa_sources');
|
||||
$t->string('source_revision', 64);
|
||||
$t->integer('capture_version');
|
||||
$t->jsonb('snapshot');
|
||||
$t->jsonb('missing');
|
||||
$t->unsignedBigInteger('prepared_by');
|
||||
$t->string('prepared_person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['source_id', 'capture_version']);
|
||||
});
|
||||
Schema::create('pa_obligations', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('source_id')->unique()->constrained('pa_sources');
|
||||
$t->foreignId('proposal_id')->constrained('pa_proposals');
|
||||
$t->string('source_revision', 64);
|
||||
$t->bigInteger('principal_minor');
|
||||
$t->string('currency', 3);
|
||||
$t->date('due_on')->nullable();
|
||||
$t->unsignedBigInteger('recognised_by');
|
||||
$t->string('recognised_person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('pa_commands', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->uuid('command_key');
|
||||
$t->string('request_hash', 64);
|
||||
$t->jsonb('result');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']);
|
||||
});
|
||||
Schema::create('pa_events', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('source_id')->constrained('pa_sources');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('kind');
|
||||
$t->jsonb('payload');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('pa_outbox', function (Blueprint $t) {
|
||||
$t->foreignId('event_id')->primary()->constrained('pa_events');
|
||||
$t->string('state');
|
||||
});
|
||||
if ($driver === 'pgsql') {
|
||||
DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$");
|
||||
foreach ($tables as $table) {
|
||||
DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
|
||||
}
|
||||
} else {
|
||||
foreach ($tables as $table) {
|
||||
foreach (['UPDATE', 'DELETE'] as $operation) {
|
||||
$trigger = $table.'_immutable_'.strtolower($operation);
|
||||
DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class PaymentDecisionSchema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_imported_payments') || Schema::hasTable('pa_payment_decisions')) {
|
||||
throw new \LogicException('Histórico anterior obrigatório; não adoptar tabela de decisões existente.');
|
||||
}
|
||||
Schema::create('pa_payment_decisions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $key) {
|
||||
$table->unsignedBigInteger($key);
|
||||
}
|
||||
$table->foreignId('obligation_id')->constrained('pa_obligations');
|
||||
$table->foreignId('imported_payment_id')->unique()->constrained('pa_imported_payments');
|
||||
$table->unsignedInteger('sequence');
|
||||
$table->string('previous_hash', 64);
|
||||
$table->string('history_hash', 64);
|
||||
$table->jsonb('payment_snapshot');
|
||||
$table->unsignedBigInteger('actor_id');
|
||||
$table->timestamp('created_at');
|
||||
$table->unique(['obligation_id', 'sequence']);
|
||||
});
|
||||
if ($driver === 'pgsql') {
|
||||
DB::unprepared('CREATE TRIGGER pa_decision_immutable BEFORE UPDATE OR DELETE ON pa_payment_decisions FOR EACH ROW EXECUTE FUNCTION pa_append_only()');
|
||||
} else {
|
||||
foreach (['UPDATE', 'DELETE'] as $operation) {
|
||||
DB::unprepared('CREATE TRIGGER pa_decision_'.strtolower($operation)." BEFORE {$operation} ON pa_payment_decisions BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** Immutable final decisions on payments that were pending when imported. */
|
||||
final class PaymentDecisions
|
||||
{
|
||||
public function __construct(private ConnectionInterface $db, private array $scope) {}
|
||||
|
||||
private function rebuild(array $capture): array
|
||||
{
|
||||
unset($capture['history_hash']);
|
||||
foreach (['confirmed', 'pending', 'rejected'] as $status) {
|
||||
$capture[$status.'_minor'] = 0;
|
||||
}
|
||||
foreach ($capture['payments'] as $payment) {
|
||||
$key = $payment['status'].'_minor';
|
||||
if (! array_key_exists($key, $capture) || $payment['amount_minor'] < 1 || $capture[$key] > PHP_INT_MAX - $payment['amount_minor']) {
|
||||
throw new \DomainException('Totais de decisões incompatíveis.');
|
||||
}
|
||||
$capture[$key] += $payment['amount_minor'];
|
||||
}
|
||||
|
||||
return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))];
|
||||
}
|
||||
|
||||
private function transition(array $capture, array $next, int $actor): array
|
||||
{
|
||||
foreach ($capture['payments'] as $index => $payment) {
|
||||
if ($payment['id'] !== ($next['id'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$unchanged = ['id', 'company', 'document', 'amount_minor', 'currency', 'created_by', 'paid_on', 'registration_audit_id', 'payment_key'];
|
||||
if ($payment['status'] !== 'pending' || ! in_array($next['status'] ?? null, ['confirmed', 'rejected'], true)
|
||||
|| ($next['reviewed_by'] ?? null) !== $actor || $actor === $payment['created_by']
|
||||
|| ! is_int($next['decision_audit_id'] ?? null) || $next['decision_audit_id'] < 1 || empty($next['reviewed_at'])
|
||||
|| count($payment) !== count($next) || array_diff_key($payment, $next) !== []) {
|
||||
throw new \DomainException('Decisão histórica inválida ou já final.');
|
||||
}
|
||||
foreach ($unchanged as $key) {
|
||||
if ($payment[$key] !== $next[$key]) {
|
||||
throw new \DomainException('A decisão não pode reescrever o pagamento original.');
|
||||
}
|
||||
}
|
||||
$capture['payments'][$index] = $next;
|
||||
|
||||
return $this->rebuild($capture);
|
||||
}
|
||||
throw new \DomainException('Pagamento ausente do histórico importado.');
|
||||
}
|
||||
|
||||
public function current(object $import): 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)) {
|
||||
throw new \DomainException('Fotografia original da importação incompatível.');
|
||||
}
|
||||
if (! $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) {
|
||||
$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();
|
||||
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.');
|
||||
}
|
||||
$capture = $this->transition($capture, $next, (int) $decision->actor_id);
|
||||
if ($decision->history_hash !== $capture['history_hash']) {
|
||||
throw new \DomainException('Decisão histórica divergente.');
|
||||
}
|
||||
}
|
||||
|
||||
return $capture;
|
||||
}
|
||||
|
||||
public function record(object $import, array $after, int $paymentId, int $actor): void
|
||||
{
|
||||
if ($this->db->transactionLevel() === 0 || ! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
|
||||
throw new \LogicException('Esquema e transacção obrigatórios para decidir histórico.');
|
||||
}
|
||||
$before = $this->current($import);
|
||||
$next = null;
|
||||
foreach ($after['payments'] as $payment) {
|
||||
if ($payment['id'] === $paymentId) {
|
||||
$next = $payment;
|
||||
}
|
||||
}
|
||||
if (! $next) {
|
||||
throw new \DomainException('Pagamento ausente da nova fotografia.');
|
||||
}
|
||||
$expected = $this->transition($before, $next, $actor);
|
||||
if (Payables::canonical($expected) !== Payables::canonical($after)) {
|
||||
throw new \DomainException('Alterações fora da decisão pendente; operação recusada.');
|
||||
}
|
||||
$link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('legacy_payment_id', $paymentId)->first();
|
||||
if (! $link || $link->status !== 'pending') {
|
||||
throw new \DomainException('Apenas pendentes originalmente importados podem ser decididos.');
|
||||
}
|
||||
$sequence = 1 + (int) $this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->max('sequence');
|
||||
$this->db->table('pa_payment_decisions')->insert($this->scope + ['obligation_id' => $import->obligation_id, 'imported_payment_id' => $link->id,
|
||||
'sequence' => $sequence, 'previous_hash' => $before['history_hash'], 'history_hash' => $after['history_hash'],
|
||||
'payment_snapshot' => Payables::canonical($next), 'actor_id' => $actor, 'created_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
<?php $e=fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $money=fn($v,$currency='MZN')=>number_format(intdiv((int)$v,100),0,'.',' ').'.'.str_pad((string)abs((int)$v%100),2,'0',STR_PAD_LEFT).' '.$e($currency); ?>
|
||||
<!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><a href="/">← 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 só reconhece o principal.</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>
|
||||
<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>
|
||||
<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>
|
||||
<?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 if($data['permissions']['validate']): ?><form data-action="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>Validar revisão exacta e reconhecer</button></form><?php endif ?></article><?php endforeach ?></section>
|
||||
<?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>
|
||||
<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><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>
|
||||
<?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['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>
|
||||
<p>Sem pagamento, cobrança, afectação, stock, novo consumo orçamental ou lançamento automático. A consulta não marca o documento como pago.</p>
|
||||
<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) ?>;
|
||||
document.querySelector('#reload').onclick=()=>location.reload();
|
||||
|
||||
Reference in New Issue
Block a user