This commit is contained in:
2026-11-02 20:29:34 +02:00
parent 340f3bb5fa
commit fcf31e8d48
181 changed files with 33383 additions and 51 deletions
+11
View File
@@ -0,0 +1,11 @@
# Inventory read projection
Read-only SQL projection over Purchasing's `stock_items`, `warehouses` and append-only `stock_movements` (`quantity_milli`, `snapshot`, `moved_at`). No stock writer, balance table, new financial engine or dependency installation.
The host supplies an already authorised source tenant and explicit warehouse IDs. `InventoryReader` does not authenticate users or infer company/unit mapping. The SGF adapter revalidates its own session/module/context/`stock.view`, permits only testing bindings and obtains its own read-only repeatable-read transaction. Configuration is empty by default. The schema name is host-controlled, validated and never accepted from HTTP.
`revision()` uses count/max of the scoped append-only ledger and SQL catalogue hashes. A signed host token detects source/catalogue/mapping/context changes between pages; it is a refresh-on-change protocol, not a historical snapshot preserved across requests. Append-only is a prerequisite: using a mutable ledger could invalidate this protocol. Each page uses one database snapshot. Metadata hashing remains proportional to the source catalogue in SQL and is included in the timings.
`page()` supports items, warehouses, balances and movements with server filtering, exact integer/string quantities and stable ordering. It never exposes raw snapshots or financial values. Balance rows exist only for pairs with eligible movements; catalogue items without movements do not create a cross-product of fabricated balances. Reserved/blocked/available remain null.
The initial delivery exercised SGF and the reader directly. The subsequent [two-application API delivery](../../docs/STOCK_READ_COMPOSITION_DELIVERY.md) exercises authenticated SGF and Purchasing kernels with equivalent isolated fixtures and a verified local artifact. `InventoryReadSession` now owns the shared transaction/token protocol. Purchasing's existing stock page/writers, receipt/consumption integration and real deployment remain separate. No network login/browser proof is implied.
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__.'/src/InventoryReader.php';
require_once __DIR__.'/src/InventoryReadSession.php';
@@ -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))]];
}
}