commit13
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
|
||||
Capacidades extraídas: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes), descontos unitários antes do IVA e pontuação comparativa exacta das propostas. Não lêem bases, movimentam stock, emitem documentos ou adjudicam. Conservam as convenções e unidades do percurso Compras. Requerem Carbon 3 e Brick Math já presentes nos dois hosts; não substituem dependências.
|
||||
|
||||
O host continua responsável por autorizações, conflitos, elegibilidade, avaliações válidas, política aplicada, mínimos de propostas e decisão independente. `ProposalScoring` recebe apenas essa projecção e devolve pontuações/todos os empates; não escolhe um vencedor nem declara o processo pronto. A fachada de preços de Compras traduz os erros de domínio para os mesmos erros de formulário anteriores. Nenhuma interpretação fiscal ou regra de emissão do SGF é substituída por estes cálculos de propostas.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__.'/src/ReplenishmentProjection.php';
|
||||
require_once __DIR__.'/src/QuotationPricing.php';
|
||||
require_once __DIR__.'/src/ProposalScoring.php';
|
||||
require_once __DIR__.'/src/RequisitionPort.php';
|
||||
require_once __DIR__.'/src/RequisitionFailure.php';
|
||||
require_once __DIR__.'/src/InternalRequisitions.php';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"package": "tempo/purchasing",
|
||||
"host_contract": 1,
|
||||
"minimum_php": "8.3.0",
|
||||
"capabilities": ["inventory.replenishment_projection", "procurement.unit_discounts", "procurement.proposal_scoring"],
|
||||
"database_changes": false,
|
||||
"commercial_permissions": false
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Internal;
|
||||
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
|
||||
/** Same lifecycle in both compositions; hosts cannot replace independent approval or budget ordering. */
|
||||
final class InternalRequisitions
|
||||
{
|
||||
public function __construct(private RequisitionPort $port) {}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $record
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function nextStep(array $record, int $actor): ?array
|
||||
{
|
||||
if ($record['status'] !== 'pending' || $record['requester_id'] === $actor) {
|
||||
return null;
|
||||
}
|
||||
$next = null;
|
||||
foreach ($record['steps'] as $step) {
|
||||
if (($step['approver_id'] ?? null) === $actor) {
|
||||
return null;
|
||||
}
|
||||
if ($next === null && $step['decision'] === 'pending') {
|
||||
$next = $step;
|
||||
}
|
||||
}
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
/** @return array{subtotal_minor:int,tax_minor:int,total_minor:int,unit_price_minor:int} */
|
||||
public static function line(string $quantity, string $price, string $taxRate): array
|
||||
{
|
||||
$minor = BigDecimal::of($price)->multipliedBy(100)->toScale(0)->toInt();
|
||||
$subtotal = BigDecimal::of($quantity)->multipliedBy($minor)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$tax = BigDecimal::of($subtotal)->multipliedBy($taxRate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
|
||||
return ['unit_price_minor' => $minor, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $record
|
||||
* @param array<string,mixed> $policy
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function roles(array $record, array $policy): array
|
||||
{
|
||||
$amount = $policy['approval_includes_tax'] ? $record['total_minor'] : $record['subtotal_minor'];
|
||||
$roles = ['department_head'];
|
||||
if ($amount > $policy['first_limit_minor']) {
|
||||
$roles[] = 'procurement';
|
||||
}
|
||||
if ($amount > $policy['second_limit_minor'] || $record['has_equipment']) {
|
||||
$roles[] = 'finance';
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
private static function canonical(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
return array_map(self::canonical(...), $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function command(string $action, ?int $id, ?int $version, ?string $key, array $input = []): array
|
||||
{
|
||||
if ($key !== null && ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/iD', $key)) {
|
||||
throw new RequisitionFailure('Chave de operação inválida.', 422);
|
||||
}
|
||||
$hash = hash('sha256', json_encode(self::canonical([$action, $id, $version, $input]), JSON_THROW_ON_ERROR));
|
||||
|
||||
return $this->port->atomic(function () use ($action, $id, $version, $key, $hash, $input) {
|
||||
$record = $id === null ? null : $this->port->load($id);
|
||||
$this->port->authorize($action, $record);
|
||||
if ($key !== null && ($old = $this->port->replay($key, $hash)) !== null) {
|
||||
$current = $this->port->load($old['id']);
|
||||
$this->port->authorize('view', $current);
|
||||
if (isset($old['operation_role'])) {
|
||||
$this->port->authorize('step:'.$old['operation_role'], $current);
|
||||
}
|
||||
|
||||
return $old + ['replayed' => true];
|
||||
}
|
||||
if ($record !== null && $version !== null && $record['version'] !== $version) {
|
||||
throw new RequisitionFailure('A requisição mudou. Actualize antes de continuar.');
|
||||
}
|
||||
$role = null;
|
||||
if ($action === 'save') {
|
||||
if ($record !== null && ($record['status'] !== 'draft' || $record['requester_id'] !== $this->port->actorId())) {
|
||||
throw new RequisitionFailure('Rascunho não autorizado.', 403);
|
||||
}
|
||||
$result = $this->port->draft($input, $id);
|
||||
} elseif ($action === 'submit') {
|
||||
if ($record === null || $record['status'] !== 'draft' || $record['requester_id'] !== $this->port->actorId()) {
|
||||
throw new RequisitionFailure('Submissão não autorizada.', 403);
|
||||
}
|
||||
$policy = $this->port->policy($record);
|
||||
$this->port->reserve($record);
|
||||
$result = $this->port->submitted($record, $policy, self::roles($record, $policy));
|
||||
} elseif ($action === 'decide') {
|
||||
$actor = $this->port->actorId();
|
||||
if ($record === null || $record['status'] !== 'pending' || $record['requester_id'] === $actor) {
|
||||
throw new RequisitionFailure('A decisão exige outra identidade autorizada.', 403);
|
||||
}
|
||||
$step = self::nextStep($record, $actor);
|
||||
if ($step === null) {
|
||||
throw new RequisitionFailure('A decisão exige outra identidade para cada etapa.', 403);
|
||||
}
|
||||
if (! in_array($input['decision'] ?? '', ['approved', 'rejected'], true)) {
|
||||
throw new RequisitionFailure('Decisão inválida.', 422);
|
||||
}
|
||||
$role = $step['role'];
|
||||
$this->port->authorize('step:'.$role, $record);
|
||||
$status = $input['decision'] === 'rejected' ? 'rejected' : (count(array_filter($record['steps'], fn ($s) => $s['decision'] === 'pending')) > 1 ? 'pending' : 'approved');
|
||||
$result = $this->port->decided($record, $step, $input['decision'], $input['comment'] ?? null, $status);
|
||||
if ($status === 'rejected') {
|
||||
$this->port->release($record);
|
||||
}
|
||||
} else {
|
||||
throw new RequisitionFailure('Operação desconhecida.', 404);
|
||||
}
|
||||
if ($role !== null) {
|
||||
$result['operation_role'] = $role;
|
||||
}
|
||||
if ($key !== null) {
|
||||
$this->port->remember($key, $hash, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function view(int $id): array
|
||||
{
|
||||
return $this->port->atomic(function () use ($id) {
|
||||
$record = $this->port->load($id);
|
||||
$this->port->authorize('view', $record);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Economic;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\RoundingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** The host supplies authorised eligibility/evaluations. This class never awards a contract. */
|
||||
final class ProposalScoring
|
||||
{
|
||||
/** @param list<array<string,mixed>> $rows
|
||||
* @return array{scores:array<int,int>,top_ids:list<int>}
|
||||
*/
|
||||
public function rank(array $rows, int $minimumEvaluators): array
|
||||
{
|
||||
if ($minimumEvaluators < 1) {
|
||||
throw new InvalidArgumentException('O mínimo de avaliadores deve ser positivo.');
|
||||
}
|
||||
$minimum = null;
|
||||
$days = null;
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_int($row['id']) || isset($ids[$row['id']])) {
|
||||
throw new InvalidArgumentException('As propostas devem ter referências distintas.');
|
||||
}
|
||||
$ids[$row['id']] = true;
|
||||
if (! $row['eligible']) {
|
||||
continue;
|
||||
}
|
||||
$weights = $row['weights'];
|
||||
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
|
||||
if (array_diff($keys, array_keys($weights)) !== [] || count($weights) !== 5 || count(array_filter($weights, fn ($weight) => is_int($weight) && $weight >= 0 && $weight <= 100)) !== 5
|
||||
|| array_sum($weights) !== 100 || ! is_int($row['subtotal_minor']) || $row['subtotal_minor'] < 1 || ! is_int($row['delivery_days']) || $row['delivery_days'] < 1
|
||||
|| $row['evaluation_count'] !== count($row['qualified_evaluations'])) {
|
||||
throw new InvalidArgumentException('Base de pontuação inconsistente.');
|
||||
}
|
||||
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
|
||||
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
|
||||
}
|
||||
$scores = [];
|
||||
$topIds = [];
|
||||
$best = null;
|
||||
foreach ($rows as $row) {
|
||||
if (! $row['eligible'] || $row['evaluation_count'] < $minimumEvaluators) {
|
||||
continue;
|
||||
}
|
||||
$weights = $row['weights'];
|
||||
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
|
||||
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
|
||||
foreach (['quality', 'history', 'payment'] as $criterion) {
|
||||
$sum = BigRational::zero();
|
||||
foreach ($row['qualified_evaluations'] as $evaluation) {
|
||||
$value = BigRational::of($evaluation[$criterion]);
|
||||
if ($value->isNegative() || $value->isGreaterThan(10)) {
|
||||
throw new InvalidArgumentException('Avaliação fora da escala de comparação.');
|
||||
}
|
||||
$sum = $sum->plus($value);
|
||||
}
|
||||
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
|
||||
}
|
||||
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
|
||||
$scores[$row['id']] = $minor;
|
||||
if ($best === null || $minor > $best) {
|
||||
$best = $minor;
|
||||
$topIds = [$row['id']];
|
||||
} elseif ($minor === $best) {
|
||||
$topIds[] = $row['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['scores' => $scores, 'top_ids' => $topIds];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Economic;
|
||||
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Unit discounts before VAT; no document, fiscal determination or persistence. */
|
||||
final class QuotationPricing
|
||||
{
|
||||
/** @return array{gross_unit_minor:int,discount_type:string,discount_value:string,net_unit_minor:int} */
|
||||
public function unit(string $price, string $type = 'none', string $value = '0'): array
|
||||
{
|
||||
$gross = BigDecimal::of($price)->multipliedBy(100)->toInt();
|
||||
$discount = BigDecimal::of($value);
|
||||
if ($gross < 1 || $discount->isNegative() || ! in_array($type, ['none', 'percent', 'amount'], true)
|
||||
|| ($type === 'none' && ! $discount->isZero()) || ($type === 'percent' && $discount->isGreaterThan(100))) {
|
||||
throw new InvalidArgumentException('Indique um desconto unitário válido: percentagem ou valor em MZN, antes do IVA.');
|
||||
}
|
||||
$reduction = match ($type) {
|
||||
'percent' => BigDecimal::of($gross)->multipliedBy($discount)->dividedBy(100, 4),
|
||||
'amount' => $discount->multipliedBy(100),
|
||||
default => BigDecimal::zero(),
|
||||
};
|
||||
$net = BigDecimal::of($gross)->minus($reduction)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
if ($net < 1) {
|
||||
throw new InvalidArgumentException('O preço unitário após desconto deve ser pelo menos 0,01 MZN.');
|
||||
}
|
||||
|
||||
return ['gross_unit_minor' => $gross, 'discount_type' => $type, 'discount_value' => (string) $discount->toScale(2), 'net_unit_minor' => $net];
|
||||
}
|
||||
}
|
||||
@@ -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.'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Internal;
|
||||
|
||||
final class RequisitionFailure extends \RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status = 409)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\Internal;
|
||||
|
||||
/** Host-owned identity, scope, persistence and effects. All mutations share one transaction. */
|
||||
interface RequisitionPort
|
||||
{
|
||||
public function atomic(callable $work): mixed;
|
||||
|
||||
public function actorId(): int;
|
||||
|
||||
/** @param array<string,mixed>|null $record */
|
||||
public function authorize(string $action, ?array $record): void;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function load(int $id): array;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function draft(array $data, ?int $id): array;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $record
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function policy(array $record): array;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $record
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function reserve(array $record): ?array;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $record
|
||||
* @param array<string,mixed> $policy
|
||||
* @param list<string> $roles
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function submitted(array $record, array $policy, array $roles): array;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $record
|
||||
* @param array<string,mixed> $step
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array;
|
||||
|
||||
/** @param array<string,mixed> $record */
|
||||
public function release(array $record): void;
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function replay(string $key, string $hash): ?array;
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
public function remember(string $key, string $hash, array $result): void;
|
||||
}
|
||||
Reference in New Issue
Block a user