commit14
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
@@ -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]'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
require_once __DIR__.'/src/IssueMovementWriter.php';
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
require_once __DIR__.'/src/InventoryReader.php';
|
||||
require_once __DIR__.'/src/InventoryReadSession.php';
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
require_once __DIR__.'/src/ReceiptMovementWriter.php';
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user