commit4
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** One payable per document identity. No session, money rounding, settlement or budget effects. */
|
||||
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) {}
|
||||
|
||||
public static function canonical(array $data): string
|
||||
{
|
||||
$sort = function ($v) use (&$sort) {
|
||||
if (! is_array($v)) {
|
||||
return $v;
|
||||
} if (! array_is_list($v)) {
|
||||
ksort($v);
|
||||
}
|
||||
|
||||
return array_map($sort, $v);
|
||||
};
|
||||
|
||||
return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public static function identity(array $s): string
|
||||
{
|
||||
return hash('sha256', self::canonical(array_intersect_key($s, array_flip(['instance', 'company', 'document']))));
|
||||
}
|
||||
|
||||
public static function revision(array $s): string
|
||||
{
|
||||
unset($s['payment_count'],$s['local_id']);
|
||||
|
||||
return hash('sha256', self::canonical($s));
|
||||
}
|
||||
|
||||
public static function missing(array $s): array
|
||||
{
|
||||
$m = [];
|
||||
foreach (['instance', 'company', 'document', 'number', 'supplier_id', 'supplier_name', 'order_id', 'validated_by', 'validated_at', 'validation_audit_id'] as $k) {
|
||||
if (empty($s[$k])) {
|
||||
$m[] = 'Origem/decisão em falta: '.$k;
|
||||
}
|
||||
}
|
||||
if (($s['status'] ?? null) !== 'validated') {
|
||||
$m[] = 'Factura ainda não conferida.';
|
||||
}
|
||||
if (($s['currency'] ?? null) !== 'MZN') {
|
||||
$m[] = 'Moeda ausente ou incompatível; apenas MZN neste recorte.';
|
||||
}
|
||||
foreach (['subtotal_minor', 'tax_minor', 'total_minor'] as $k) {
|
||||
if (! isset($s[$k]) || ! is_int($s[$k]) || $s[$k] < 0) {
|
||||
$m[] = 'Montante sustentado em falta: '.$k;
|
||||
}
|
||||
}
|
||||
if (isset($s['subtotal_minor'],$s['tax_minor'],$s['total_minor']) && $s['total_minor'] !== $s['subtotal_minor'] + $s['tax_minor']) {
|
||||
$m[] = 'Componentes documentais incompatíveis; sem recálculo.';
|
||||
}
|
||||
if (empty($s['lines'])) {
|
||||
$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.';
|
||||
}
|
||||
|
||||
return $m;
|
||||
}
|
||||
|
||||
private function q(string $table)
|
||||
{
|
||||
return $this->db->table('pa_'.$table)->where($this->scope);
|
||||
}
|
||||
|
||||
private function event(string $kind, int $source, array $data): void
|
||||
{
|
||||
$id = $this->db->table('pa_events')->insertGetId($this->scope + ['source_id' => $source, 'actor_id' => $this->actor, 'kind' => $kind, 'payload' => self::canonical($data), 'created_at' => date('Y-m-d H:i:s')]);
|
||||
$this->db->table('pa_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_settlement_or_accounting']);
|
||||
}
|
||||
|
||||
private function source(int $local): array
|
||||
{
|
||||
$s = ($this->loadSource)($local);
|
||||
foreach (['instance', 'company', 'document'] as $key) {
|
||||
if (empty($s[$key])) {
|
||||
throw new \DomainException('Identidade de origem insuficiente.');
|
||||
}
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
private function locked(array $s): object
|
||||
{
|
||||
$key = self::identity($s);
|
||||
$this->db->table('pa_sources')->insertOrIgnore($this->scope + ['source_key' => $key, 'local_source_id' => $s['local_id'], 'created_at' => date('Y-m-d H:i:s')]);
|
||||
$row = $this->db->table('pa_sources')->where('source_key', $key)->lockForUpdate()->first();
|
||||
if (! $row) {
|
||||
throw new \DomainException('Identidade documental comercial já associada; revisão necessária.');
|
||||
}
|
||||
foreach ($this->scope as $k => $v) {
|
||||
if ((int) $row->$k !== $v) {
|
||||
throw new \DomainException('Origem já associada a outro contexto.');
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.');
|
||||
}
|
||||
|
||||
return $this->db->transaction(function () use ($action, $id, $reason, $key) {
|
||||
($this->authorize)($action);
|
||||
$hash = hash('sha256', self::canonical([$action, $id, $reason]));
|
||||
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
|
||||
if ($old) {
|
||||
if ($old->request_hash !== $hash) {
|
||||
throw new \DomainException('Chave usada com conteúdo diferente.');
|
||||
}
|
||||
// Even replay rechecks the current source/revision/payment eligibility.
|
||||
$result = json_decode($old->result, true, 32, JSON_THROW_ON_ERROR);
|
||||
$local = $result['local_source_id'];
|
||||
$s = $this->source($local);
|
||||
$row = $this->locked($s);
|
||||
$material = self::revision($s);
|
||||
if (($result['source_revision'] ?? null) !== $material || self::missing($s) !== ($result['missing'] ?? [])) {
|
||||
return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.');
|
||||
}
|
||||
|
||||
return $result + ['replayed' => true];
|
||||
}
|
||||
if ($action === 'prepare') {
|
||||
$s = $this->source($id);
|
||||
$row = $this->locked($s);
|
||||
$revision = self::revision($s);
|
||||
$missing = self::missing($s);
|
||||
$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.');
|
||||
} 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')]);
|
||||
$this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]);
|
||||
} else {
|
||||
$pid = $candidate->id;
|
||||
}
|
||||
$result = ['state' => $missing === [] ? 'prepared' : 'pending_source', 'proposal_id' => $pid, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => $missing];
|
||||
}
|
||||
} else {
|
||||
$p = $this->q('proposals')->where('id', $id)->first();
|
||||
if (! $p) {
|
||||
throw new \DomainException('Proposta fora do âmbito.');
|
||||
}
|
||||
if ((int) $p->prepared_by === $this->actor || $p->prepared_person === $this->person) {
|
||||
throw new \DomainException('Validação exige pessoa distinta da preparação.');
|
||||
}
|
||||
$row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first();
|
||||
$local = (int) $row->local_source_id;
|
||||
$s = $this->source($local);
|
||||
$revision = self::revision($s);
|
||||
$missing = self::missing($s);
|
||||
$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 !== []) {
|
||||
$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' => []];
|
||||
} 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')]);
|
||||
$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' => []];
|
||||
}
|
||||
}
|
||||
$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];
|
||||
});
|
||||
}
|
||||
|
||||
private function conflict(object $row, int $local, string $reason): array
|
||||
{
|
||||
$this->event('payable.source_conflict', $row->id, ['reason' => $reason, 'observed_revision' => self::revision($this->source($local))]);
|
||||
|
||||
return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
|
||||
}
|
||||
|
||||
public function listing(array $localSources): array
|
||||
{
|
||||
return $this->db->transaction(function () use ($localSources) {
|
||||
($this->authorize)('view');
|
||||
$documents = [];
|
||||
foreach ($localSources as $id) {
|
||||
$s = $this->source($id);
|
||||
$documents[] = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
|
||||
}
|
||||
$current = [];
|
||||
foreach ($documents as $doc) {
|
||||
$current[self::identity($doc)] = $doc;
|
||||
}
|
||||
$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.'])];
|
||||
})->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,14 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
final class Workspace
|
||||
{
|
||||
public static function render(array $data, array $settings): string
|
||||
{
|
||||
ob_start();
|
||||
require __DIR__.'/../ui/page.php';
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user