commit4
This commit is contained in:
@@ -6,3 +6,6 @@ require_once __DIR__.'/src/HistoricalPayments.php';
|
||||
require_once __DIR__.'/src/HistorySchema.php';
|
||||
require_once __DIR__.'/src/PaymentDecisions.php';
|
||||
require_once __DIR__.'/src/PaymentDecisionSchema.php';
|
||||
require_once __DIR__.'/src/SupplierSettlements.php';
|
||||
require_once __DIR__.'/src/SettlementSchema.php';
|
||||
require_once __DIR__.'/src/SettlementHttp.php';
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** One payable per document identity. No session, money rounding, settlement or budget effects. */
|
||||
/** One payable per document identity. No session, money rounding or budget effects. */
|
||||
final class Payables
|
||||
{
|
||||
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
|
||||
@@ -160,14 +160,42 @@ final class Payables
|
||||
}
|
||||
}
|
||||
$confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0);
|
||||
$allocated = SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id);
|
||||
$balance = (int) $obligation->principal_minor - $confirmed - $allocated;
|
||||
if ($balance < 0) {
|
||||
$compatible = false;
|
||||
}
|
||||
|
||||
return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $obligation->principal_minor - $confirmed : null,
|
||||
return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? $balance : null,
|
||||
'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed,
|
||||
'allocated_payments_minor' => $allocated, 'payment_state' => ! $compatible ? 'indeterminate' : ($balance === 0 ? 'settled' : ($balance === (int) $obligation->principal_minor ? 'unpaid' : 'partial')),
|
||||
'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 settlements(): SupplierSettlements
|
||||
{
|
||||
return new SupplierSettlements($this->db, $this->actor, $this->person, $this->scope, $this->authorize, function (int $id): array {
|
||||
$o = $this->q('obligations')->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação fora do contexto.');
|
||||
$row = $this->q('sources')->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.');
|
||||
$source = $this->source((int) $row->local_source_id);
|
||||
if (self::identity($source) !== $row->source_key) {
|
||||
throw new \DomainException('Identidade documental alterada.');
|
||||
}
|
||||
$position = $this->position($o, $source);
|
||||
if ($position['source_conflict'] || $position['balance_minor'] === null) {
|
||||
throw new \DomainException('Origem ou histórico divergente; afectação suspensa.');
|
||||
}
|
||||
if ($position['history_imported']) {
|
||||
throw new \DomainException('Novos pagamentos sobre história importada exigem recorte de compatibilidade próprio; nenhum histórico ignorado.');
|
||||
}
|
||||
|
||||
return $position + ['supplier_key' => hash('sha256', self::canonical([$source['instance'], $source['company'], $source['supplier_id']])),
|
||||
'supplier_name' => $source['supplier_name']];
|
||||
});
|
||||
}
|
||||
|
||||
public function positionForSource(int $local): ?array
|
||||
{
|
||||
return $this->db->transaction(function () use ($local) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Shared presentation/controller; host supplies its existing organisation/permission adapter. */
|
||||
abstract class SettlementHttp extends Controller
|
||||
{
|
||||
protected const MODE = '';
|
||||
|
||||
protected const SERVICE = '';
|
||||
|
||||
protected function guard(): void
|
||||
{
|
||||
$db = DB::connection();
|
||||
$prefix = static::MODE === 'compras' ? 'compras_receipts_test_' : 'payable_sgf_';
|
||||
abort_unless(app()->environment('payable-testing') && $db->getDriverName() === 'pgsql' && $db->getConfig('host') === '127.0.0.1'
|
||||
&& preg_match('/^'.$prefix.'[a-f0-9]{12}$/D', $db->getDatabaseName())
|
||||
&& $db->selectOne('select current_database() as d')->d === $db->getDatabaseName(), 403, 'Pagamentos apenas no ensaio isolado.');
|
||||
foreach (SettlementSchema::TABLES as $table) {
|
||||
abort_unless($db->getSchemaBuilder()->hasTable($table), 403, 'Ensaio de pagamentos ainda não instalado nesta base.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function data(Request $r): array
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
return DB::transaction(function () use ($r): array {
|
||||
$s = app(static::SERVICE);
|
||||
$user = $r->user();
|
||||
$settlements = $s->engine($user)->settlements()->listing();
|
||||
$base = $s->listing($user);
|
||||
foreach ($base['obligations'] as &$o) {
|
||||
$proposal = $base['proposals']->first(fn ($p) => (int) $p->id === (int) $o['proposal_id']);
|
||||
$o['origin_snapshot'] = $proposal ? json_decode($proposal->snapshot, true, 64, JSON_THROW_ON_ERROR) : null;
|
||||
}
|
||||
unset($o);
|
||||
$permissions = [];
|
||||
foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'] as $action) {
|
||||
$permissions[$action] = $s->can($user, $action);
|
||||
}
|
||||
|
||||
return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'],
|
||||
'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'],
|
||||
'permissions' => $permissions, 'current_actor' => $user->id];
|
||||
});
|
||||
}
|
||||
|
||||
public function index(Request $r): JsonResponse
|
||||
{
|
||||
return response()->json($this->data($r))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r): Response
|
||||
{
|
||||
$data = $this->data($r);
|
||||
$settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $r->user()->name,
|
||||
'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments',
|
||||
'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables'];
|
||||
|
||||
return response(Workspace::settlements($data, $settings))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action): JsonResponse
|
||||
{
|
||||
$this->guard();
|
||||
try {
|
||||
return response()->json(app(static::SERVICE)->engine($r->user())->settlements()->command($action, $r->all(), (string) $r->header('Idempotency-Key')));
|
||||
} catch (\DomainException $e) {
|
||||
abort(409, $e->getMessage());
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
abort(422, $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Additive schema. Called explicitly in isolated QA only, never by host migrations. */
|
||||
final class SettlementSchema
|
||||
{
|
||||
public const TABLES = ['ps_payments', 'ps_payment_decisions', 'ps_proposals', 'ps_allocation_aliases', 'ps_allocation_decisions', 'ps_allocations', 'ps_commands', 'ps_events', 'ps_outbox'];
|
||||
|
||||
public static function install(): void
|
||||
{
|
||||
$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('Settlement schema requires isolated QA.');
|
||||
}
|
||||
if (array_filter(self::TABLES, fn ($t) => Schema::hasTable($t))) {
|
||||
throw new \LogicException('Settlement schema already exists; no automatic repair.');
|
||||
}
|
||||
$scope = static function (Blueprint $t): void {
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $k) {
|
||||
$t->unsignedBigInteger($k);
|
||||
}
|
||||
};
|
||||
DB::transaction(function () use ($scope): void {
|
||||
Schema::create('ps_payments', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('obligation_id')->constrained('pa_obligations');
|
||||
$t->string('fact_key', 64)->unique();
|
||||
$t->string('evidence_key', 64)->unique();
|
||||
$t->string('supplier_key', 64);
|
||||
$t->string('currency', 3);
|
||||
$t->bigInteger('amount_minor');
|
||||
$t->date('paid_on');
|
||||
$t->string('fact_reference');
|
||||
$t->string('evidence_reference');
|
||||
$t->string('payload_hash', 64);
|
||||
$t->jsonb('snapshot');
|
||||
$t->unsignedBigInteger('prepared_by');
|
||||
$t->string('prepared_person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_payment_decisions', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->unique()->constrained('ps_payments');
|
||||
$t->string('decision');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('person');
|
||||
$t->string('evidence_reference');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_proposals', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->constrained('ps_payments');
|
||||
$t->string('reference_key', 64)->unique();
|
||||
$t->string('reference');
|
||||
$t->string('plan_hash', 64);
|
||||
$t->jsonb('plan');
|
||||
$t->unsignedBigInteger('prepared_by');
|
||||
$t->string('prepared_person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['payment_id', 'plan_hash']);
|
||||
});
|
||||
Schema::create('ps_allocation_decisions', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('proposal_id')->unique()->constrained('ps_proposals');
|
||||
$t->string('decision');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_allocation_aliases', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->string('reference_key', 64)->unique();
|
||||
$t->string('reference');
|
||||
$t->foreignId('proposal_id')->constrained('ps_proposals');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_allocations', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->constrained('ps_payments');
|
||||
$t->foreignId('obligation_id')->constrained('pa_obligations');
|
||||
$t->foreignId('decision_id')->constrained('ps_allocation_decisions');
|
||||
$t->bigInteger('amount_minor');
|
||||
$t->string('source_revision', 64);
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['payment_id', 'obligation_id']);
|
||||
});
|
||||
Schema::create('ps_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('ps_events', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->constrained('ps_payments');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('person');
|
||||
$t->string('kind');
|
||||
$t->jsonb('payload');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_outbox', function (Blueprint $t) {
|
||||
$t->foreignId('event_id')->primary()->constrained('ps_events');
|
||||
$t->string('state');
|
||||
});
|
||||
foreach (self::TABLES as $table) {
|
||||
DB::unprepared("CREATE TRIGGER ps_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
|
||||
}
|
||||
foreach (['ps_payments', 'ps_allocations'] as $table) {
|
||||
DB::unprepared("ALTER TABLE {$table} ADD CONSTRAINT {$table}_positive CHECK (amount_minor > 0)");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
/** Synthetic payment facts and allocations. No session, bank execution or accounting. */
|
||||
final class SupplierSettlements
|
||||
{
|
||||
/**
|
||||
* @param array<string,int> $scope
|
||||
* @param \Closure(string):void $authorize
|
||||
* @param \Closure(int):array<string,mixed> $obligation
|
||||
*/
|
||||
public function __construct(private ConnectionInterface $db, private int $actor, private string $person,
|
||||
private array $scope, private \Closure $authorize, private \Closure $obligation) {}
|
||||
|
||||
private function q(string $table): Builder
|
||||
{
|
||||
return $this->db->table('ps_'.$table)->where($this->scope);
|
||||
}
|
||||
|
||||
private function stamp(): string
|
||||
{
|
||||
return date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/** @param array<array-key,mixed> $v */
|
||||
private function hash(array $v): string
|
||||
{
|
||||
return hash('sha256', Payables::canonical($v));
|
||||
}
|
||||
|
||||
private function key(string $ref): string
|
||||
{
|
||||
// One payment fact per organisation, even if the request arrives through another unit.
|
||||
return $this->hash([array_intersect_key($this->scope, array_flip(['tenant_id', 'company_id'])), $ref]);
|
||||
}
|
||||
|
||||
private function referenceScope(string $table, string $column, string $key): void
|
||||
{
|
||||
$row = $this->db->table('ps_'.$table)->where($column, $key)->first(array_keys($this->scope));
|
||||
if ($row) {
|
||||
foreach ($this->scope as $field => $value) {
|
||||
if ((int) $row->$field !== $value) {
|
||||
throw new \DomainException('Referência já associada noutro contexto da organização; nenhuma duplicação permitida.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function minor(mixed $value): int
|
||||
{
|
||||
if (! is_string($value) || ! preg_match('/^(0|[1-9][0-9]{0,11})(?:\.([0-9]{1,2}))?$/D', $value, $m)) {
|
||||
throw new \InvalidArgumentException('Montante decimal positivo, com até duas casas; sem arredondamento.');
|
||||
}
|
||||
$minor = (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0');
|
||||
if ($minor < 1) {
|
||||
throw new \InvalidArgumentException('O montante deve ser positivo.');
|
||||
}
|
||||
|
||||
return $minor;
|
||||
}
|
||||
|
||||
private function reference(mixed $v): string
|
||||
{
|
||||
if (! is_string($v) || ! preg_match('/^SIM-[A-Z0-9][A-Z0-9._-]{2,95}$/D', $v)) {
|
||||
throw new \InvalidArgumentException('Referência sintética estável SIM-… obrigatória.');
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
private function reason(mixed $v): string
|
||||
{
|
||||
if (! is_string($v) || mb_strlen(trim($v)) < 3 || mb_strlen($v) > 2000) {
|
||||
throw new \InvalidArgumentException('Fundamento obrigatório, entre 3 e 2000 caracteres.');
|
||||
}
|
||||
|
||||
return trim($v);
|
||||
}
|
||||
|
||||
private function id(mixed $v): int
|
||||
{
|
||||
if (! is_int($v) || $v < 1) {
|
||||
throw new \InvalidArgumentException('Identificador inteiro positivo obrigatório.');
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param list<string> $keys
|
||||
*/
|
||||
private function exact(array $data, array $keys): void
|
||||
{
|
||||
if (array_diff(array_keys($data), $keys) || array_diff($keys, array_keys($data))) {
|
||||
throw new \InvalidArgumentException('Campos ausentes ou não permitidos.');
|
||||
}
|
||||
}
|
||||
|
||||
private function payment(int $id): \stdClass
|
||||
{
|
||||
$row = $this->q('payments')->where('id', $id)->lockForUpdate()->first();
|
||||
if (! $row instanceof \stdClass) {
|
||||
throw new \DomainException('Pagamento fora do contexto.');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function independent(\stdClass $prepared): void
|
||||
{
|
||||
if ((int) $prepared->prepared_by === $this->actor || $prepared->prepared_person === $this->person) {
|
||||
throw new \DomainException('Confirmação exige outra identidade e outra pessoa sintética.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
private function event(int $payment, string $kind, array $payload): void
|
||||
{
|
||||
$id = $this->db->table('ps_events')->insertGetId($this->scope + ['payment_id' => $payment, 'actor_id' => $this->actor,
|
||||
'person' => $this->person, 'kind' => $kind, 'payload' => Payables::canonical($payload), 'created_at' => $this->stamp()]);
|
||||
$this->db->table('ps_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_bank_or_accounting']);
|
||||
}
|
||||
|
||||
/** @param array<string,int> $scope */
|
||||
public static function allocated(ConnectionInterface $db, array $scope, int $obligation): int
|
||||
{
|
||||
if (! method_exists($db, 'getSchemaBuilder')) {
|
||||
throw new \LogicException('A ligação exige consulta explícita do esquema financeiro.');
|
||||
}
|
||||
if (! $db->getSchemaBuilder()->hasTable('ps_allocations')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
|
||||
}
|
||||
|
||||
private function unallocated(\stdClass $payment): int
|
||||
{
|
||||
return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @return array<string,int|bool>
|
||||
*/
|
||||
public function command(string $action, array $data, string $key): array
|
||||
{
|
||||
$permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm'];
|
||||
if (! isset($permissions[$action]) || ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $key)) {
|
||||
throw new \InvalidArgumentException('Acção ou chave inválida.');
|
||||
}
|
||||
|
||||
return $this->db->transaction(function () use ($action, $data, $key, $permissions): array {
|
||||
($this->authorize)('payments_view');
|
||||
($this->authorize)($permissions[$action]); // Host locks organisation and revalidates actor/grants before replay too.
|
||||
$hash = $this->hash([$action, $data]);
|
||||
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
|
||||
if ($old && $old->request_hash !== $hash) {
|
||||
throw new \DomainException('Chave repetida com conteúdo diferente.');
|
||||
}
|
||||
$result = match ($action) {
|
||||
'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data),
|
||||
'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($data),
|
||||
};
|
||||
if ($old && Payables::canonical(json_decode($old->result, true, 32, JSON_THROW_ON_ERROR)) !== Payables::canonical($result)) {
|
||||
throw new \DomainException('Resultado anterior incompatível; não repetir o efeito.');
|
||||
}
|
||||
if (! $old) {
|
||||
$this->db->table('ps_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key,
|
||||
'request_hash' => $hash, 'result' => Payables::canonical($result), 'created_at' => $this->stamp()]);
|
||||
}
|
||||
|
||||
return $result + ['replayed' => $old !== null];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function preparePayment(array $d): array
|
||||
{
|
||||
$this->exact($d, ['obligation_id', 'source_revision', 'amount', 'currency', 'paid_on', 'fact_reference', 'evidence_reference', 'reason']);
|
||||
$o = ($this->obligation)($this->id($d['obligation_id']));
|
||||
if ($o['source_revision'] !== $d['source_revision'] || $d['currency'] !== 'MZN' || $o['currency'] !== $d['currency']) {
|
||||
throw new \DomainException('Versão ou moeda incompatível.');
|
||||
}
|
||||
if (! is_string($d['paid_on']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D', $d['paid_on'], $date)
|
||||
|| ! checkdate((int) $date[2], (int) $date[3], (int) $date[1]) || $d['paid_on'] > date('Y-m-d')) {
|
||||
throw new \InvalidArgumentException('Data factual de pagamento válida obrigatória, sem data futura.');
|
||||
}
|
||||
$fact = $this->reference($d['fact_reference']);
|
||||
$evidence = $this->reference($d['evidence_reference']);
|
||||
$reason = $this->reason($d['reason']);
|
||||
$snapshot = ['obligation_id' => $o['id'], 'source_revision' => $o['source_revision'], 'supplier_key' => $o['supplier_key'],
|
||||
'amount_minor' => self::minor($d['amount']), 'currency' => $d['currency'], 'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'reason' => $reason, 'synthetic_only' => true];
|
||||
$hash = $this->hash($snapshot);
|
||||
$this->referenceScope('payments', 'fact_key', $this->key($fact));
|
||||
$this->referenceScope('payments', 'evidence_key', $this->key($evidence));
|
||||
$existing = $this->q('payments')->where(fn ($q) => $q->where('fact_key', $this->key($fact))->orWhere('evidence_key', $this->key($evidence)))->get();
|
||||
if ($existing->isNotEmpty()) {
|
||||
if ($existing->count() !== 1 || $existing->first()->payload_hash !== $hash) {
|
||||
throw new \DomainException('Referência ou evidência já usada com conteúdo incompatível.');
|
||||
}
|
||||
|
||||
return ['payment_id' => (int) $existing->first()->id];
|
||||
}
|
||||
$id = $this->db->table('ps_payments')->insertGetId($this->scope + ['obligation_id' => $o['id'], 'fact_key' => $this->key($fact),
|
||||
'evidence_key' => $this->key($evidence), 'supplier_key' => $o['supplier_key'], 'currency' => $d['currency'], 'amount_minor' => $snapshot['amount_minor'],
|
||||
'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'payload_hash' => $hash, 'snapshot' => Payables::canonical($snapshot),
|
||||
'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
|
||||
$this->event($id, 'supplier_payment.proposed', ['payment_id' => $id, 'amount_minor' => $snapshot['amount_minor']]);
|
||||
|
||||
return ['payment_id' => $id];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function decidePayment(array $d): array
|
||||
{
|
||||
$this->exact($d, ['payment_id', 'decision', 'evidence_reference', 'reason']);
|
||||
if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) {
|
||||
throw new \InvalidArgumentException('Decisão inválida.');
|
||||
}
|
||||
$p = $this->payment($this->id($d['payment_id']));
|
||||
$this->independent($p);
|
||||
$o = ($this->obligation)((int) $p->obligation_id);
|
||||
$snapshot = json_decode($p->snapshot, true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($o['source_revision'] !== $snapshot['source_revision'] || $o['supplier_key'] !== $p->supplier_key) {
|
||||
throw new \DomainException('Origem do pagamento alterada; revisão necessária.');
|
||||
}
|
||||
$evidence = $this->reference($d['evidence_reference']);
|
||||
$reason = $this->reason($d['reason']);
|
||||
$old = $this->q('payment_decisions')->where('payment_id', $p->id)->first();
|
||||
if ($old) {
|
||||
if ($old->decision !== $d['decision'] || $old->evidence_reference !== $evidence || $old->reason !== $reason) {
|
||||
throw new \DomainException('Decisão final já registada; correcção exige fluxo próprio.');
|
||||
}
|
||||
|
||||
return ['payment_id' => (int) $p->id, 'decision_id' => (int) $old->id];
|
||||
}
|
||||
$id = $this->db->table('ps_payment_decisions')->insertGetId($this->scope + ['payment_id' => $p->id, 'decision' => $d['decision'],
|
||||
'evidence_reference' => $evidence, 'reason' => $reason, 'actor_id' => $this->actor, 'person' => $this->person, 'created_at' => $this->stamp()]);
|
||||
$this->event((int) $p->id, 'supplier_payment.'.$d['decision'], ['payment_id' => $p->id, 'decision_id' => $id]);
|
||||
|
||||
return ['payment_id' => (int) $p->id, 'decision_id' => $id];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function prepareAllocation(array $d): array
|
||||
{
|
||||
$this->exact($d, ['payment_id', 'reference', 'allocations', 'reason']);
|
||||
$p = $this->payment($this->id($d['payment_id']));
|
||||
if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) {
|
||||
throw new \DomainException('Pagamento ainda não confirmado.');
|
||||
}
|
||||
$reference = $this->reference($d['reference']);
|
||||
$reason = $this->reason($d['reason']);
|
||||
if (! is_array($d['allocations']) || ! array_is_list($d['allocations']) || count($d['allocations']) < 1 || count($d['allocations']) > 50) {
|
||||
throw new \InvalidArgumentException('Indique entre uma e cinquenta afectações.');
|
||||
}
|
||||
$plan = [];
|
||||
foreach ($d['allocations'] as $line) {
|
||||
if (! is_array($line)) {
|
||||
throw new \InvalidArgumentException('Linha de afectação inválida.');
|
||||
}
|
||||
$this->exact($line, ['obligation_id', 'source_revision', 'amount']);
|
||||
$id = $this->id($line['obligation_id']);
|
||||
if (isset($plan[$id])) {
|
||||
throw new \InvalidArgumentException('Obrigação repetida na mesma proposta.');
|
||||
}
|
||||
$plan[$id] = ['obligation_id' => $id, 'source_revision' => $line['source_revision'], 'amount_minor' => self::minor($line['amount'])];
|
||||
}
|
||||
ksort($plan);
|
||||
$plan = array_values($plan);
|
||||
$hash = $this->hash($plan);
|
||||
$this->referenceScope('proposals', 'reference_key', $this->key($reference));
|
||||
$this->referenceScope('allocation_aliases', 'reference_key', $this->key($reference));
|
||||
// Semantic recovery precedes balance checks: an already-applied plan remains recoverable.
|
||||
$alias = $this->q('allocation_aliases')->where('reference_key', $this->key($reference))->value('proposal_id');
|
||||
$old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0)->orWhere(fn ($q) => $q->where('payment_id', $p->id)->where('plan_hash', $hash)))->get();
|
||||
if ($old->isNotEmpty()) {
|
||||
if ($old->count() !== 1 || (int) $old->first()->payment_id !== (int) $p->id || $old->first()->plan_hash !== $hash) {
|
||||
throw new \DomainException('Referência de afectação incompatível.');
|
||||
}
|
||||
$this->checkPlan($p, $plan, false);
|
||||
$this->db->table('ps_allocation_aliases')->insertOrIgnore($this->scope + ['reference_key' => $this->key($reference), 'reference' => $reference,
|
||||
'proposal_id' => $old->first()->id, 'actor_id' => $this->actor, 'created_at' => $this->stamp()]);
|
||||
|
||||
return ['proposal_id' => (int) $old->first()->id];
|
||||
}
|
||||
$this->checkPlan($p, $plan, true);
|
||||
$id = $this->db->table('ps_proposals')->insertGetId($this->scope + ['payment_id' => $p->id, 'reference_key' => $this->key($reference),
|
||||
'reference' => $reference, 'plan_hash' => $hash, 'plan' => Payables::canonical($plan), 'prepared_by' => $this->actor,
|
||||
'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
|
||||
$this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]);
|
||||
|
||||
return ['proposal_id' => $id];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $plan */
|
||||
private function checkPlan(\stdClass $p, array $plan, bool $checkBalances): void
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($plan as $line) {
|
||||
$o = ($this->obligation)($line['obligation_id']);
|
||||
if ($o['supplier_key'] !== $p->supplier_key || $o['currency'] !== $p->currency || $o['source_revision'] !== $line['source_revision']) {
|
||||
throw new \DomainException('Fornecedor, moeda ou versão incompatível na afectação.');
|
||||
}
|
||||
if ($line['amount_minor'] > PHP_INT_MAX - $total) {
|
||||
throw new \DomainException('Montante fora dos limites.');
|
||||
}
|
||||
$total += $line['amount_minor'];
|
||||
if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']])->exists())) {
|
||||
throw new \DomainException('Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa.');
|
||||
}
|
||||
}
|
||||
if ($checkBalances && $total > $this->unallocated($p)) {
|
||||
throw new \DomainException('Valor proposto superior ao pagamento ainda por afectar.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function decideAllocation(array $d): array
|
||||
{
|
||||
$this->exact($d, ['proposal_id', 'decision', 'reason']);
|
||||
if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) {
|
||||
throw new \InvalidArgumentException('Decisão inválida.');
|
||||
}
|
||||
$proposal = $this->q('proposals')->where('id', $this->id($d['proposal_id']))->first() ?? throw new \DomainException('Proposta fora do contexto.');
|
||||
$p = $this->payment((int) $proposal->payment_id);
|
||||
$this->independent($proposal);
|
||||
if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) {
|
||||
throw new \DomainException('Pagamento não confirmado.');
|
||||
}
|
||||
$reason = $this->reason($d['reason']);
|
||||
$plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR);
|
||||
$old = $this->q('allocation_decisions')->where('proposal_id', $proposal->id)->first();
|
||||
$this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed');
|
||||
if ($old) {
|
||||
if ($old->decision !== $d['decision'] || $old->reason !== $reason) {
|
||||
throw new \DomainException('Decisão de afectação já final; exige correcção própria.');
|
||||
}
|
||||
|
||||
return ['proposal_id' => (int) $proposal->id, 'decision_id' => (int) $old->id];
|
||||
}
|
||||
$id = $this->db->table('ps_allocation_decisions')->insertGetId($this->scope + ['proposal_id' => $proposal->id, 'decision' => $d['decision'],
|
||||
'actor_id' => $this->actor, 'person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
|
||||
if ($d['decision'] === 'confirmed') {
|
||||
foreach ($plan as $line) {
|
||||
$this->db->table('ps_allocations')->insert($this->scope + $line + ['payment_id' => $p->id, 'decision_id' => $id, 'created_at' => $this->stamp()]);
|
||||
}
|
||||
}
|
||||
$this->event((int) $p->id, 'supplier_allocation.'.$d['decision'], ['proposal_id' => $proposal->id, 'decision_id' => $id, 'plan_hash' => $proposal->plan_hash]);
|
||||
|
||||
return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function listing(): array
|
||||
{
|
||||
return $this->db->transaction(function (): array {
|
||||
($this->authorize)('payments_view');
|
||||
$payments = $this->q('payments')->orderBy('id')->get()->map(function ($p): array {
|
||||
$decision = $this->q('payment_decisions')->where('payment_id', $p->id)->first();
|
||||
|
||||
return (array) $p + ['decision' => $decision, 'state' => $decision->decision ?? 'proposed',
|
||||
'unallocated_minor' => $decision?->decision === 'confirmed' ? $this->unallocated($p) : null, 'reconciliation' => 'not_implemented'];
|
||||
})->all();
|
||||
$proposals = $this->q('proposals')->orderBy('id')->get()->map(fn ($p) => (array) $p + ['decision' => $this->q('allocation_decisions')->where('proposal_id', $p->id)->first()])->all();
|
||||
|
||||
return ['synthetic_only' => true, 'payments' => $payments, 'proposals' => $proposals, 'allocations' => $this->q('allocations')->orderBy('id')->get()->all(),
|
||||
'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@ namespace Tempo\Finance\Payables;
|
||||
|
||||
final class Workspace
|
||||
{
|
||||
public static function settlements(array $data, array $settings): string
|
||||
{
|
||||
ob_start();
|
||||
require __DIR__.'/../ui/settlements.php';
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
public static function render(array $data, array $settings): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!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>
|
||||
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
|
||||
<?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>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
$e = static fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$money = static fn ($v) => $v === null ? 'Indeterminado' : number_format(intdiv((int) $v, 100), 0, '.', ' ').'.'.str_pad((string) abs((int) $v % 100), 2, '0', STR_PAD_LEFT).' MZN';
|
||||
$eligible = array_values(array_filter($data['obligations'], static fn ($o) => ! $o['source_conflict'] && ! $o['history_imported']));
|
||||
$confirmed = array_values(array_filter($data['payments'], static fn ($p) => $p['state'] === 'confirmed'));
|
||||
$options = static function () use ($eligible, $e, $money): void {
|
||||
foreach ($eligible as $o) echo '<option value="'.$e($o['id']).'" data-revision="'.$e($o['source_revision']).'">#'.$e($o['id']).' · '.$e($o['origin_snapshot']['supplier_name'] ?? 'Fornecedor').' · '.$e($money($o['balance_minor'])).'</option>';
|
||||
};
|
||||
?>
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pagamentos e afectações a fornecedores — ensaio</title><style>
|
||||
body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-width:1100px;margin:auto;padding:24px}section,header{background:white;padding:24px;border:1px solid #dbe4df;border-radius:12px;margin:18px 0}h1{font-size:28px}h2{font-size:22px}p,li{line-height:1.6}label{display:block;margin:14px 0}input,textarea,select,button{font:inherit;padding:10px;max-width:100%}input,textarea,select{display:block;width:min(100%,600px);box-sizing:border-box}button{background:#125a4b;color:white;border:0;border-radius:7px;margin:6px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}.notice{background:#fff3cf;padding:16px}a{color:#125a4b}.scroll{overflow:auto}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:12px;border-bottom:1px solid #ddd}pre{white-space:pre-wrap;overflow-wrap:anywhere}.line{border:1px solid #ddd;padding:16px;margin:12px 0}#error{color:#8d251e}article{border-top:1px solid #ddd;padding:16px 0}
|
||||
</style></head><body><main>
|
||||
<header><a href="<?= $e($settings['obligations_url']) ?>">← Obrigações a fornecedores</a> · <a href="/demo">Trocar função ou sair</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Pagamentos e afectações a fornecedores</h1><p class="notice"><strong>ENSAIO SINTÉTICO — SEM MOVIMENTAÇÃO DE DINHEIRO.</strong> Propor não confirma; confirmar um pagamento não abate automaticamente uma dívida. Só uma afectação confirmada reduz o saldo. Não há execução bancária, reconciliação, stock, orçamento adicional ou lançamento contabilístico.</p><p>As referências de pagamento e evidência são artificiais e começam por SIM-. Não introduza dados bancários ou comprovativos reais.</p></header>
|
||||
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
|
||||
<section><h2>Obrigações e saldo canónico</h2><div class="scroll"><table><thead><tr><th>Origem / fornecedor</th><th>Principal</th><th>Afectado neste percurso</th><th>Saldo</th><th>Vencimento</th></tr></thead><tbody><?php foreach ($data['obligations'] as $o): ?><tr><td>#<?= $e($o['id']) ?> · <?= $e($o['origin_snapshot']['number'] ?? '') ?><br><?= $e($o['origin_snapshot']['supplier_name'] ?? '') ?><details><summary>Versão e decisão</summary><pre><?= $e(json_encode($o, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details></td><td><?= $money($o['principal_minor']) ?></td><td><?= $money($o['allocated_payments_minor']) ?></td><td><?= $money($o['balance_minor']) ?><?= $o['source_conflict'] ? ' — origem divergente' : '' ?></td><td>Pendente<br>Atraso indeterminado<br>Dias de atraso: sem valor</td></tr><?php endforeach ?></tbody></table></div><?php if (! $data['obligations']): ?><p>Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.</p><?php endif ?></section>
|
||||
<?php if ($data['permissions']['payment_prepare'] && $eligible): ?><section><h2>1. Propor pagamento sintético</h2><form data-action="payment-prepare"><label>Obrigação de referência / fornecedor<select name="obligation_id" data-origin><?php $options(); ?></select></label><input type="hidden" name="source_revision"><label>Montante do pagamento (MZN)<input name="amount" required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><input type="hidden" name="currency" value="MZN"><label>Data factual do pagamento simulado<input type="date" name="paid_on" required></label><label>Referência estável do facto<input name="fact_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-PAY-001"></label><label>Evidência artificial de origem<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-EVID-001"></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor sem afectar dívida</button></form></section><?php endif ?>
|
||||
<section><h2>2. Pagamentos propostos e decisões</h2><?php foreach ($data['payments'] as $p): ?><article><h3>Pagamento #<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?></h3><p><?= $money($p['amount_minor']) ?> · Data simulada <?= $e($p['paid_on']) ?> · Estado: <?= $e(['proposed'=>'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?></p><p>Por afectar: <?= $money($p['unallocated_minor']) ?> · Reconciliação bancária: não implementada.</p><details><summary>Preparação e evidência preservadas</summary><pre><?= $e($p['snapshot']) ?></pre><?php if ($p['decision']): ?><pre><?= $e(json_encode($p['decision'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre><?php endif ?></details>
|
||||
<?php if ($data['permissions']['payment_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="payment-decide"><input type="hidden" name="payment_id" value="<?= $e($p['id']) ?>"><label>Decisão explícita<select name="decision"><option value="confirmed">Confirmar com evidência sintética</option><option value="rejected">Rejeitar</option></select></label><label>Evidência artificial da conferência<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-CONF-001"></label><label>Fundamento da conferência<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Registar decisão independente</button></form><?php endif ?></article><?php endforeach ?><?php if (! $data['payments']): ?><p>Sem pagamentos propostos neste contexto.</p><?php endif ?></section>
|
||||
<?php if ($data['permissions']['allocation_prepare'] && $confirmed && $eligible): ?><section><h2>3. Propor afectações</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id"><?php foreach ($confirmed as $p): ?><option value="<?= $e($p['id']) ?>">#<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?> · Por afectar <?= $money($p['unallocated_minor']) ?></option><?php endforeach ?></select></label><label>Referência estável da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-ALLOC-001"></label><div data-lines><div class="line" data-line><label>Obrigação<select data-obligation><?php $options(); ?></select></label><label>Valor a afectar (MZN)<input data-amount required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><button type="button" data-remove>Remover linha</button></div></div><button type="button" data-add>Adicionar outra obrigação</button><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><p>Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.</p><button>Submeter afectações sem abater saldo</button></form></section><?php endif ?>
|
||||
<section><h2>4. Propostas de afectação</h2><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p>
|
||||
<?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?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 ?></section>
|
||||
<p>Correcções, reversões, reconciliação e novos pagamentos sobre documentos com histórico importado ficam para âmbito próprio. Os originais e as decisões confirmadas não são editados ou apagados.</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();
|
||||
for(const selector of document.querySelectorAll('[data-origin]')){const sync=()=>selector.form.elements.source_revision.value=selector.selectedOptions[0]?.dataset.revision??'';selector.onchange=sync;sync();}
|
||||
for(const form of document.querySelectorAll('[data-action]')){
|
||||
const add=form.querySelector('[data-add]');if(add){const lines=form.querySelector('[data-lines]');const template=lines.firstElementChild.cloneNode(true);add.onclick=()=>{if(lines.children.length<50)lines.append(template.cloneNode(true));};lines.onclick=e=>{if(e.target.matches('[data-remove]')&&lines.children.length>1)e.target.closest('[data-line]').remove();};}
|
||||
form.onsubmit=async e=>{e.preventDefault();let body=Object.fromEntries(new FormData(form));for(const key of ['obligation_id','payment_id','proposal_id'])if(key in body)body[key]=Number(body[key]);if(form.dataset.action==='allocation-prepare')body.allocations=[...form.querySelectorAll('[data-line]')].map(line=>{const select=line.querySelector('[data-obligation]');return {obligation_id:Number(select.value),source_revision:select.selectedOptions[0]?.dataset.revision??'',amount:line.querySelector('[data-amount]').value};});const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true);document.querySelector('#error').textContent='';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:signature});const d=await r.json();if(!r.ok){document.querySelector('#error').textContent=d.message??'Não foi possível concluir; consulte o histórico.';return;}location.reload();}catch{document.querySelector('#error').textContent='Não foi possível confirmar o resultado. Actualize a consulta e confira o histórico antes de repetir.';}finally{buttons.forEach(b=>b.disabled=false);}};
|
||||
}
|
||||
</script></body></html>
|
||||
Reference in New Issue
Block a user