commit15
This commit is contained in:
@@ -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.
|
||||
@@ -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))]];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user