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
+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);
}
}