This commit is contained in:
2026-11-02 20:27:11 +02:00
parent b44378b899
commit badaada043
300 changed files with 17458 additions and 1056 deletions
+42
View File
@@ -7,3 +7,45 @@ O Compras carrega estes artefactos em todos os ambientes, incluindo produção,
`runtime-manifest.json` identifica cada ficheiro distribuído por SHA-256. Conferir com `composer run runtime:check`. Actualizações devem vir da origem comum, passar os testes dos adaptadores e renovar o manifesto; não editar os artefactos directamente nem aplicar formatação que altere os bytes aceites.
O empacotamento não executa migrações, activa pagamentos, cria direitos ou transporta dados. As funcionalidades sintéticas ainda presentes nos núcleos conservam o seu âmbito até à implementação da respectiva operação comercial. Não distribuir configurações privadas ou bases de avaliação com o produto.
## Recepção de eventos para integração
`integration/bootstrap.php` carrega um módulo independente de Laravel, com PHP/PDO, para consumir `sgc.warehouse.fulfilment-feed.v1`. Não integra os18artefactos financeiros fixados nem altera a instalação do SGF. `WarehouseFeed` valida o contrato; `WarehouseInbox` guarda os factos e o cursor atomicamente; `WarehousePull` percorre páginas com limites e retoma pelo ponto persistido.
O host fornece uma ligação PDO SQLite/PostgreSQL em modo de excepções, a identificação estável da instalação de origem, empresa de origem, empresa local e geração do leitor. Estes vínculos devem vir da configuração autorizada do host, nunca de parâmetros livres recebidos do exterior. Os identificadores de empresa da origem e do destino são independentes. Diferentes leitores da mesma origem/empresa local partilham a deduplicação dos factos e conservam checkpoints separados. Uma geração nova de leitor permite reler uma origem após mudança de âmbito, preservando o checkpoint anterior; não representa outra conta de utilizador.
A instalação usa `WarehouseInbox::schema()` como entrada explícita para a migração do host. O construtor e o processamento não criam tabelas. Instalação, privilégios mínimos, backup, configuração de acesso e transporte autenticado pertencem ao procedimento do host; nenhum destes passos foi executado em instalações persistentes por este incremento.
Depois da preparação autorizada do destino, a ligação no host usa:
```php
require $packageDirectory.'/integration/bootstrap.php';
$inbox = new Tempo\Integration\WarehouseInbox(
$dedicatedPdo, $configuredSourceId, $sourceTenantId,
$destinationTenantId, $configuredReaderGeneration,
);
$pull = new Tempo\Integration\WarehousePull($inbox);
$result = $pull->run($fetchAuthenticatedPage, maxPages: 10, pageSize: 50);
```
`$fetchAuthenticatedPage` é uma função do host que recebe `(?string $cursor, int $limit)` e devolve a página JSON descodificada do endpoint configurado `/stock/integracao/entregas`, com a conta e contexto autorizados. Deve verificar origem, autenticação e resposta HTTP antes de devolver dados; o módulo não escolhe URLs, credenciais ou destinatários. Excepções interrompem a leitura e preservam as páginas já aceites. `has_more=true` no resultado indica continuação pendente pelo limite do lote, não conclusão do processamento.
Os eventos guardados são documentos históricos reutilizáveis e consultáveis por `fact($idempotencyKey)`. Não são comandos para lançar stock, aprovar compras ou facturar. Confirmações exigem a entrega original reconciliada e não criam outra saída. O SHA-256 confere o conteúdo emitido pelo produtor, sem substituir a autenticação do canal. O adaptador não cria projecções de negócio nem confirmações funcionais no sistema de origem. Testes e provas em `tests/Unit/WarehouseInboxTest.php`, `tests/Feature/WarehouseExchangeTest.php` e `scripts/work-context-pg-check.php --inbox`, só com dados sintéticos/base descartável.
### Consulta HTTP autenticada
`WarehouseHttpSource` implementa a função de consulta para `WarehousePull`, usando PHP cURL e a sessão já autorizada na origem. O host fornece a origem HTTPS, a empresa e uma função que obtém o cabeçalho Cookie da sessão por canal protegido. A sessão deve ter o contexto Compras internas seleccionado quando aplicável; as mesmas verificações de conta, perfis, empresa e contexto da rota continuam activas. O cliente não faz login, selecciona contexto, cria contas, outorga permissões ou armazena credenciais.
```php
$source = new Tempo\Integration\WarehouseHttpSource(
$configuredHttpsOrigin, $sourceTenantId, $sessionCookieProvider,
);
$result = $pull->run($source, maxPages: 10, pageSize: 50);
```
O cliente consulta apenas GET /stock/integracao/entregas dentro da origem configurada. Cursor/limite são codificados como parâmetros, e as credenciais seguem apenas no cabeçalho, nunca na URL. A origem não admite userinfo, query, fragmento, escapes de caminho ou travessia. Não segue redireccionamentos. HTTPS confere certificado/hostname; a opção allowLoopbackHttp=true só permite HTTP em127.0.0.1 ou[::1] para desenvolvimento explícito, sem proxy nesse percurso local. O ensaio8005 usa esta excepção; não representa prova de certificados TLS de um destino comercial.
Há limites técnicos configuráveis no construtor:15segundos totais,5de ligação e8MiB de resposta por defeito; tamanho é limitado também após descompressão. A resposta exige HTTP200, JSON e contrato/empresa/hash válidos. A função de credenciais é consultada novamente em cada pedido e a informação de depuração oculta-a. WarehouseTransportFailure oferece category/httpStatus/retryable(), sem corpo remoto, cookies ou erro original do fornecedor; falhas de acesso/cursor não desencadeiam reset, nova conta ou recuperação automática de autoridade. retryable é um diagnóstico técnico, não uma tentativa automática.
A instalação permanente, configuração pela administração do host, armazenamento protegido da sessão, renovação após expiração e uma eventual autenticação técnica restrita ainda dependem dos seus percursos próprios. Esta implementação não oferece tokens permanentes, login automático, serviço agendado ou projecção de stock/contabilidade no destinatário. Registar essas lacunas ao entregar; preservar os perfis/aprovações e a instalação real do SGF. Prova local: scripts/warehouse-http-source-http.mjs obtém a sessão sintética existente e passa-a por stdin privado ao processo PHP; o receptor usa só SQLite em memória e a prova não grava a credencial.
+7
View File
@@ -0,0 +1,7 @@
<?php
require_once __DIR__.'/src/WarehouseFeed.php';
require_once __DIR__.'/src/WarehouseInbox.php';
require_once __DIR__.'/src/WarehousePull.php';
require_once __DIR__.'/src/WarehouseTransportFailure.php';
require_once __DIR__.'/src/WarehouseHttpSource.php';
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Tempo\Integration;
use InvalidArgumentException;
/** Validates the existing v1 wire contract, independently of Laravel. */
final class WarehouseFeed
{
/** @param array<string,mixed> $page */
public static function validate(array $page, int $tenant): void
{
self::check(($page['schema'] ?? null) === 'sgc.warehouse.fulfilment-feed.v1', 'Versão de feed incompatível.');
self::check(isset($page['items']) && is_array($page['items']) && array_is_list($page['items']) && count($page['items']) <= 100, 'Página inválida.');
self::check(is_bool($page['has_more'] ?? null), 'Indicador de paginação inválido.');
self::check(is_string($page['next_cursor'] ?? null) && strlen($page['next_cursor']) > 0 && strlen($page['next_cursor']) <= 4096, 'Cursor inválido.');
self::check(! $page['has_more'] || count($page['items']) > 0, 'Uma página vazia não pode prometer continuação.');
$previous = 0;
foreach ($page['items'] as $event) {
self::check(is_array($event), 'Evento inválido.');
self::event($event, $tenant);
self::check($event['event_id'] > $previous, 'Eventos fora de ordem ou repetidos na página.');
$previous = $event['event_id'];
}
}
/** @param array<string,mixed> $event */
private static function event(array $event, int $tenant): void
{
self::check(($event['schema'] ?? null) === 'sgc.warehouse.fulfilment-event.v1' && ($event['tenant_id'] ?? null) === $tenant, 'Evento de outra versão ou empresa.');
self::positive($event['event_id'] ?? null);
self::positive($event['event_version'] ?? null);
self::check(($event['idempotency_key'] ?? null) === 'sgc:'.$tenant.':warehouse-event:'.$event['event_id'], 'Identidade do evento inválida.');
self::check(in_array($event['action'] ?? null, ['deliver', 'receive'], true), 'Acção incompatível.');
self::check(is_string($event['occurred_at'] ?? null) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/D', $event['occurred_at']) === 1, 'Data do evento inválida.');
$date = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', $event['occurred_at']);
$errors = \DateTimeImmutable::getLastErrors();
self::check($date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)), 'Data do evento inexistente.');
self::actor($event['actor'] ?? null);
$request = $event['request'] ?? null;
self::check(is_array($request), 'Requisição inválida.');
self::positive($request['id'] ?? null);
self::check(($request['reference'] ?? null) === 'RA-'.$request['id'] && ($request['document_type'] ?? null) === 'internal_warehouse_request', 'Documento de origem inválido.');
self::check(array_key_exists('work_context_key', $request) && in_array($request['work_context_key'], ['procurement.internal', null], true), 'Contexto incompatível.');
self::actor($request['requester'] ?? null);
self::registry($request['warehouse'] ?? null);
self::registry($request['cost_center'] ?? null);
self::check(is_string($request['destination'] ?? null), 'Destino inválido.');
self::check(isset($event['lines']) && is_array($event['lines']) && array_is_list($event['lines']) && count($event['lines']) <= 50, 'Linhas inválidas.');
self::check(array_key_exists('delivery_event_key', $event), 'Referência à entrega em falta.');
if ($event['action'] === 'deliver') {
self::check(count($event['lines']) > 0 && $event['delivery_event_key'] === null, 'Entrega sem linhas ou com referência indevida.');
$indexes = [];
foreach ($event['lines'] as $line) {
self::check(is_array($line) && is_int($line['request_line_index'] ?? null) && $line['request_line_index'] >= 0, 'Linha de origem inválida.');
self::check(! isset($indexes[$line['request_line_index']]), 'Linha de origem repetida.');
$indexes[$line['request_line_index']] = true;
self::registry($line['item'] ?? null);
self::check(is_string($line['item']['unit'] ?? null) && is_string($line['destination'] ?? null), 'Unidade ou destino em falta.');
self::positive($line['quantity_milli'] ?? null);
self::positive($line['stock_operation_id'] ?? null);
self::check(in_array($line['use'] ?? null, ['internal', 'equipment'], true) && array_key_exists('equipment', $line), 'Utilização inválida.');
if ($line['use'] === 'equipment') {
self::check(is_array($line['equipment']), 'Equipamento em falta.');
self::positive($line['equipment']['id'] ?? null);
self::check(is_string($line['equipment']['code'] ?? null) && is_string($line['equipment']['description'] ?? null), 'Equipamento inválido.');
} else {
self::check($line['equipment'] === null, 'Equipamento incompatível com utilização interna.');
}
}
} else {
self::check($event['lines'] === [] && is_string($event['delivery_event_key'])
&& preg_match('/^sgc:'.preg_quote((string) $tenant, '/').':warehouse-event:[1-9][0-9]*$/D', $event['delivery_event_key']) === 1, 'Confirmação sem referência válida ou com nova saída.');
}
$payload = $event;
unset($payload['payload_sha256']);
self::check(is_string($event['payload_sha256'] ?? null) && hash_equals(hash('sha256', self::json($payload)), $event['payload_sha256']), 'Conteúdo do evento alterado.');
}
/** @param array<mixed> $value */
public static function json(array $value): string
{
return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private static function positive(mixed $value): void
{
self::check(is_int($value) && $value > 0, 'Identificador ou quantidade inválidos.');
}
private static function actor(mixed $value): void
{
self::check(is_array($value), 'Interveniente em falta.');
self::positive($value['id'] ?? null);
self::check(is_string($value['name'] ?? null), 'Nome do interveniente em falta.');
}
private static function registry(mixed $value): void
{
self::actor($value);
self::check(is_string($value['code'] ?? null), 'Código do cadastro em falta.');
}
private static function check(bool $valid, string $message): void
{
if (! $valid) {
throw new InvalidArgumentException($message);
}
}
}
@@ -0,0 +1,138 @@
<?php
namespace Tempo\Integration;
use Closure;
use InvalidArgumentException;
use SensitiveParameter;
use Throwable;
/** Fixed, read-only endpoint using an existing authorised source session. */
final class WarehouseHttpSource
{
private string $endpoint;
private bool $directLoopback;
/** @var Closure():string */
private Closure $sessionCookie;
/** @param callable():string $sessionCookie */
public function __construct(
#[SensitiveParameter] string $baseUrl,
private int $sourceTenant,
#[SensitiveParameter] callable $sessionCookie,
bool $allowLoopbackHttp = false,
private int $timeoutMs = 15000,
private int $connectTimeoutMs = 5000,
private int $maxResponseBytes = 8388608,
) {
$parts = parse_url($baseUrl);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user']) || isset($parts['pass'])
|| isset($parts['query']) || isset($parts['fragment'])
|| preg_match('/[\x00-\x20\x7f\\\\]/', $baseUrl)
|| ! preg_match('!^(?:/[A-Za-z0-9._~-]+)*/?$!D', $parts['path'] ?? '')
|| in_array('..', explode('/', $parts['path'] ?? ''), true)
|| in_array('.', explode('/', $parts['path'] ?? ''), true)
|| ! ($parts['scheme'] === 'https' || ($allowLoopbackHttp && $parts['scheme'] === 'http' && in_array(strtolower($parts['host']), ['127.0.0.1', '[::1]'], true)))) {
throw new InvalidArgumentException('Configure uma origem HTTPS sem credenciais, query ou fragmento. HTTP só é permitido explicitamente em loopback.');
}
if ($sourceTenant <= 0 || $timeoutMs < 100 || $timeoutMs > 120000 || $connectTimeoutMs < 100 || $connectTimeoutMs > $timeoutMs
|| $maxResponseBytes < 1024 || $maxResponseBytes > 16777216) {
throw new InvalidArgumentException('Âmbito ou limites de transporte inválidos.');
}
if (! extension_loaded('curl') || ! defined('CURLOPT_PROTOCOLS_STR')) {
throw new InvalidArgumentException('O transporte de integração exige PHP cURL com selecção de protocolos.');
}
$this->endpoint = rtrim($baseUrl, '/').'/stock/integracao/entregas';
$provider = Closure::fromCallable($sessionCookie);
$this->sessionCookie = static fn (): string => $provider();
$this->directLoopback = in_array(strtolower($parts['host']), ['127.0.0.1', '[::1]'], true);
}
/** @return array<string,mixed> */
public function __invoke(?string $cursor, int $limit): array
{
if ($limit < 1 || $limit > 100 || ($cursor !== null && (strlen($cursor) === 0 || strlen($cursor) > 4096))) {
throw new InvalidArgumentException('Cursor ou limite de leitura inválidos.');
}
try {
$cookie = ($this->sessionCookie)();
} catch (Throwable) {
throw new WarehouseTransportFailure('credentials');
}
if (strlen($cookie) === 0 || strlen($cookie) > 16384 || preg_match('/[^\x20-\x7e]/', $cookie)) {
throw new WarehouseTransportFailure('credentials');
}
$query = ['limit' => $limit];
if ($cursor !== null) {
$query['cursor'] = $cursor;
}
$handle = curl_init($this->endpoint.'?'.http_build_query($query, encoding_type: PHP_QUERY_RFC3986));
if ($handle === false) {
throw new WarehouseTransportFailure('connection');
}
$body = '';
$oversized = false;
curl_setopt_array($handle, [
CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0,
CURLOPT_PROTOCOLS_STR => 'http,https', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs, CURLOPT_TIMEOUT_MS => $this->timeoutMs,
CURLOPT_ENCODING => '', CURLOPT_HTTPHEADER => ['Accept: application/json', 'Cookie: '.$cookie],
CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body, &$oversized): int {
if (strlen($body) + strlen($chunk) > $this->maxResponseBytes) {
$oversized = true;
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
]);
if ($this->directLoopback) {
curl_setopt($handle, CURLOPT_PROXY, '');
}
$ok = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
if ($oversized) {
throw new WarehouseTransportFailure('response_size', $status ?: null);
}
if ($ok === false) {
throw new WarehouseTransportFailure(curl_errno($handle) === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'connection', $status ?: null);
}
if ($status !== 200) {
$category = match (true) {
$status >= 300 && $status < 400 => 'redirect',
in_array($status, [401, 419], true) => 'authentication',
$status === 403 => 'authorization',
in_array($status, [409, 422], true) => 'cursor',
$status === 429 || $status >= 500 => 'remote_unavailable',
default => 'http',
};
throw new WarehouseTransportFailure($category, $status);
}
$type = curl_getinfo($handle, CURLINFO_CONTENT_TYPE);
if (! is_string($type) || ! preg_match('~^application/json(?:\s*;.*)?$~iD', $type)) {
throw new WarehouseTransportFailure('response_contract', 200);
}
try {
$page = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
if (! is_array($page)) {
throw new InvalidArgumentException;
}
WarehouseFeed::validate($page, $this->sourceTenant);
} catch (Throwable) {
throw new WarehouseTransportFailure('response_contract', 200);
}
return $page;
}
/** @return array<string,int|string> */
public function __debugInfo(): array
{
return ['endpoint' => $this->endpoint, 'source_tenant' => $this->sourceTenant, 'session' => '[protected]'];
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Tempo\Integration;
use InvalidArgumentException;
use PDO;
use RuntimeException;
use Throwable;
/** Durable receiving journal, not a stock/approval engine or remote transport. */
final class WarehouseInbox
{
/** @var array<string,int|string> */
private array $scope;
public function __construct(private PDO $db, string $source, int $sourceTenant, int $destinationTenant, string $consumer)
{
if (! in_array($db->getAttribute(PDO::ATTR_DRIVER_NAME), ['sqlite', 'pgsql'], true) || $db->getAttribute(PDO::ATTR_ERRMODE) !== PDO::ERRMODE_EXCEPTION
|| ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/D', $source)
|| ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/D', $consumer)
|| $sourceTenant <= 0 || $destinationTenant <= 0) {
throw new InvalidArgumentException('Ligação ou âmbito de integração inválidos.');
}
$this->scope = ['source_id' => $source, 'source_tenant_id' => $sourceTenant, 'destination_tenant_id' => $destinationTenant, 'consumer_id' => $consumer];
}
/** Explicit host migration input; construction/consumption never installs schema.
* @return list<string>
*/
public static function schema(): array
{
return ['CREATE TABLE sgc_exchange_streams (source_id VARCHAR(100) NOT NULL, source_tenant_id BIGINT NOT NULL, destination_tenant_id BIGINT NOT NULL, consumer_id VARCHAR(100) NOT NULL, version BIGINT NOT NULL, cursor TEXT NULL, last_event_id BIGINT NOT NULL, last_page_sha256 VARCHAR(64) NULL, PRIMARY KEY (source_id, source_tenant_id, destination_tenant_id, consumer_id))',
'CREATE TABLE sgc_exchange_facts (source_id VARCHAR(100) NOT NULL, source_tenant_id BIGINT NOT NULL, destination_tenant_id BIGINT NOT NULL, event_key VARCHAR(200) NOT NULL, event_id BIGINT NOT NULL, request_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL, delivery_event_key VARCHAR(200) NULL, payload_sha256 VARCHAR(64) NOT NULL, payload TEXT NOT NULL, recorded_at VARCHAR(40) NOT NULL, PRIMARY KEY (source_id, source_tenant_id, destination_tenant_id, event_key), UNIQUE (source_id, source_tenant_id, destination_tenant_id, delivery_event_key))'];
}
/** @return array{version:int,cursor:?string,last_event_id:int} */
public function checkpoint(): array
{
$row = $this->stream(false);
return ['version' => $row === false ? 0 : (int) $row['version'], 'cursor' => $row === false ? null : $row['cursor'], 'last_event_id' => $row === false ? 0 : (int) $row['last_event_id']];
}
/** @param array<string,mixed> $page
* @return array{inserted:int,replayed:bool,version:int,cursor:string,has_more:bool}
*/
public function accept(array $page, int $expectedVersion, ?string $requestedCursor): array
{
WarehouseFeed::validate($page, (int) $this->scope['source_tenant_id']);
if ($expectedVersion < 0 || $this->db->inTransaction()) {
throw new InvalidArgumentException('Versão inválida ou transacção externa activa.');
}
$digest = hash('sha256', WarehouseFeed::json($page));
$this->db->beginTransaction();
try {
$this->execute('INSERT INTO sgc_exchange_streams (source_id, source_tenant_id, destination_tenant_id, consumer_id, version, cursor, last_event_id) VALUES (:source_id, :source_tenant_id, :destination_tenant_id, :consumer_id, 0, NULL, 0) ON CONFLICT DO NOTHING', $this->scope);
$stream = $this->stream(true);
if ($stream === false) {
throw new RuntimeException('Ponto de leitura indisponível.');
}
if ((int) $stream['version'] !== $expectedVersion) {
if ((int) $stream['version'] === $expectedVersion + 1 && $stream['last_page_sha256'] === $digest) {
$this->db->commit();
return ['inserted' => 0, 'replayed' => true, 'version' => (int) $stream['version'], 'cursor' => $stream['cursor'], 'has_more' => $page['has_more']];
}
throw new RuntimeException('A leitura avançou noutro executor. Releia o ponto guardado.');
}
if ($stream['cursor'] !== $requestedCursor) {
throw new RuntimeException('A página não corresponde ao ponto de leitura solicitado.');
}
$last = (int) $stream['last_event_id'];
$inserted = 0;
foreach ($page['items'] as $event) {
if ($event['event_id'] <= $last) {
throw new RuntimeException('Página antiga: o ponto guardado não pode recuar.');
}
$existing = $this->fact($event['idempotency_key']);
if ($existing !== null && $existing['payload_sha256'] !== $event['payload_sha256']) {
throw new RuntimeException('A mesma identidade tem conteúdo diferente. Reconciliação necessária.');
}
if ($event['action'] === 'receive') {
$delivery = $this->fact($event['delivery_event_key']);
if ($delivery === null || $delivery['action'] !== 'deliver' || $delivery['request'] !== $event['request'] || $delivery['event_id'] >= $event['event_id']) {
throw new RuntimeException('Confirmação sem entrega de origem reconciliada.');
}
}
if ($existing === null) {
$bindings = $this->factScope();
$bindings += ['event_key' => $event['idempotency_key'], 'event_id' => $event['event_id'], 'request_id' => $event['request']['id'], 'action' => $event['action'], 'delivery_event_key' => $event['delivery_event_key'],
'payload_sha256' => $event['payload_sha256'], 'payload' => WarehouseFeed::json($event), 'recorded_at' => gmdate('c')];
$written = $this->execute('INSERT INTO sgc_exchange_facts (source_id, source_tenant_id, destination_tenant_id, event_key, event_id, request_id, action, delivery_event_key, payload_sha256, payload, recorded_at) VALUES (:source_id, :source_tenant_id, :destination_tenant_id, :event_key, :event_id, :request_id, :action, :delivery_event_key, :payload_sha256, :payload, :recorded_at) ON CONFLICT DO NOTHING', $bindings);
// Another stream may have inserted this identity concurrently.
$saved = $this->fact($event['idempotency_key']);
if ($saved === null || $saved['payload_sha256'] !== $event['payload_sha256']) {
throw new RuntimeException('Conflito de conteúdo entre consumidores.');
}
$inserted += $written->rowCount();
}
$last = $event['event_id'];
}
$statement = $this->execute('UPDATE sgc_exchange_streams SET version = version + 1, cursor = :cursor, last_event_id = :last_event_id, last_page_sha256 = :digest WHERE '.$this->streamWhere().' AND version = :expected_version',
$this->scope + ['cursor' => $page['next_cursor'], 'last_event_id' => $last, 'digest' => $digest, 'expected_version' => $expectedVersion]);
if ($statement->rowCount() !== 1) {
throw new RuntimeException('O ponto de leitura mudou durante a recepção.');
}
$this->db->commit();
return ['inserted' => $inserted, 'replayed' => false, 'version' => $expectedVersion + 1, 'cursor' => $page['next_cursor'], 'has_more' => $page['has_more']];
} catch (Throwable $e) {
$this->rollbackOpenTransaction();
throw $e;
}
}
/** @return array<string,mixed>|null */
public function fact(string $key): ?array
{
$row = $this->execute('SELECT payload FROM sgc_exchange_facts WHERE source_id = :source_id AND source_tenant_id = :source_tenant_id AND destination_tenant_id = :destination_tenant_id AND event_key = :event_key', $this->factScope() + ['event_key' => $key])->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : json_decode($row['payload'], true, 512, JSON_THROW_ON_ERROR);
}
/** @return array<string,mixed>|false */
private function stream(bool $lock): array|false
{
return $this->execute('SELECT version, cursor, last_event_id, last_page_sha256 FROM sgc_exchange_streams WHERE '.$this->streamWhere().($lock && $this->db->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : ''), $this->scope)->fetch(PDO::FETCH_ASSOC);
}
/** @return literal-string */
private function streamWhere(): string
{
return 'source_id = :source_id AND source_tenant_id = :source_tenant_id AND destination_tenant_id = :destination_tenant_id AND consumer_id = :consumer_id';
}
/** @return array<string,int|string> */
private function factScope(): array
{
return array_diff_key($this->scope, ['consumer_id' => true]);
}
/** @param literal-string $sql
* @param array<string,int|string|null> $bindings
*/
private function execute(string $sql, array $bindings): \PDOStatement
{
$statement = $this->db->prepare($sql);
$statement->execute($bindings);
return $statement;
}
private function rollbackOpenTransaction(): void
{
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace Tempo\Integration;
use InvalidArgumentException;
/** Bounded orchestration; authenticated transport/host configuration stay outside. */
final class WarehousePull
{
public function __construct(private WarehouseInbox $inbox) {}
/** @param callable(?string,int):array<string,mixed> $fetch
* @return array{pages:int,inserted:int,replayed_pages:int,has_more:bool,checkpoint:array{version:int,cursor:?string,last_event_id:int}}
*/
public function run(callable $fetch, int $maxPages = 1, int $pageSize = 50): array
{
if ($maxPages < 1 || $maxPages > 100 || $pageSize < 1 || $pageSize > 100) {
throw new InvalidArgumentException('Limites de leitura inválidos.');
}
$pages = 0;
$inserted = 0;
$replayed = 0;
do {
$checkpoint = $this->inbox->checkpoint();
$page = $fetch($checkpoint['cursor'], $pageSize);
$result = $this->inbox->accept($page, $checkpoint['version'], $checkpoint['cursor']);
$pages++;
$inserted += $result['inserted'];
$replayed += $result['replayed'] ? 1 : 0;
} while ($result['has_more'] && $pages < $maxPages);
return ['pages' => $pages, 'inserted' => $inserted, 'replayed_pages' => $replayed, 'has_more' => $result['has_more'], 'checkpoint' => $this->inbox->checkpoint()];
}
}
@@ -0,0 +1,30 @@
<?php
namespace Tempo\Integration;
use RuntimeException;
/** Sanitised transport diagnosis; never contains credentials or response bodies. */
final class WarehouseTransportFailure extends RuntimeException
{
public function __construct(public readonly string $category, public readonly ?int $httpStatus = null)
{
parent::__construct(match ($category) {
'credentials' => 'A sessão autorizada está indisponível ou é inválida.',
'authentication' => 'A origem exige uma sessão autenticada válida.',
'authorization' => 'A origem recusou o acesso aos eventos neste âmbito.',
'redirect' => 'A origem devolveu um redireccionamento. Confirme a ligação e a sessão.',
'cursor' => 'A origem recusou o cursor ou os parâmetros da leitura.',
'timeout' => 'A consulta à origem excedeu o tempo permitido.',
'remote_unavailable' => 'A origem está temporariamente indisponível.',
'response_size' => 'A resposta da origem excedeu o limite de leitura.',
'response_contract' => 'A resposta da origem não corresponde ao contrato de entregas.',
default => 'Não foi possível consultar a origem de integração.',
});
}
public function retryable(): bool
{
return in_array($this->category, ['timeout', 'connection', 'remote_unavailable'], true);
}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/IssueMovementWriter.php';
+8
View File
@@ -0,0 +1,8 @@
{
"id": "tempo-inventory-issue",
"version": "0.1.0",
"files": {
"bootstrap.php": "3e6c7c698279f3ffb865131e79b3f89e5ec3e4aa9114782df62e17712f48df6b",
"src/IssueMovementWriter.php": "36d59bce53b8447516399974fc7d08beaa5d92b7b45a416014bd855e03fdf409"
}
}
@@ -0,0 +1,40 @@
<?php
namespace Tempo\Inventory;
use Brick\Math\BigDecimal;
use Closure;
use Illuminate\Database\ConnectionInterface;
/** One physical issue, within the host's authorised transaction. No independent balance. */
final class IssueMovementWriter
{
public function __construct(private ConnectionInterface $db,private string $schema,private int $tenant,private int $actor,private Closure $authorize)
{
if(!preg_match('/^[a-z][a-z0-9_]{0,62}$/D',$schema)||$tenant<1||$actor<1)throw new \InvalidArgumentException('Invalid issue context');
}
private function host(): void {if($this->db->transactionLevel()<1)throw new \LogicException('Issue requires the host transaction');($this->authorize)();}
private function operation(int $id,bool $lock=false): object
{
$q=$this->db->table($this->schema.'.stock_operations')->where(['tenant_id'=>$this->tenant,'id'=>$id]);if($lock)$q->lockForUpdate();
$o=$q->first();abort_unless($o,403);abort_unless($o->kind==='issue'&&(int)$o->created_by===$this->actor&&BigDecimal::of((string)$o->quantity_milli)->isPositive(),409,'Operação incompatível com saída física.');return $o;
}
public function preview(int $id): string {$this->host();return hash('sha256',json_encode((array)$this->operation($id),JSON_THROW_ON_ERROR));}
public function write(int $id,string $hash): array
{
$this->host();$o=$this->operation($id);
// Lock the actual warehouse, including a previously empty book. Hosts keep their company lock too.
$warehouse=$this->db->table($this->schema.'.warehouses')->where(['tenant_id'=>$this->tenant,'id'=>$o->warehouse_id])->lockForUpdate()->first();abort_unless($warehouse,403);
abort_unless($this->db->table($this->schema.'.stock_items')->where(['tenant_id'=>$this->tenant,'id'=>$o->stock_item_id])->lockForUpdate()->first(),403);
$o=$this->operation($id,true);abort_unless(hash_equals($hash,hash('sha256',json_encode((array)$o,JSON_THROW_ON_ERROR))),409,'A operação mudou. Faça nova conferência.');
$q=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'warehouse_id'=>$o->warehouse_id]);$old=$q->first();
$negative=(string)BigDecimal::of((string)$o->quantity_milli)->negated();
if($old){$s=json_decode($old->snapshot,true,512,JSON_THROW_ON_ERROR);abort_unless(($s['inventory_operation_hash']??null)===$hash&&(string)$old->quantity_milli===$negative&&(int)$old->stock_item_id===(int)$o->stock_item_id,409,'Saída existente incompatível; exige reconciliação.');return ['movement_id'=>(int)$old->id,'replayed'=>true];}
$book=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->selectRaw('coalesce(sum(quantity_milli),0) AS quantity,coalesce(max(id),0) AS revision')->first();
abort_unless((string)$o->ledger_version===(string)$book->revision,409,'O livro mudou. Faça nova conferência.');
abort_unless(BigDecimal::of((string)$book->quantity)->isGreaterThanOrEqualTo((string)$o->quantity_milli),409,'Stock físico insuficiente.');
$snapshot=json_decode($o->snapshot,true,512,JSON_THROW_ON_ERROR)+['reference'=>$o->reference,'reason'=>$o->reason,'inventory_operation_hash'=>$hash];
$movement=$this->db->table($this->schema.'.stock_movements')->insertGetId(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id,
'equipment_id'=>$o->equipment_id??null,'spare_part_id'=>$o->spare_part_id??null,'moved_by'=>$this->actor,'movement_type'=>'issue','quantity_milli'=>$negative,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'moved_at'=>now()]);
return ['movement_id'=>(int)$movement,'replayed'=>false];
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/InventoryReader.php';
require_once __DIR__.'/src/InventoryReadSession.php';
+9
View File
@@ -0,0 +1,9 @@
{
"id": "tempo-inventory-read",
"version": "0.1.0",
"files": {
"bootstrap.php": "65cba0924869ba93afa00a4786b361ad71d5c2e67bd021b3d1fda172b1190476",
"src/InventoryReader.php": "9b0591600da07719d655324ccf0b740f06de07db2806540684c9730d048e44b6",
"src/InventoryReadSession.php": "e5ea1ec6771e34478367218d73ba1d1d90464ddc87d1c5fa3d679622ffb81111"
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tempo\Inventory;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Crypt;
/** Shared read transaction and refresh-on-change protocol; hosts retain authorisation. */
final class InventoryReadSession
{
public function __construct(private ConnectionInterface $db) {}
public function read(array $binding, string $fingerprint, array $filters, ?string $token = null): array
{
return $this->db->transaction(function () use ($binding,$fingerprint,$filters,$token) {
abort_unless($this->db->transactionLevel() === 1,503,'Consulta de stock exige transacção de leitura própria.');
$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$this->db->statement("SET LOCAL statement_timeout='3s'");
$reader = new InventoryReader($this->db,$binding['schema'],$binding['source_tenant_id'],$binding['warehouse_ids']);
try { $revision = $reader->revision(); }
catch (\UnexpectedValueException) { abort(503,'Fonte de stock incompatível.'); }
$observed = time();
if ($token !== null) {
try { $cut = json_decode(Crypt::decryptString($token),true,512,JSON_THROW_ON_ERROR); }
catch (\Throwable) { abort(409,'A referência de consulta é inválida. Actualize a consulta.'); }
abort_unless(($cut['fingerprint'] ?? null) === $fingerprint && ($cut['revision'] ?? null) === $revision
&& is_int($cut['observed'] ?? null) && $cut['observed'] <= time() && $cut['observed'] >= time()-600,409,'A fonte ou o contexto mudou. Actualize a consulta antes de continuar.');
$observed = $cut['observed'];
}
$snapshot = Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR));
return $reader->page($filters) + ['status'=>'available','synthetic_only'=>true,'view'=>$filters['view'],'snapshot'=>$snapshot,
'observed_at'=>gmdate('c',$observed),'ledger_revision'=>$revision,'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],
'notice'=>'Consulta sintética. Disponível indeterminado; pares sem movimentos não são saldos zero. Sem movimentos ou custos nesta interface.'];
});
}
}
@@ -0,0 +1,75 @@
<?php
namespace Tempo\Inventory;
use Illuminate\Database\ConnectionInterface;
/** Read-only projection of the existing Purchasing physical ledger, not a second writer. */
final class InventoryReader
{
public function __construct(private ConnectionInterface $db, private string $schema, private int $tenant, private array $warehouses)
{
if (!preg_match('/^[a-z][a-z0-9_]{0,62}$/D', $schema) || $tenant < 1 || !$warehouses || count($warehouses) > 1000
|| array_filter($warehouses, fn ($id) => !is_int($id) || $id < 1)) throw new \InvalidArgumentException('Invalid inventory source scope');
}
private function table(string $name) { return $this->db->table($this->schema.'.'.$name); }
private function movements()
{
return $this->table('stock_movements')->where('stock_movements.tenant_id', $this->tenant)->whereIn('warehouse_id', $this->warehouses);
}
/** Must be called inside the host's read-only repeatable-read transaction. */
public function revision(): array
{
$ledger = $this->movements()->selectRaw('count(*)::text AS count,coalesce(max(id),0)::text AS last')->first();
// Metadata is hashed in SQL, not materialised as a whole catalogue in PHP.
$items = $this->table('stock_items')->where('tenant_id', $this->tenant)
->selectRaw("md5(coalesce(string_agg(jsonb_build_array(id,code,name,unit)::text, '' ORDER BY id),'')) AS hash")->first();
$warehouses = $this->table('warehouses')->where('tenant_id', $this->tenant)->whereIn('id', $this->warehouses)
->selectRaw("count(*) AS count,md5(coalesce(string_agg(jsonb_build_array(id,code,name)::text, '' ORDER BY id),'')) AS hash")->first();
if ((int) $warehouses->count !== count(array_unique($this->warehouses))) throw new \UnexpectedValueException('Unmapped inventory warehouse');
return ['ledger_count' => $ledger->count, 'ledger_last' => $ledger->last, 'catalogue' => $items->hash, 'warehouses' => $warehouses->hash];
}
public function page(array $filters): array
{
$view = $filters['view']; $warehouse = $filters['warehouse_id'] ?? null;
if ($warehouse !== null && !in_array($warehouse, $this->warehouses, true)) throw new \DomainException('Warehouse outside authorised source');
if ($view === 'items') {
$q = $this->table('stock_items')->where('tenant_id', $this->tenant)->select(['id','code','name','unit'])->orderBy('id');
} elseif ($view === 'warehouses') {
$q = $this->table('warehouses')->where('tenant_id', $this->tenant)->whereIn('id', $this->warehouses)->select(['id','code','name'])->orderBy('id');
} elseif (in_array($view, ['balances','movements'], true)) {
$q = $this->movements()
->join($this->schema.'.stock_items AS i', fn ($j) => $j->on('i.id','=','stock_movements.stock_item_id')->on('i.tenant_id','=','stock_movements.tenant_id'))
->join($this->schema.'.warehouses AS w', fn ($j) => $j->on('w.id','=','stock_movements.warehouse_id')->on('w.tenant_id','=','stock_movements.tenant_id'));
if ($warehouse !== null) $q->where('stock_movements.warehouse_id', $warehouse);
if ($view === 'balances') {
$q->selectRaw('stock_movements.stock_item_id,stock_movements.warehouse_id,i.code,i.name,i.unit,w.code AS warehouse_code,w.name AS warehouse_name,SUM(stock_movements.quantity_milli)::text AS physical_milli,MAX(stock_movements.id)::text AS ledger_version')
->groupBy('stock_movements.stock_item_id','stock_movements.warehouse_id','i.code','i.name','i.unit','w.code','w.name')
->orderBy('stock_movements.stock_item_id')->orderBy('stock_movements.warehouse_id');
} else {
$q->select(['stock_movements.id','stock_movements.stock_item_id','stock_movements.warehouse_id','i.code','i.name','i.unit','w.code AS warehouse_code','w.name AS warehouse_name',
'stock_movements.movement_type','stock_movements.moved_at'])
->selectRaw('stock_movements.quantity_milli::text AS quantity_milli')
// Only safe provenance; never return the raw document/financial snapshot.
->selectRaw("stock_movements.snapshot->>'reference' AS source_reference")
->orderBy('stock_movements.id');
}
} else throw new \InvalidArgumentException('Unsupported inventory view');
$search = $filters['search'] ?? '';
if ($search !== '') {
$text = '%'.str_replace(['\\','%','_'], ['\\\\','\\%','\\_'], $search).'%';
$prefix = in_array($view, ['items','warehouses'], true) ? '' : 'i.';
$q->where(fn ($s) => $s->where($prefix.'code','ilike',$text)->orWhere($prefix.'name','ilike',$text));
}
$total = (int) $this->db->query()->fromSub((clone $q)->reorder(), 'inventory_rows')->count();
$perPage = $filters['per_page']; $page = $filters['page'];
$rows = $q->limit($perPage)->offset(($page-1)*$perPage)->get()->map(fn ($r) => (array) $r)->all();
if ($view === 'balances') foreach ($rows as &$row) {
$row += ['reserved_milli' => null, 'blocked_milli' => null, 'available_milli' => null];
}
return ['rows' => $rows, 'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage, 'last_page' => max(1, (int) ceil($total/$perPage))]];
}
}
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/DeliveryReturnWriter.php';
@@ -0,0 +1,8 @@
{
"id": "tempo-inventory-return",
"version": "0.1.0",
"files": {
"bootstrap.php": "f6735623101377038cde58085d5f00e030249066bc6a67f30b718caab73288be",
"src/DeliveryReturnWriter.php": "a7c65169a5c1f3510f9a2762cbf4163ee4cc81b0a23174a7abed96147e16790a"
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tempo\Inventory;
use Brick\Math\BigDecimal;
use Closure;
use Illuminate\Database\ConnectionInterface;
/** A physical return into the same book; the host owns unconsumed allocation and receiving evidence. */
final class DeliveryReturnWriter
{
public function __construct(private ConnectionInterface $db,private string $schema,private int $tenant,private int $actor,private Closure $authorize)
{
if(!preg_match('/^[a-z][a-z0-9_]{0,62}$/D',$schema)||$tenant<1||$actor<1)throw new \InvalidArgumentException('Invalid return context');
}
private function host():void {if($this->db->transactionLevel()<1)throw new \LogicException('Return requires host transaction');($this->authorize)();}
private function hash(object $o):string {return hash('sha256',json_encode((array)$o,JSON_THROW_ON_ERROR));}
private function operation(int $id,bool $lock=false):object
{
$q=$this->db->table($this->schema.'.stock_operations')->where(['tenant_id'=>$this->tenant,'id'=>$id]);if($lock)$q->lockForUpdate();$o=$q->first();
abort_unless($o&&$o->kind==='workshop_return'&&(int)$o->created_by===$this->actor&&BigDecimal::of((string)$o->quantity_milli)->isPositive(),409,'Operação de devolução incompatível.');return $o;
}
public function preview(int $id):string {$this->host();return $this->hash($this->operation($id));}
public function write(int $id,string $expected):array
{
$this->host();$o=$this->operation($id);
abort_unless($this->db->table($this->schema.'.warehouses')->where(['tenant_id'=>$this->tenant,'id'=>$o->warehouse_id])->lockForUpdate()->first(),403);
abort_unless($this->db->table($this->schema.'.stock_items')->where(['tenant_id'=>$this->tenant,'id'=>$o->stock_item_id])->lockForUpdate()->first(),403);
$o=$this->operation($id,true);$hash=$this->hash($o);abort_unless(hash_equals($expected,$hash),409,'Operação de devolução alterada.');
$snapshot=json_decode($o->snapshot,true,512,JSON_THROW_ON_ERROR);$parentId=$snapshot['delivery_movement_id']??null;abort_unless(is_int($parentId)&&$parentId>0,409);
abort_unless(($snapshot['received_in_warehouse']??false)===true,409,'Recepção física da devolução não declarada.');
$parent=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'id'=>$parentId,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->lockForUpdate()->first();
abort_unless($parent&&$parent->movement_type==='issue'&&BigDecimal::of((string)$parent->quantity_milli)->isNegative()&&$this->hash($parent)===($snapshot['delivery_movement_hash']??null),409,'Entrega original incompatível ou alterada.');
$old=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'warehouse_id'=>$o->warehouse_id])->first();
if($old){$s=json_decode($old->snapshot,true,512,JSON_THROW_ON_ERROR);abort_unless(($s['inventory_return_hash']??null)===$hash&&(string)$old->quantity_milli===(string)$o->quantity_milli&&(int)$old->stock_item_id===(int)$o->stock_item_id,409,'Devolução existente incompatível.');return ['movement_id'=>(int)$old->id,'replayed'=>true];}
$returned=(string)$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'movement_type'=>'workshop_return_in','snapshot->delivery_movement_id'=>$parentId])->sum('quantity_milli');
abort_unless(BigDecimal::of((string)$parent->quantity_milli)->negated()->minus($returned)->isGreaterThanOrEqualTo((string)$o->quantity_milli),409,'Devolução excede a quantidade entregue.');
$book=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->selectRaw('coalesce(max(id),0) AS revision')->first();
abort_unless((string)$o->ledger_version===(string)$book->revision,409,'Livro alterado; confira novamente.');
$movement=$this->db->table($this->schema.'.stock_movements')->insertGetId(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id,'moved_by'=>$this->actor,'movement_type'=>'workshop_return_in','quantity_milli'=>$o->quantity_milli,'snapshot'=>json_encode($snapshot+['inventory_return_hash'=>$hash,'reason'=>$o->reason],JSON_THROW_ON_ERROR),'moved_at'=>now()]);
return ['movement_id'=>(int)$movement,'replayed'=>false];
}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/ReceiptMovementWriter.php';
+8
View File
@@ -0,0 +1,8 @@
{
"id": "tempo-inventory-receipt-write",
"version": "0.1.0",
"files": {
"bootstrap.php": "b6ea8939536008c9277724216df2a968f416d9e7374189587a1172c02ecacb75",
"src/ReceiptMovementWriter.php": "279cec2ec915e27cf431355fca1e36f21cb3c15a77d02834723a41ec7a6f8f40"
}
}
@@ -0,0 +1,74 @@
<?php
namespace Tempo\Inventory;
use Closure;
use Illuminate\Database\ConnectionInterface;
/** Receipt effects only. Host owns receipt eligibility, company lock, lots and audit transaction. */
final class ReceiptMovementWriter
{
public function __construct(private ConnectionInterface $db, private string $schema, private int $tenant, private int $actor, private Closure $authorize)
{
if(!preg_match('/^[a-z][a-z0-9_]{0,62}$/D',$schema)||$tenant<1||$actor<1)throw new \InvalidArgumentException('Invalid inventory context');
}
public function preview(int $lineId): array
{
$this->checkHost();$facts=$this->facts($lineId,false);
return ['source_hash'=>$this->hash($facts),'receipt_item_id'=>$lineId,'accepted_milli'=>(string)$facts['accepted_milli'],
'stock_item_id'=>(int)$facts['stock_item_id'],'warehouse_id'=>(int)$facts['warehouse_id']];
}
public function write(int $lineId, string $expectedHash): array
{
$this->checkHost();$facts=$this->facts($lineId,true);$hash=$this->hash($facts);
abort_unless(hash_equals($hash,$expectedHash),409,'A origem da recepção mudou. Faça nova conferência.');
$existing=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'goods_receipt_item_id'=>$lineId])->first();
if($existing){
$snapshot=json_decode($existing->snapshot,true,512,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['inventory_source_hash']??null)===$hash && (string)$existing->quantity_milli===(string)$facts['accepted_milli']
&& (int)$existing->stock_item_id===(int)$facts['stock_item_id'] && (int)$existing->warehouse_id===(int)$facts['warehouse_id'],409,'A origem já tem movimento incompatível ou histórico não reconciliado.');
return ['movement_id'=>(int)$existing->id,'source_hash'=>$hash,'replayed'=>true];
}
$lineSnapshot=json_decode($facts['line_snapshot'],true,512,JSON_THROW_ON_ERROR);
$receiptSnapshot=json_decode($facts['receipt_snapshot'],true,512,JSON_THROW_ON_ERROR);
$snapshot=[...$lineSnapshot,'receipt_id'=>(int)$facts['receipt_id'],'order_number'=>$receiptSnapshot['order']['number']??null,
'delivery_note'=>$facts['delivery_note'],'reference'=>$facts['delivery_note'],'inventory_source_hash'=>$hash];
$id=$this->db->table($this->schema.'.stock_movements')->insertGetId([
'tenant_id'=>$this->tenant,'goods_receipt_item_id'=>$lineId,'stock_item_id'=>$facts['stock_item_id'],'warehouse_id'=>$facts['warehouse_id'],
'moved_by'=>$this->actor,'quantity_milli'=>$facts['accepted_milli'],'movement_type'=>'receipt_in','moved_at'=>now(),
'equipment_id'=>$lineSnapshot['equipment_id']??null,'spare_part_id'=>$lineSnapshot['spare_part_id']??null,
'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),
]);
return ['movement_id'=>(int)$id,'source_hash'=>$hash,'replayed'=>false];
}
private function checkHost(): void
{
if($this->db->transactionLevel()<1)throw new \LogicException('Receipt movement requires the host atomic transaction');
($this->authorize)();
}
private function facts(int $lineId,bool $lock): array
{
$q=$this->db->table($this->schema.'.goods_receipt_items AS l')
->join($this->schema.'.goods_receipts AS r',fn($j)=>$j->on('r.id','=','l.goods_receipt_id')->on('r.tenant_id','=','l.tenant_id'))
->where('l.tenant_id',$this->tenant)->where('l.id',$lineId)
->select(['l.id','l.tenant_id','l.stock_item_id','l.received_milli','l.accepted_milli','l.rejected_milli','l.quality_status','l.notes',
'l.snapshot AS line_snapshot','r.id AS receipt_id','r.purchase_order_id','r.received_by','r.warehouse_id','r.delivery_note','r.received_on','r.confirmed_at','r.snapshot AS receipt_snapshot']);
if($lock)$q->lockForUpdate();
$row=$q->first();abort_unless($row,403,'Origem fora do âmbito autorizado.');$facts=(array)$row;
$snapshot=json_decode($facts['line_snapshot'],true,512,JSON_THROW_ON_ERROR);
abort_unless((int)$facts['received_by']===$this->actor,403,'A origem não pertence ao recebedor desta confirmação.');
abort_unless($facts['confirmed_at']!==null && ($snapshot['physical']??false)===true && (int)$facts['accepted_milli']>0
&& $facts['stock_item_id']!==null && $facts['warehouse_id']!==null,409,'A origem não comprova quantidade física aceite.');
return $facts;
}
private function hash(array $facts): string
{
// Parent status/number are intentionally absent: finalisation occurs later in the same transaction.
return hash('sha256',json_encode($facts,JSON_THROW_ON_ERROR));
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tempo\Purchasing\Sourcing;
use Tempo\Purchasing\Economic\ProposalScoring;
use Tempo\Purchasing\Economic\QuotationPricing;
use Tempo\Purchasing\Internal\RequisitionFailure;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
/** Host-independent sourcing transitions. No messaging, budget movements or payments. */
final class Sourcing
{
public function comparison(array $state, array $policy, string $today): array
{
$rows=[];
foreach($state['quotes'] as $id=>$q){
$evaluations=array_values(array_filter($q['evaluations'],fn($e)=>$e['quote_revision']===$q['revision']&&($e['eligible']??false)));
$rows[]=[...$q,'id'=>(int)$id,'eligible'=>($q['supplier_eligible']??false)&&$q['valid_until']>=$today,'weights'=>$policy['weights'],'evaluation_count'=>count($evaluations),'qualified_evaluations'=>$evaluations];
}
$ranking=(new ProposalScoring)->rank($rows,$policy['minimum_evaluators']);
$eligible=array_filter($rows,fn($r)=>$r['eligible']);
$ready=count($eligible)>=$policy['minimum_proposals'];
foreach($eligible as $row)$ready=$ready&&$row['evaluation_count']>=$policy['minimum_evaluators'];
return ['rows'=>$rows,'ready'=>$ready,'top_ids'=>$ranking['top_ids'],'scores'=>$ranking['scores'],'fingerprint'=>hash('sha256',json_encode(['rows'=>$rows,'policy'=>$policy],JSON_THROW_ON_ERROR))];
}
public function transition(array $state,array $request,int $actor,string $action,array $data,array $policy,string $today): array
{
$fail=fn(string $message,int $status=409)=>throw new RequisitionFailure($message,$status);
if($request['status']!=='approved')$fail('A requisição de origem não está aprovada.');
if(($state['confirmed']??null)!==null)$fail('Adjudicação confirmada: processo fechado.');
if(in_array($action,['evaluate','propose','confirm'],true)&&$actor===$request['requester_id'])$fail('O requerente não pode avaliar, propor ou confirmar esta adjudicação.',403);
switch($action){
case 'select':
$supplier=$data['supplier'];
if(!$supplier['eligible'])$fail('Fornecedor não elegível.',422);
if(isset($state['suppliers'][$supplier['id']]))$fail('Fornecedor já seleccionado.');
$state['suppliers'][$supplier['id']]=$supplier;
break;
case 'quote':
$supplier=(int)$data['supplier_id'];
if(!isset($state['suppliers'][$supplier]))$fail('Seleccione um fornecedor autorizado.',422);
$old=$state['quotes'][$supplier]??null;$lines=[];$subtotal=0;$tax=0;
if(count($data['items'])!==count($request['data']['items']))$fail('A cotação deve cobrir exactamente as linhas da requisição.',422);
foreach($request['data']['items'] as $index=>$source){
$input=$data['items'][$index]??$fail('Linha em falta.',422);
if(($input['line']??null)!==$index)$fail('Referência de linha inválida.',422);
$pricing=(new QuotationPricing)->unit($input['unit_price'],$input['discount_type']??'none',$input['discount_value']??'0');
$net=BigDecimal::of($source['quantity'])->multipliedBy($pricing['net_unit_minor'])->toScale(0,RoundingMode::HalfUp)->toInt();
$vat=BigDecimal::of($net)->multipliedBy($source['tax_rate'])->dividedBy(100,0,RoundingMode::HalfUp)->toInt();
$subtotal+=$net;$tax+=$vat;
if($subtotal+$tax>100000000000000)$fail('Valor acima do limite técnico.',422);
$lines[]=['line'=>$index,'source'=>$source,'pricing'=>$pricing,'subtotal_minor'=>$net,'tax_minor'=>$vat];
}
if($data['valid_until']<$today)$fail('Cotação expirada.',422);
$state['quotes'][$supplier]=['supplier_id'=>$supplier,'revision'=>($old['revision']??0)+1,'reference'=>$data['reference'],'currency'=>'MZN','delivery_days'=>$data['delivery_days'],'payment_terms'=>$data['payment_terms'],'valid_until'=>$data['valid_until'],'author_id'=>$actor,'recorded_on'=>$today,'items'=>$lines,'subtotal_minor'=>$subtotal,'tax_minor'=>$tax,'total_minor'=>$subtotal+$tax,'evaluations'=>[],'supplier_eligible'=>true];
break;
case 'evaluate':
$id=$data['supplier_id'];$q=$state['quotes'][$id]??$fail('Cotação inexistente.',404);
if(!($q['supplier_eligible']??false)||$q['valid_until']<$today)$fail('Fornecedor inelegível ou cotação expirada.',422);
if($q['revision']!==$data['quote_revision'])$fail('Versão da cotação alterada.');
$state['quotes'][$id]['evaluations'][$actor]=['evaluator_id'=>$actor,'quote_revision'=>$q['revision'],'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'no_conflict'=>true,'eligible'=>true,'reason'=>$data['reason']];
break;
case 'propose':
$comparison=$this->comparison($state,$policy,$today);
if(!$comparison['ready'])$fail('São necessárias '.$policy['minimum_proposals'].' propostas elegíveis e '.$policy['minimum_evaluators'].' avaliações independentes por proposta.',422);
if(!hash_equals($comparison['fingerprint'],$data['comparison_hash']))$fail('Comparação desactualizada.');
if(!in_array($data['supplier_id'],$comparison['top_ids'],true))$fail('A selecção deve respeitar a classificação da comparação.',422);
if(($data['delivery_date']??'')<$today||trim($data['delivery_location']??'')==='')$fail('Indique data e local de entrega previstos, como na adjudicação de Compras.',422);
$state['proposals'][]=['supplier_id'=>$data['supplier_id'],'comparison_hash'=>$comparison['fingerprint'],'comparison_version'=>$state['version'],'comparison'=>$comparison,'proposed_by'=>$actor,'reason'=>$data['reason'],'delivery_date'=>$data['delivery_date'],'delivery_location'=>$data['delivery_location'],'proposed_on'=>$today];
break;
case 'confirm':
$proposal=end($state['proposals']);
if(!$proposal)$fail('Proposta de adjudicação em falta.',422);
if($proposal['proposed_by']===$actor)$fail('A confirmação exige outra identidade.',403);
if(($data['proposal_index']??null)!==array_key_last($state['proposals']))$fail('Proposta de adjudicação alterada.');
$comparison=$this->comparison($state,$policy,$today);
if(!$comparison['ready']||!hash_equals($proposal['comparison_hash'],$comparison['fingerprint'])||!hash_equals($proposal['comparison_hash'],$data['comparison_hash']))$fail('As cotações, avaliações ou elegibilidade alteraram-se; proponha novamente.');
$state['confirmed']=['proposal'=>$proposal,'confirmed_by'=>$actor,'reason'=>$data['reason'],'confirmed_on'=>$today];
break;
default:$fail('Operação não suportada.',404);
}
if(in_array($action,['quote','evaluate'],true))$state['comparison_changed']=true;
return $state;
}
}