This commit is contained in:
2026-11-02 08:06:45 +02:00
parent e4f520092e
commit 2a4169162b
108 changed files with 3793 additions and 88 deletions
+11
View File
@@ -0,0 +1,11 @@
# Distribuição Compras → SGF
Fonte canónica: este directório em Compras. As cópias no SGF são artefactos do mesmo mecanismo, não implementações mantidas em paralelo. O runtime instalado nunca procura a pasta de Compras. PHP ZipArchive e as dependências já instaladas de cada host são reutilizados.
O formato 1 transporta apenas código, metadados e documentação do pacote Purchasing. Exclui `.env`, bases, documentos, contas, dados de teste e concessões. Exige uma referência SHA-256 de manifesto obtida de um canal de confiança; hashes não substituem a autenticidade desse canal. Não existe download de URLs arbitrários, execução de scripts de migração ou substituição de dependências. Distribuição remota assinada e migrações de módulos com dados exigem extensão explícita do contrato.
O editor publica uma versão patch apenas quando os bytes da origem mudam. A publicação oficial executa regressões e uma prova da fotografia imutável antes de actualizar o catálogo. A instalação verifica o pacote inteiro, prova a candidata num processo PHP do host, verifica a integridade da versão anterior e activa uma pasta imutável por ponteiro atómico. Bloqueio de ficheiro serializa instalações concorrentes. Código alterado localmente, versão repetida com outros bytes, downgrade implícito, ficheiros extra/ausentes e caminhos fora do pacote são recusados. A versão anterior não é apagada.
Histórico de activações fica na instalação, separado do código distribuído. Uma falha antes da troca do ponteiro mantém a versão activa; preparações incompletas podem permanecer para diagnóstico. `rollback` regressa explicitamente à versão de código anterior verificada, sem apagar ou recalcular dados. O contrato actual não permite mudanças de schema, tornando essa reversão independente da base.
Actualizar este próprio mecanismo exige distribuir e conferir os seus artefactos de arranque, em separado do pacote de negócio. Não sobrescrever modificações locais por conveniência. Serviços de longa duração devem ser reiniciados pelo procedimento de operação da instalação depois de uma actualização; não são terminados automaticamente pelo instalador.
+295
View File
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace Tempo\Distribution;
use RuntimeException;
use ZipArchive;
/** Offline, immutable code releases. Never connects to a database or grants access. */
final class Release
{
private const LIMIT = 33554432;
private static function fail(bool $condition, string $message): void
{
if (! $condition) {
throw new RuntimeException($message);
}
}
private static function path(string $path): bool
{
return preg_match('~^(bootstrap\.php|module\.json|README\.md|src/[A-Za-z0-9_/]+\.php)$~D', $path) === 1
&& ! str_contains($path, '..') && ! str_contains($path, '//');
}
private static function json(string $path): array
{
self::fail(is_file($path) && ! is_link($path) && filesize($path) < self::LIMIT, 'Manifesto inexistente ou inválido.');
$value = json_decode(file_get_contents($path), true, 64, JSON_THROW_ON_ERROR);
self::fail(is_array($value), 'Manifesto inválido.');
return $value;
}
private static function write(string $path, string $content): void
{
self::fail(! is_link($path), 'Destino de gravação não pode ser um link.');
self::fail(file_put_contents($path, $content, LOCK_EX) === strlen($content), 'Não foi possível gravar o artefacto.');
}
private static function encode(array $value): string
{
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
}
private static function version(string $version): void
{
self::fail(preg_match('/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/D', $version) === 1, 'Versão inválida.');
}
/** Publish a patch version only when canonical package contents have changed. */
public static function publish(string $source, string $channel): array
{
$source = realpath($source);
self::fail($source !== false && is_dir($source), 'Pacote de origem inexistente.');
$module = self::json($source.'/module.json');
self::fail(($module['package'] ?? '') === 'tempo/purchasing' && ($module['host_contract'] ?? null) === 1
&& ($module['database_changes'] ?? null) === false && ($module['commercial_permissions'] ?? null) === false, 'Contrato de distribuição incompatível.');
$contents = [];
$files = [];
$size = 0;
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS)) as $file) {
self::fail(! $file->isLink(), 'Links não são permitidos no pacote.');
if (! $file->isFile()) {
continue;
}
$path = str_replace('\\', '/', substr($file->getPathname(), strlen($source) + 1));
self::fail(self::path($path), 'Ficheiro fora do contrato do pacote: '.$path);
$size += $file->getSize();
self::fail($size <= self::LIMIT, 'Pacote demasiado grande.');
$contents[$path] = file_get_contents($file->getPathname());
$files[$path] = hash('sha256', $contents[$path]);
}
ksort($files);
self::fail(isset($files['bootstrap.php'], $files['module.json']) && count($files) <= 20000, 'Pacote incompleto.');
$digest = hash('sha256', self::encode($files));
self::fail(is_dir($channel) || mkdir($channel, 0700, true), 'Canal de distribuição indisponível.');
$lock = fopen($channel.'/.publish.lock', 'c');
self::fail($lock !== false && flock($lock, LOCK_EX), 'Publicação ocupada.');
try {
$catalog = is_file($channel.'/catalog.json') ? self::json($channel.'/catalog.json') : ['schema' => 1, 'releases' => []];
self::fail(($catalog['schema'] ?? null) === 1 && is_array($catalog['releases'] ?? null), 'Canal incompatível.');
$last = $catalog['releases'] === [] ? null : $catalog['releases'][array_key_last($catalog['releases'])];
if ($last !== null && ($last['source_sha256'] ?? null) === $digest) {
self::inspect($channel.'/'.$last['archive'], $last['manifest_sha256']);
return $last + ['changed' => false];
}
$version = $last === null ? '0.1.0' : implode('.', [...array_slice(explode('.', $last['version']), 0, 2), ((int) explode('.', $last['version'])[2]) + 1]);
self::version($version);
$manifest = ['schema' => 1, 'package' => 'tempo/purchasing', 'version' => $version,
'source_sha256' => $digest, 'module' => $module, 'files' => $files];
$manifestText = self::encode($manifest);
$filename = 'purchasing-'.$version.'.zip';
self::fail(! file_exists($channel.'/'.$filename), 'Versão já existe; não será sobrescrita.');
$temporary = $channel.'/.publish-'.bin2hex(random_bytes(8)).'.zip';
$zip = new ZipArchive;
self::fail($zip->open($temporary, ZipArchive::CREATE | ZipArchive::EXCL) === true, 'Não foi possível criar o pacote.');
foreach ($files as $path => $hash) {
self::fail($zip->addFromString('payload/'.$path, $contents[$path]), 'Falha ao empacotar código.');
}
$zip->addFromString('manifest.json', $manifestText);
self::fail($zip->close(), 'Falha ao concluir o pacote.');
$entry = ['version' => $version, 'archive' => $filename, 'source_sha256' => $digest, 'manifest_sha256' => hash('sha256', $manifestText)];
self::inspect($temporary, $entry['manifest_sha256']);
self::fail(rename($temporary, $channel.'/'.$filename), 'Falha ao publicar versão.');
$catalog['releases'][] = $entry;
self::write($channel.'/catalog.next.json', self::encode($catalog));
self::fail(rename($channel.'/catalog.next.json', $channel.'/catalog.json'), 'Falha ao publicar catálogo.');
return $entry + ['changed' => true];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Validate every archive byte and reject undeclared files before writing anywhere. */
public static function inspect(string $archive, string $expectedManifest): array
{
self::fail(preg_match('/^[a-f0-9]{64}$/D', $expectedManifest) === 1 && is_file($archive) && filesize($archive) <= self::LIMIT, 'Pacote ou referência de confiança inválidos.');
$zip = new ZipArchive;
self::fail($zip->open($archive, ZipArchive::RDONLY) === true, 'Pacote ilegível.');
try {
$metadata = $zip->statName('manifest.json');
self::fail(is_array($metadata) && $metadata['size'] < 1048576 && $zip->numFiles < 20001, 'Manifesto ou arquivo demasiado grande.');
$raw = $zip->getFromName('manifest.json');
self::fail(is_string($raw) && strlen($raw) < 1048576 && hash_equals($expectedManifest, hash('sha256', $raw)), 'Manifesto diferente da referência aprovada.');
$manifest = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
self::fail(($manifest['schema'] ?? null) === 1 && ($manifest['package'] ?? '') === 'tempo/purchasing'
&& ($manifest['module']['host_contract'] ?? null) === 1 && ($manifest['module']['database_changes'] ?? null) === false
&& ($manifest['module']['commercial_permissions'] ?? null) === false, 'Contrato do pacote incompatível.');
self::version($manifest['version']);
self::fail(version_compare(PHP_VERSION, $manifest['module']['minimum_php'], '>='), 'Versão PHP incompatível.');
$files = $manifest['files'] ?? [];
self::fail(is_array($files) && isset($files['bootstrap.php'], $files['module.json']) && count($files) < 20000, 'Pacote incompleto.');
$seen = [];
$contents = [];
$size = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
$name = $stat['name'];
self::fail(! isset($seen[$name]), 'Entrada duplicada no pacote.');
$seen[$name] = true;
$size += $stat['size'];
self::fail($size <= self::LIMIT, 'Pacote expandido demasiado grande.');
if ($name === 'manifest.json') {
continue;
}
self::fail(str_starts_with($name, 'payload/'), 'Entrada inesperada no pacote.');
$path = substr($name, 8);
self::fail(self::path($path) && isset($files[$path]), 'Caminho fora do pacote.');
$value = $zip->getFromIndex($index);
self::fail(is_string($value) && hash_equals($files[$path], hash('sha256', $value)), 'Código divergente do manifesto.');
$contents[$path] = $value;
}
self::fail(count($contents) === count($files), 'Faltam ficheiros no pacote.');
$sorted = $files;
ksort($sorted);
self::fail(hash_equals($manifest['source_sha256'], hash('sha256', self::encode($sorted))), 'Identidade da origem inválida.');
self::fail(json_decode($contents['module.json'], true, 32, JSON_THROW_ON_ERROR) === $manifest['module'], 'Metadados do módulo divergentes.');
return ['manifest' => $manifest, 'manifest_text' => $raw, 'contents' => $contents];
} finally {
$zip->close();
}
}
/** Read the installed pointer and verify its immutable source files. */
public static function current(string $host): ?array
{
$base = rtrim($host, '/\\').'/packages/purchasing';
if (! is_file($base.'/current.json')) {
return null;
}
return self::verifiedPointer($host, self::json($base.'/current.json'));
}
private static function verifiedPointer(string $host, array $pointer): array
{
$base = rtrim($host, '/\\').'/packages/purchasing';
self::version($pointer['version']);
$release = $base.'/releases/'.$pointer['version'];
$realHost = str_replace('\\', '/', realpath($host) ?: '');
$realRelease = str_replace('\\', '/', realpath($release) ?: '');
self::fail($realHost !== '' && str_starts_with(strtolower($realRelease), strtolower($realHost).'/packages/purchasing/releases/'), 'Caminho de versão fora da instalação.');
$manifest = self::json($release.'/manifest.json');
self::fail(($manifest['version'] ?? '') === $pointer['version'] && ($manifest['package'] ?? '') === 'tempo/purchasing', 'Identidade da versão instalada inválida.');
self::fail(hash_equals($pointer['manifest_sha256'], hash_file('sha256', $release.'/manifest.json')), 'Versão instalada diverge da referência.');
foreach ($manifest['files'] as $path => $hash) {
$realFile = str_replace('\\', '/', realpath($release.'/'.$path) ?: '');
self::fail(self::path($path) && ! is_link($release.'/'.$path) && is_file($release.'/'.$path)
&& str_starts_with(strtolower($realFile), strtolower($realRelease).'/')
&& hash_equals($hash, hash_file('sha256', $release.'/'.$path)), 'Versão instalada foi alterada: '.$path);
}
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($release, \FilesystemIterator::SKIP_DOTS)) as $file) {
self::fail(! $file->isLink(), 'Link inesperado na versão instalada.');
if ($file->isFile()) {
$relative = str_replace('\\', '/', substr($file->getPathname(), strlen($release) + 1));
self::fail($relative === 'manifest.json' || isset($manifest['files'][$relative]), 'Ficheiro não declarado na versão instalada.');
}
}
return $pointer + ['path' => $release, 'capabilities' => $manifest['module']['capabilities']];
}
private static function activate(string $base, ?array $before, array $pointer, string $action): void
{
$pointer['audit_id'] = bin2hex(random_bytes(12));
self::fail(! is_link($base.'/history') && (is_dir($base.'/history') || mkdir($base.'/history', 0700)), 'Histórico indisponível.');
self::write($base.'/history/'.$pointer['audit_id'].'.json', self::encode(['action' => $action, 'at' => gmdate('c'), 'before' => $before, 'after' => $pointer, 'database_changed' => false]));
self::write($base.'/current.next.json', self::encode($pointer));
self::fail(rename($base.'/current.next.json', $base.'/current.json'), 'Falha ao activar a versão; versão anterior preservada.');
}
/** Explicit rollback only to the previous verified code release; never rolls back data. */
public static function rollback(string $host): array
{
$base = rtrim($host, '/\\').'/packages/purchasing';
self::fail(is_dir($base) && ! is_link($base) && ! is_link(rtrim($host, '/\\').'/packages'), 'Módulo não instalado ou caminho inválido.');
$lock = fopen($base.'/.update.lock', 'c');
self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.');
try {
$current = self::current($host);
self::fail($current !== null && preg_match('/^[a-f0-9]{24}$/D', $current['audit_id']) === 1, 'Sem versão anterior.');
$history = self::json($base.'/history/'.$current['audit_id'].'.json');
$previous = $history['before'] ?? null;
self::fail(is_array($previous), 'Sem versão anterior.');
$verified = self::verifiedPointer($host, $previous);
$pointer = array_intersect_key($verified, array_flip(['version', 'manifest_sha256', 'source_sha256']));
$pointer['previous'] = $current['version'];
self::activate($base, $current, $pointer, 'rollback_code_release');
return self::current($host);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Immutable version installation + atomic pointer. No migrations, seeds or permissions. */
public static function install(string $host, string $archive, string $expectedManifest): array
{
$checked = self::inspect($archive, $expectedManifest);
self::fail(is_dir($host) && ! is_link($host), 'Instalação de destino inexistente.');
$host = realpath($host);
self::fail((self::json($host.'/composer.json')['name'] ?? '') === 'tempo/sgf-tempo', 'Destino não é uma instalação SGF.');
self::fail(! is_link($host.'/packages'), 'O directório de pacotes não pode redireccionar a instalação.');
$base = $host.'/packages/purchasing';
self::fail(! is_link($base) && (is_dir($base) || mkdir($base, 0700, true)), 'Directório de módulo inválido.');
$lock = fopen($base.'/.update.lock', 'c');
self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.');
try {
$current = self::current($host);
$version = $checked['manifest']['version'];
if ($current !== null && $current['version'] === $version) {
self::fail(hash_equals($current['manifest_sha256'], $expectedManifest), 'Tentativa de substituir uma versão imutável.');
return $current + ['changed' => false];
}
self::fail($current === null || version_compare($version, $current['version'], '>'), 'Actualização não permite downgrade implícito.');
$releases = $base.'/releases';
self::fail(! is_link($releases) && (is_dir($releases) || mkdir($releases, 0700)), 'Directório de versões inválido.');
$destination = $releases.'/'.$version;
if (is_dir($destination)) {
$pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null];
self::verifiedPointer($host, $pointer);
self::activate($base, $current, $pointer, 'reactivate_verified_code_release');
return self::current($host) + ['changed' => true];
}
self::fail(! file_exists($destination), 'Destino de versão inválido.');
$stage = $releases.'/.stage-'.bin2hex(random_bytes(8));
self::fail(mkdir($stage, 0700), 'Preparação da versão falhou.');
foreach ($checked['contents'] as $path => $content) {
$directory = dirname($stage.'/'.$path);
self::fail(is_dir($directory) || mkdir($directory, 0700, true), 'Preparação do código falhou.');
self::write($stage.'/'.$path, $content);
}
self::write($stage.'/manifest.json', $checked['manifest_text']);
self::fail(rename($stage, $destination), 'Falha ao fixar a versão preparada.');
$pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null];
self::verifiedPointer($host, $pointer);
self::activate($base, $current, $pointer, 'activate_code_release');
return self::current($host) + ['changed' => true];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
}
@@ -0,0 +1,12 @@
<?php
use Tempo\Distribution\Release;
// Installed host loader. No runtime dependency on the source checkout.
require_once dirname(__DIR__).'/module-updates/Release.php';
$purchasingRelease = Release::current(dirname(__DIR__, 2));
if ($purchasingRelease === null) {
throw new RuntimeException('Instale uma versão verificada do módulo de Compras.');
}
require_once $purchasingRelease['path'].'/bootstrap.php';
unset($purchasingRelease);
@@ -0,0 +1,11 @@
{
"schema": 1,
"canonical_source": "Compras/packages/module-updates and Compras/scripts",
"files": {
"packages/module-updates/Release.php": "c64020ad7fb8bd82348d4f1b0d63ff7ac8209ea1005c90d86fe1144309e41161",
"packages/purchasing/bootstrap.php": "f0b2e9f06e29c767609f58ca6fe410af4850bc9066076610fa7fb513d286ecb2",
"scripts/purchasing-release.php": "c1420370252302d3da2d9981ead5a288eec9be35f0797e6df0643f9a8b9844be",
"scripts/purchasing-probe.php": "b4b505624e31f1bed4985e800a6f31f47c05e29dfabb0f296504521b5121c57d",
"scripts/purchasing-client-check.php": "57f1195e1e40bd304164740f4ae0b45424858587f9fd6fcf26eb143c8d62becc"
}
}
+1
View File
@@ -8,4 +8,5 @@ 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/ReversalSchema.php';
require_once __DIR__.'/src/SettlementHttp.php';
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Explicit QA upgrade; never run automatically on existing demos or operational databases. */
final class ReversalSchema
{
public const TABLES = ['ps_reversal_proposals', 'ps_reversals'];
public static function install(): void
{
$db = DB::connection();
if (! app()->environment('payable-testing') || $db->getDriverName() !== 'pgsql' || $db->getConfig('host') !== '127.0.0.1'
|| ! 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('Reversal upgrade requires isolated QA.');
}
foreach (SettlementSchema::TABLES as $table) {
if (! Schema::hasTable($table)) {
throw new \LogicException('Complete settlement schema required.');
}
}
if (array_filter(self::TABLES, fn ($t) => Schema::hasTable($t)) || Schema::hasColumn('ps_proposals', 'allocation_cycle')) {
throw new \LogicException('Upgrade already present or incomplete; no automatic repair.');
}
DB::transaction(function (): void {
Schema::create('ps_reversal_proposals', function (Blueprint $t): void {
$t->id();
foreach (['tenant_id', 'company_id', 'unit_id'] as $field) {
$t->unsignedBigInteger($field);
}
$t->foreignId('allocation_id')->unique()->constrained('ps_allocations');
$t->string('source_revision', 64);
$t->unsignedBigInteger('prepared_by');
$t->string('prepared_person');
$t->text('reason');
$t->timestamp('created_at');
});
Schema::create('ps_reversals', function (Blueprint $t): void {
$t->id();
foreach (['tenant_id', 'company_id', 'unit_id'] as $field) {
$t->unsignedBigInteger($field);
}
$t->foreignId('allocation_id')->unique()->constrained('ps_allocations');
$t->foreignId('proposal_id')->unique()->constrained('ps_reversal_proposals');
$t->foreignId('payment_id')->constrained('ps_payments');
$t->foreignId('obligation_id')->constrained('pa_obligations');
$t->bigInteger('amount_minor');
$t->string('source_revision', 64);
$t->unsignedBigInteger('actor_id');
$t->string('person');
$t->text('reason');
$t->timestamp('created_at');
});
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()");
}
// DB protection of the entire compensation, including context and distinct identities.
DB::unprepared(<<<'SQL'
CREATE FUNCTION ps_reversal_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM ps_allocations a JOIN ps_reversal_proposals p ON p.allocation_id=a.id
WHERE a.id=NEW.allocation_id AND p.id=NEW.proposal_id
AND (a.tenant_id,a.company_id,a.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id)
AND (p.tenant_id,p.company_id,p.unit_id)=(NEW.tenant_id,NEW.company_id,NEW.unit_id)
AND a.payment_id=NEW.payment_id AND a.obligation_id=NEW.obligation_id
AND a.amount_minor=NEW.amount_minor AND NEW.amount_minor>0
AND a.source_revision=NEW.source_revision AND p.source_revision=NEW.source_revision
AND p.prepared_by<>NEW.actor_id AND p.prepared_person<>NEW.person
) THEN RAISE EXCEPTION 'Invalid full allocation compensation'; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER ps_reversal_integrity BEFORE INSERT ON ps_reversals FOR EACH ROW EXECUTE FUNCTION ps_reversal_integrity();
ALTER TABLE ps_proposals ADD COLUMN allocation_cycle bigint NOT NULL DEFAULT 0;
ALTER TABLE ps_proposals DROP CONSTRAINT ps_proposals_payment_id_plan_hash_unique;
ALTER TABLE ps_proposals ADD CONSTRAINT ps_proposals_payment_plan_cycle_unique UNIQUE (payment_id,plan_hash,allocation_cycle);
ALTER TABLE ps_allocations DROP CONSTRAINT ps_allocations_payment_id_obligation_id_unique;
ALTER TABLE ps_allocations ADD CONSTRAINT ps_allocations_decision_obligation_unique UNIQUE (decision_id,obligation_id);
SQL);
});
}
}
+11 -2
View File
@@ -2,6 +2,7 @@
namespace Tempo\Finance\Payables;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -35,6 +36,9 @@ abstract class SettlementHttp extends Controller
return DB::transaction(function () use ($r): array {
$s = app(static::SERVICE);
$user = $r->user();
if (! $user instanceof User) {
abort(401);
}
$settlements = $s->engine($user)->settlements()->listing();
$base = $s->listing($user);
foreach ($base['obligations'] as &$o) {
@@ -43,12 +47,13 @@ abstract class SettlementHttp extends Controller
}
unset($o);
$permissions = [];
foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'] as $action) {
foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm', 'reversal_prepare', 'reversal_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'],
'reversals_available' => $settlements['reversals_available'], 'reversal_proposals' => $settlements['reversal_proposals'], 'reversals' => $settlements['reversals'],
'permissions' => $permissions, 'current_actor' => $user->id];
});
}
@@ -61,7 +66,11 @@ abstract class SettlementHttp extends Controller
public function page(Request $r): Response
{
$data = $this->data($r);
$settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $r->user()->name,
$user = $r->user();
if (! $user instanceof User) {
abort(401);
}
$settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $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'];
+116 -6
View File
@@ -135,12 +135,33 @@ final class SupplierSettlements
return 0;
}
return (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
$allocated = (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
$reversed = $db->getSchemaBuilder()->hasTable('ps_reversals')
? (int) $db->table('ps_reversals')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor') : 0;
return $allocated - $reversed;
}
private function unallocated(\stdClass $payment): int
{
return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor');
return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor')
+ ($this->reversalsAvailable() ? (int) $this->q('reversals')->where('payment_id', $payment->id)->sum('amount_minor') : 0);
}
private function reversalsAvailable(): bool
{
if (! method_exists($this->db, 'getSchemaBuilder')) {
throw new \LogicException('A ligação exige consulta explícita do esquema financeiro.');
}
return $this->db->getSchemaBuilder()->hasTable('ps_reversals')
&& $this->db->getSchemaBuilder()->hasTable('ps_reversal_proposals')
&& $this->db->getSchemaBuilder()->hasColumn('ps_proposals', 'allocation_cycle');
}
private function cycle(int $payment): int
{
return $this->reversalsAvailable() ? (int) $this->q('reversals')->where('payment_id', $payment)->max('id') : 0;
}
/** @param array<string,mixed> $data
@@ -148,7 +169,8 @@ final class SupplierSettlements
*/
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'];
$permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm',
'reversal-prepare' => 'reversal_prepare', 'reversal-confirm' => 'reversal_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.');
}
@@ -164,6 +186,7 @@ final class SupplierSettlements
$result = match ($action) {
'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data),
'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($data),
'reversal-prepare' => $this->prepareReversal($data), 'reversal-confirm' => $this->confirmReversal($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.');
@@ -283,7 +306,14 @@ final class SupplierSettlements
$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();
$old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0))->get();
if ($old->isEmpty()) {
$semantic = $this->q('proposals')->where('payment_id', $p->id)->where('plan_hash', $hash);
if ($this->reversalsAvailable()) {
$semantic->where('allocation_cycle', $this->cycle((int) $p->id));
}
$old = $semantic->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.');
@@ -297,7 +327,8 @@ final class SupplierSettlements
$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()]);
'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]
+ ($this->reversalsAvailable() ? ['allocation_cycle' => $this->cycle((int) $p->id)] : []));
$this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]);
return ['proposal_id' => $id];
@@ -316,7 +347,11 @@ final class SupplierSettlements
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())) {
$active = $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']]);
if ($this->reversalsAvailable()) {
$active->whereNotIn('id', $this->q('reversals')->select('allocation_id'));
}
if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $active->exists())) {
throw new \DomainException('Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa.');
}
}
@@ -343,6 +378,9 @@ final class SupplierSettlements
$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();
if (! $old && $d['decision'] === 'confirmed' && $this->reversalsAvailable() && (int) $proposal->allocation_cycle !== $this->cycle((int) $p->id)) {
throw new \DomainException('Ciclo de afectação desactualizado após reversão; prepare uma nova proposta.');
}
$this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed');
if ($old) {
if ($old->decision !== $d['decision'] || $old->reason !== $reason) {
@@ -363,6 +401,75 @@ final class SupplierSettlements
return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id];
}
private function reversible(int $id, mixed $revision): \stdClass
{
if (! $this->reversalsAvailable()) {
throw new \DomainException('Reversões não instaladas neste ensaio.');
}
$a = $this->q('allocations')->where('id', $id)->first() ?? throw new \DomainException('Afectação fora do contexto.');
$p = $this->payment((int) $a->payment_id);
$o = ($this->obligation)((int) $a->obligation_id);
if ($revision !== $a->source_revision || $revision !== $o['source_revision'] || $o['currency'] !== $p->currency || $o['supplier_key'] !== $p->supplier_key) {
throw new \DomainException('Versão ou origem incompatível; reversão pendente de revisão.');
}
return $a;
}
/** @param array<string,mixed> $d
* @return array<string,int>
*/
private function prepareReversal(array $d): array
{
$this->exact($d, ['allocation_id', 'source_revision', 'reason']);
$a = $this->reversible($this->id($d['allocation_id']), $d['source_revision']);
$reason = $this->reason($d['reason']);
$old = $this->q('reversal_proposals')->where('allocation_id', $a->id)->first();
if ($old) {
if ($old->reason !== $reason || $old->source_revision !== $d['source_revision']) {
throw new \DomainException('Reversão já proposta com conteúdo diferente.');
}
return ['proposal_id' => (int) $old->id];
}
$id = $this->db->table('ps_reversal_proposals')->insertGetId($this->scope + ['allocation_id' => $a->id,
'source_revision' => $a->source_revision, 'prepared_by' => $this->actor, 'prepared_person' => $this->person,
'reason' => $reason, 'created_at' => $this->stamp()]);
$this->event((int) $a->payment_id, 'supplier_allocation.reversal_proposed', ['proposal_id' => $id, 'allocation_id' => $a->id]);
return ['proposal_id' => $id];
}
/** @param array<string,mixed> $d
* @return array<string,int>
*/
private function confirmReversal(array $d): array
{
$this->exact($d, ['proposal_id', 'reason']);
if (! $this->reversalsAvailable()) {
throw new \DomainException('Reversões não instaladas neste ensaio.');
}
$proposal = $this->q('reversal_proposals')->where('id', $this->id($d['proposal_id']))->first() ?? throw new \DomainException('Proposta de reversão fora do contexto.');
$a = $this->reversible((int) $proposal->allocation_id, $proposal->source_revision);
$this->independent($proposal);
$reason = $this->reason($d['reason']);
$old = $this->q('reversals')->where('allocation_id', $a->id)->first();
if ($old) {
if ((int) $old->proposal_id !== (int) $proposal->id || $old->reason !== $reason) {
throw new \DomainException('Afectação já revertida; conteúdo incompatível.');
}
return ['proposal_id' => (int) $proposal->id, 'reversal_id' => (int) $old->id];
}
$id = $this->db->table('ps_reversals')->insertGetId($this->scope + ['allocation_id' => $a->id, 'proposal_id' => $proposal->id,
'payment_id' => $a->payment_id, 'obligation_id' => $a->obligation_id, 'amount_minor' => $a->amount_minor,
'source_revision' => $a->source_revision, 'actor_id' => $this->actor, 'person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
$this->event((int) $a->payment_id, 'supplier_allocation.reversed', ['proposal_id' => $proposal->id, 'reversal_id' => $id,
'allocation_id' => $a->id, 'amount_minor' => (int) $a->amount_minor]);
return ['proposal_id' => (int) $proposal->id, 'reversal_id' => $id];
}
/** @return array<string,mixed> */
public function listing(): array
{
@@ -377,6 +484,9 @@ final class SupplierSettlements
$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(),
'reversals_available' => $this->reversalsAvailable(),
'reversal_proposals' => $this->reversalsAvailable() ? $this->q('reversal_proposals')->orderBy('id')->get()->all() : [],
'reversals' => $this->reversalsAvailable() ? $this->q('reversals')->orderBy('id')->get()->all() : [],
'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor];
});
}
+12 -2
View File
@@ -19,14 +19,24 @@ body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-widt
<?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 id="allocation-proposals"><h2>4. Propostas de afectação</h2><p>A proposta não reduz a dívida. Troque para Validar para confirmar a afectação com outra identidade.</p><?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 id="reversals"><h2>5. Reversão integral e nova afectação</h2><p>Propor não altera saldos. Confirmar com outra identidade compensa integralmente a afectação original e volta a disponibilizar esse montante do pagamento. O pagamento continua confirmado: não há cancelamento bancário. Depois, use «Propor afectações» com uma nova referência para corrigir o valor.</p>
<?php if (! ($data['reversals_available'] ?? false)): ?><p>Actualização de reversões ainda não aplicada neste ensaio. Os saldos e o percurso anterior permanecem disponíveis.</p><?php else: ?>
<?php foreach ($data['allocations'] as $a): $reversal = null; $proposal = null; foreach ($data['reversals'] as $r) if ((int) $r->allocation_id === (int) $a->id) $reversal = $r; foreach ($data['reversal_proposals'] as $r) if ((int) $r->allocation_id === (int) $a->id) $proposal = $r; ?>
<article><h3>Afectação #<?= $e($a->id) ?> · Pagamento #<?= $e($a->payment_id) ?> · Obrigação #<?= $e($a->obligation_id) ?></h3><p>Original preservado: <?= $money($a->amount_minor) ?> · Versão <?= $e($a->source_revision) ?></p>
<?php if ($reversal): ?><p>Revertida integralmente por movimento #<?= $e($reversal->id) ?> · <?= $money($reversal->amount_minor) ?> · actor #<?= $e($reversal->actor_id) ?> · <?= $e($reversal->reason) ?></p>
<?php elseif ($proposal): ?><p>Proposta de reversão #<?= $e($proposal->id) ?> · Preparador #<?= $e($proposal->prepared_by) ?> · <?= $e($proposal->reason) ?> · Sem efeito no saldo</p>
<?php if (($data['permissions']['reversal_confirm'] ?? false) && (int) $proposal->prepared_by !== $data['current_actor']): ?><form data-action="reversal-confirm"><input type="hidden" name="proposal_id" value="<?= $e($proposal->id) ?>"><label>Motivo da confirmação independente<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar reversão integral de <?= $money($a->amount_minor) ?></button></form><?php endif ?>
<?php elseif ($data['permissions']['reversal_prepare'] ?? false): ?><form data-action="reversal-prepare"><input type="hidden" name="allocation_id" value="<?= $e($a->id) ?>"><input type="hidden" name="source_revision" value="<?= $e($a->source_revision) ?>"><label>Motivo obrigatório da reversão integral<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor reversão integral sem alterar saldo</button></form><?php endif ?>
<?php if ($proposal): ?><details><summary>Proposta original de reversão preservada</summary><pre><?= $e(json_encode($proposal, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details><?php endif ?></article><?php endforeach ?>
<?php endif ?></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>
<p>Reversões parciais, cancelamento bancário, reconciliação e novos pagamentos sobre documentos com histórico importado ficam fora desta entrega. 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='';document.querySelector('#status').textContent='A processar. Aguarde antes de repetir.';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=Object.values(d.errors??{}).flat().join(' ')||d.message||'Não foi possível concluir; consulte o histórico.';document.querySelector('#error').focus();return;}location.hash=form.dataset.action==='allocation-decide'?'obligation-balances':form.dataset.action==='allocation-prepare'?'allocation-proposals':'payments';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.';document.querySelector('#error').focus();}finally{document.querySelector('#status').textContent='';buttons.forEach(b=>b.disabled=false);}};
form.onsubmit=async e=>{e.preventDefault();let body=Object.fromEntries(new FormData(form));for(const key of ['obligation_id','payment_id','proposal_id','allocation_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='';document.querySelector('#status').textContent='A processar. Aguarde antes de repetir.';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=Object.values(d.errors??{}).flat().join(' ')||d.message||'Não foi possível concluir; consulte o histórico.';document.querySelector('#error').focus();return;}location.hash=form.dataset.action.startsWith('reversal-')?'reversals':form.dataset.action==='allocation-decide'?'obligation-balances':form.dataset.action==='allocation-prepare'?'allocation-proposals':'payments';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.';document.querySelector('#error').focus();}finally{document.querySelector('#status').textContent='';buttons.forEach(b=>b.disabled=false);}};
}
</script></body></html>
+12
View File
@@ -0,0 +1,12 @@
<?php
use Tempo\Distribution\Release;
// Installed host loader. No runtime dependency on the source checkout.
require_once dirname(__DIR__).'/module-updates/Release.php';
$purchasingRelease = Release::current(dirname(__DIR__, 2));
if ($purchasingRelease === null) {
throw new RuntimeException('Instale uma versão verificada do módulo de Compras.');
}
require_once $purchasingRelease['path'].'/bootstrap.php';
unset($purchasingRelease);
+9
View File
@@ -0,0 +1,9 @@
# Purchasing: fonte única de capacidades transferíveis
A origem deste pacote é Compras. O SGF recebe artefactos versionados desta origem, sem depender da pasta de desenvolvimento de Compras e sem manter outra implementação.
Primeira capacidade extraída: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes). O cálculo não lê bases, não movimenta stock nem cria encomendas. Conserva as convenções e unidades do percurso Compras. Requer Carbon 3 e Brick Math já presentes nos dois hosts; não substitui dependências.
Os restantes percursos de Compras ainda não fazem parte deste pacote. A presença do pacote não equivale a transferência integral, integração de utilizadores/empresas, concessão de permissões ou activação de módulos comerciais. O SGF não recebe contratações de pessoas para terceiros.
Após melhorias neste pacote, publicar pelo mecanismo `purchasing-release.php`. O manifesto regista versões e hashes; o SGF confere e instala os mesmos bytes. Nunca editar uma versão distribuída no SGF: corrigir a origem e publicar uma versão seguinte.
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/ReplenishmentProjection.php';
+22
View File
@@ -0,0 +1,22 @@
{
"schema": 1,
"package": "tempo/purchasing",
"version": "0.1.0",
"source_sha256": "2bb77aa1b723e0f616393aee739929399151548089693f38bdaffb0d75f3b186",
"module": {
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": [
"inventory.replenishment_projection"
],
"database_changes": false,
"commercial_permissions": false
},
"files": {
"README.md": "ad51be37229d2891b8e9c3a5ca5f6edc90f69feb11b9fa6f5f384a09a35d138e",
"bootstrap.php": "829c9f55ea850e64203927302a8daa1ebee791fe2d7763383b2b1a69cfe5f3f5",
"module.json": "f70d4553eec85f6f1f6f50d6c3e7a22678649bfb17248b45342787c405eb3752",
"src/ReplenishmentProjection.php": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": ["inventory.replenishment_projection"],
"database_changes": false,
"commercial_permissions": false
}
@@ -0,0 +1,115 @@
<?php
namespace Tempo\Purchasing\Supply;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Carbon\CarbonImmutable;
/** Quantities use integer thousandths, consistently with the existing stock ledger. */
final class ReplenishmentProjection
{
/** @param array<string,mixed> $policy
* @param list<array{date:string,quantity:int,additional?:bool}> $reservations
* @param list<array{date:string,quantity:int,confirmed:bool}> $receipts
* @param list<array{date:string,quantity:int}> $fulfilledNeeds
* @param list<array{quantity:int,last_usable_on:?string}>|null $lotBuckets
* @return array<string,mixed>
*/
public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array
{
$day = CarbonImmutable::parse($start)->startOfDay();
$daily = $policy['daily_milli'] ?? null;
$rate = $policy['daily_rate'] ?? null;
if ($rate !== null) {
if (! is_int($rate['numerator_milli']) || $rate['numerator_milli'] < 0 || ! is_int($rate['denominator_days']) || $rate['denominator_days'] < 1) {
throw new \InvalidArgumentException('A média histórica exige numerador não negativo e denominador positivo.');
}
$daily = 0;
}
if ($daily === null && $reservations === []) {
return ['available' => false, 'reason' => 'Previsão indisponível: configure consumo manual ou histórico validado.'];
}
$knownOnly = $daily === null;
$daily ??= 0;
$balance = $physical;
$risk = null;
$shortage = null;
$before = null;
$series = [];
$safety = $policy['safety_milli'];
if ($balance <= $safety) {
$risk = $day->toDateString();
$before = $balance;
}
for ($offset = 0; $offset < $policy['horizon_days']; $offset++) {
$date = $day->addDays($offset)->toDateString();
$expired = 0;
if ($lotBuckets !== null) {
foreach ($lotBuckets as &$bucket) {
if ($bucket['last_usable_on'] !== null && $bucket['last_usable_on'] < $date) {
$expired += $bucket['quantity'];
$bucket['quantity'] = 0;
}
}
unset($bucket);
}
$incoming = array_sum(array_column(array_filter($receipts, fn ($r) => $r['confirmed'] && $r['date'] === $date), 'quantity'));
$known = array_filter($reservations, fn ($r) => $r['date'] === $date);
$substitutes = array_sum(array_column(array_filter($known, fn ($r) => ! ($r['additional'] ?? false)), 'quantity'));
$additional = array_sum(array_column(array_filter($known, fn ($r) => $r['additional'] ?? false), 'quantity'));
$satisfied = array_sum(array_column(array_filter($fulfilledNeeds, fn ($n) => $n['date'] === $date), 'quantity'));
$expected = $daily;
if ($rate !== null) {
$cumulative = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset + 1)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$previous = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$expected = $cumulative - $previous;
}
$residualForecast = max(0, $expected - $satisfied - ($offset === 0 ? $consumedToday : 0));
$demand = max($residualForecast, $substitutes) + $additional;
$balance += $incoming - $demand - $expired;
if ($lotBuckets !== null) {
$remaining = $demand;
foreach ($lotBuckets as &$bucket) {
$take = min($remaining, $bucket['quantity']);
$bucket['quantity'] -= $take;
$remaining -= $take;
}
unset($bucket);
}
if ($risk === null && $balance <= $safety) {
$risk = $date;
$before = $balance;
}
if ($shortage === null && $balance < 0) {
$shortage = $date;
}
$series[] = ['date' => $date, 'balance_milli' => $balance, 'incoming_milli' => $incoming, 'demand_milli' => $demand, ...($lotBuckets !== null ? ['expired_milli' => $expired] : [])];
}
$base = $before === null ? 0 : max(0, $policy['target_milli'] - $before);
$quantity = $base === 0 ? 0 : max($base, $policy['minimum_milli']);
$multiple = $policy['pack_milli'];
$quantity = $quantity === 0 ? 0 : intdiv($quantity + $multiple - 1, $multiple) * $multiple;
$excess = $before === null ? 0 : max(0, $before + $quantity - $policy['maximum_milli']);
$futureExcess = 0;
if ($risk !== null && $quantity > 0) {
foreach ($series as $point) {
if ($point['date'] >= $risk) {
$futureExcess = max($futureExcess, $point['balance_milli'] + $quantity - $policy['maximum_milli']);
}
}
}
$mean = $rate === null ? $daily : (string) BigDecimal::of($rate['numerator_milli'])->dividedBy($rate['denominator_days'], 6, RoundingMode::HalfUp);
$reorder = $rate === null ? $daily * $policy['lead_days'] + $safety : BigDecimal::of($rate['numerator_milli'])->multipliedBy($policy['lead_days'])->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->plus($safety)->toInt();
return ['available' => true, 'method' => $knownOnly ? 'known_needs_only' : ($rate === null ? 'manual_calendar_days' : 'historical_mean_calendar_days'), 'demand_complete' => ! $knownOnly, 'physical_milli' => $physical,
'daily_milli' => $mean, 'reorder_point_milli' => $reorder,
'risk_date' => $risk, 'shortage_date' => $shortage,
'order_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'])->toDateString(),
'request_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'] + $policy['approval_days'])->toDateString(),
'base_milli' => $base, 'quantity_milli' => $quantity, 'excess_milli' => $excess,
'future_excess_milli' => $futureExcess, 'requires_exception' => $excess > 0 || $futureExcess > 0, 'series' => $series,
'convention' => 'Stock inicial na abertura; entradas confirmadas de manhã; consumo e nova reposição no fim do dia.'];
}
}
+13
View File
@@ -0,0 +1,13 @@
# Purchasing: fonte única de capacidades transferíveis
A origem deste pacote é Compras. O SGF recebe artefactos versionados desta origem, sem depender da pasta de desenvolvimento de Compras e sem manter outra implementação.
Primeira capacidade extraída: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes). O cálculo não lê bases, não movimenta stock nem cria encomendas. Conserva as convenções e unidades do percurso Compras. Requer Carbon 3 e Brick Math já presentes nos dois hosts; não substitui dependências.
Os restantes percursos de Compras ainda não fazem parte deste pacote. A presença do pacote não equivale a transferência integral, integração de utilizadores/empresas, concessão de permissões ou activação de módulos comerciais. O SGF não recebe contratações de pessoas para terceiros.
Após melhorias neste pacote, publicar pelo mecanismo `purchasing-release.php`. O manifesto regista versões e hashes; o SGF confere e instala os mesmos bytes. Nunca editar uma versão distribuída no SGF: corrigir a origem e publicar uma versão seguinte.
Percurso verificado no desenvolvimento: `composer purchasing:transfer -- <instalação-SGF> <canal-de-versões>`. Executa testes na origem, prova a fotografia candidata, publica uma nova versão apenas se houver alterações e usa o PHP fixado pelo SGF para conferir a candidata antes da activação. Repetir sem melhorias não reinstala código. Clientes recebem o ZIP e a referência SHA-256 por canal de distribuição de confiança e não precisam do checkout de Compras.
Este contrato não executa migrações nem substitui dependências, dados, acessos ou configuração empresarial. Pacotes que peçam alterações de base de dados são recusados até existir um contrato de migração ensaiado. Versões de código anteriores permanecem disponíveis para reversão explícita. A activação de módulos adquiridos e as permissões continuam sob a configuração e os mecanismos de acesso da instalação.
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/ReplenishmentProjection.php';
+22
View File
@@ -0,0 +1,22 @@
{
"schema": 1,
"package": "tempo/purchasing",
"version": "0.1.1",
"source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250",
"module": {
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": [
"inventory.replenishment_projection"
],
"database_changes": false,
"commercial_permissions": false
},
"files": {
"README.md": "44b6358d36e12fb9db7f897b7223a347b0fea1f98aa4cf5955245671ae171c81",
"bootstrap.php": "829c9f55ea850e64203927302a8daa1ebee791fe2d7763383b2b1a69cfe5f3f5",
"module.json": "f70d4553eec85f6f1f6f50d6c3e7a22678649bfb17248b45342787c405eb3752",
"src/ReplenishmentProjection.php": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": ["inventory.replenishment_projection"],
"database_changes": false,
"commercial_permissions": false
}
@@ -0,0 +1,115 @@
<?php
namespace Tempo\Purchasing\Supply;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Carbon\CarbonImmutable;
/** Quantities use integer thousandths, consistently with the existing stock ledger. */
final class ReplenishmentProjection
{
/** @param array<string,mixed> $policy
* @param list<array{date:string,quantity:int,additional?:bool}> $reservations
* @param list<array{date:string,quantity:int,confirmed:bool}> $receipts
* @param list<array{date:string,quantity:int}> $fulfilledNeeds
* @param list<array{quantity:int,last_usable_on:?string}>|null $lotBuckets
* @return array<string,mixed>
*/
public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array
{
$day = CarbonImmutable::parse($start)->startOfDay();
$daily = $policy['daily_milli'] ?? null;
$rate = $policy['daily_rate'] ?? null;
if ($rate !== null) {
if (! is_int($rate['numerator_milli']) || $rate['numerator_milli'] < 0 || ! is_int($rate['denominator_days']) || $rate['denominator_days'] < 1) {
throw new \InvalidArgumentException('A média histórica exige numerador não negativo e denominador positivo.');
}
$daily = 0;
}
if ($daily === null && $reservations === []) {
return ['available' => false, 'reason' => 'Previsão indisponível: configure consumo manual ou histórico validado.'];
}
$knownOnly = $daily === null;
$daily ??= 0;
$balance = $physical;
$risk = null;
$shortage = null;
$before = null;
$series = [];
$safety = $policy['safety_milli'];
if ($balance <= $safety) {
$risk = $day->toDateString();
$before = $balance;
}
for ($offset = 0; $offset < $policy['horizon_days']; $offset++) {
$date = $day->addDays($offset)->toDateString();
$expired = 0;
if ($lotBuckets !== null) {
foreach ($lotBuckets as &$bucket) {
if ($bucket['last_usable_on'] !== null && $bucket['last_usable_on'] < $date) {
$expired += $bucket['quantity'];
$bucket['quantity'] = 0;
}
}
unset($bucket);
}
$incoming = array_sum(array_column(array_filter($receipts, fn ($r) => $r['confirmed'] && $r['date'] === $date), 'quantity'));
$known = array_filter($reservations, fn ($r) => $r['date'] === $date);
$substitutes = array_sum(array_column(array_filter($known, fn ($r) => ! ($r['additional'] ?? false)), 'quantity'));
$additional = array_sum(array_column(array_filter($known, fn ($r) => $r['additional'] ?? false), 'quantity'));
$satisfied = array_sum(array_column(array_filter($fulfilledNeeds, fn ($n) => $n['date'] === $date), 'quantity'));
$expected = $daily;
if ($rate !== null) {
$cumulative = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset + 1)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$previous = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$expected = $cumulative - $previous;
}
$residualForecast = max(0, $expected - $satisfied - ($offset === 0 ? $consumedToday : 0));
$demand = max($residualForecast, $substitutes) + $additional;
$balance += $incoming - $demand - $expired;
if ($lotBuckets !== null) {
$remaining = $demand;
foreach ($lotBuckets as &$bucket) {
$take = min($remaining, $bucket['quantity']);
$bucket['quantity'] -= $take;
$remaining -= $take;
}
unset($bucket);
}
if ($risk === null && $balance <= $safety) {
$risk = $date;
$before = $balance;
}
if ($shortage === null && $balance < 0) {
$shortage = $date;
}
$series[] = ['date' => $date, 'balance_milli' => $balance, 'incoming_milli' => $incoming, 'demand_milli' => $demand, ...($lotBuckets !== null ? ['expired_milli' => $expired] : [])];
}
$base = $before === null ? 0 : max(0, $policy['target_milli'] - $before);
$quantity = $base === 0 ? 0 : max($base, $policy['minimum_milli']);
$multiple = $policy['pack_milli'];
$quantity = $quantity === 0 ? 0 : intdiv($quantity + $multiple - 1, $multiple) * $multiple;
$excess = $before === null ? 0 : max(0, $before + $quantity - $policy['maximum_milli']);
$futureExcess = 0;
if ($risk !== null && $quantity > 0) {
foreach ($series as $point) {
if ($point['date'] >= $risk) {
$futureExcess = max($futureExcess, $point['balance_milli'] + $quantity - $policy['maximum_milli']);
}
}
}
$mean = $rate === null ? $daily : (string) BigDecimal::of($rate['numerator_milli'])->dividedBy($rate['denominator_days'], 6, RoundingMode::HalfUp);
$reorder = $rate === null ? $daily * $policy['lead_days'] + $safety : BigDecimal::of($rate['numerator_milli'])->multipliedBy($policy['lead_days'])->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->plus($safety)->toInt();
return ['available' => true, 'method' => $knownOnly ? 'known_needs_only' : ($rate === null ? 'manual_calendar_days' : 'historical_mean_calendar_days'), 'demand_complete' => ! $knownOnly, 'physical_milli' => $physical,
'daily_milli' => $mean, 'reorder_point_milli' => $reorder,
'risk_date' => $risk, 'shortage_date' => $shortage,
'order_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'])->toDateString(),
'request_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'] + $policy['approval_days'])->toDateString(),
'base_milli' => $base, 'quantity_milli' => $quantity, 'excess_milli' => $excess,
'future_excess_milli' => $futureExcess, 'requires_exception' => $excess > 0 || $futureExcess > 0, 'series' => $series,
'convention' => 'Stock inicial na abertura; entradas confirmadas de manhã; consumo e nova reposição no fim do dia.'];
}
}