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
+7
View File
@@ -0,0 +1,7 @@
# Inventory issue primitive
Common physical issue writer; Laravel host transaction and an explicit authorization callback are mandatory. Locks the warehouse and item, checks the frozen operation hash and ledger revision, refuses insufficient physical quantity and writes a single negative movement. Replaying the same operation returns its original movement.
The host owns permissions, context, reservation/lot/equipment rules, source eligibility and audit. This primitive neither authorizes arbitrary consumption nor manages returns. SGF's synthetic bridge accepts only explicitly mapped, untracked, unreserved articles and new eligible workshop orders.
`scripts/package-inventory-issue.mjs` creates the immutable local Compras artifact. No publication or production installation is implied.
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/IssueMovementWriter.php';
@@ -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];
}
}
+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))]];
}
}
+7
View File
@@ -0,0 +1,7 @@
# Physical return to the same stock book
`DeliveryReturnWriter` writes one positive `workshop_return_in` movement inside the host transaction. It requires authorization, a frozen operation, explicit physical warehouse receipt and a matching original issue movement. Warehouse, article, operation and original movement are locked. Ledger revision and cumulative returned quantity are checked; replay returns the existing movement.
The host must verify the returned observation, source eligibility, unconsumed/unallocated quantity, independence, context, rights and audit. This primitive caps physical returns by the original issue; it does not know workshop allocations. It never cancels consumption, corrects financial documents or creates another stock balance.
The current adapter is synthetic SGF only. Native Purchasing return-command integration, operational installation, other drivers and capacity remain unvalidated.
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/DeliveryReturnWriter.php';
@@ -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];
}
}
+11
View File
@@ -0,0 +1,11 @@
# Receipt movement writer
`ReceiptMovementWriter` is the single extracted writer for a new accepted physical receipt line. Source code comes from the former insert in Purchasing `ReceiptWorkflow`; versioned artifacts are distributed locally, verified by manifest and never replaced with different contents.
The host owns the company lock, current actor/policy/conflict checks, order eligibility, quantities/units/quality, explicit warehouse/item mapping, lots, downstream signals and audit. It must call the writer within that same transaction, with an authorisation callback. The writer is not a public receipt intake API and must not be used to reconstruct/backfill historical effects or bypass `ReceiptWorkflow`.
The writer reads the scoped receipt/line, checks physical acceptance and original receiver, freezes a source hash, locks/revalidates that hash and writes one `receipt_in` using the existing unique `goods_receipt_item_id`. It preserves the original snapshot and adds origin hash/reference. A compatible receipt with the same hash reuses its movement. A stale hash or a legacy/incompatible existing movement is refused; old rows are never rewritten/adopted automatically. Parent final status/number are excluded from the hash because native finalisation occurs later in the same transaction.
No independent commit, second balance or audit engine is introduced. Failure after insert rolls back with the host's receipt/lots/audit. Stock issues, transfers, corrections, consumption, payments and accounting are not implemented here. Tested on isolated PostgreSQL; no other-driver or deployment claim.
See [delivery and evidence](../../docs/STOCK_RECEIPT_LINK_DELIVERY.md).
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/ReceiptMovementWriter.php';
@@ -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));
}
}
+4 -2
View File
@@ -93,9 +93,11 @@ final class HistoricalPayments
}
}
public function matches(object $import, array $capture): bool
public function matches(object $import, array $capture, ?array $preloaded = null): bool
{
$rows = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get();
$rows = $preloaded === null
? $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get()
: collect($preloaded);
if ($import->history_hash !== $capture['history_hash'] || $import->source_key !== $capture['source_key']
|| Payables::canonical(json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($capture) || $rows->count() !== count($capture['payments'])) {
return false;
+53 -8
View File
@@ -10,7 +10,7 @@ final class Payables
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
public function __construct(private ConnectionInterface $db, private int $actor, private string $person, private array $scope,
private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null) {}
private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null, private ?\Closure $authorizeRead = null) {}
public static function canonical(array $data): string
{
@@ -123,9 +123,9 @@ final class Payables
}
}
private function history(array $source): array
private function history(array $source, bool $availabilityChecked = false): array
{
if ($this->loadHistory === null || ! (new HistoricalPayments($this->db, $this->scope))->available()) {
if ($this->loadHistory === null || (! $availabilityChecked && ! (new HistoricalPayments($this->db, $this->scope))->available())) {
throw new \DomainException('Passagem de histórico não activada nesta instalação.');
}
@@ -141,10 +141,10 @@ final class Payables
return self::missing($source);
}
private function position(object $obligation, ?array $source): array
private function position(object $obligation, ?array $source, ?array $page = null): array
{
$store = new HistoricalPayments($this->db, $this->scope);
$import = $store->imported((int) $obligation->id);
$import = $page === null ? $store->imported((int) $obligation->id) : ($page['imports'][$obligation->id] ?? null);
$current = null;
$compatible = false;
if ($source) {
@@ -152,15 +152,17 @@ final class Payables
&& $this->eligibility($source, $import !== null) === [];
if ($import) {
try {
$current = (new PaymentDecisions($this->db, $this->scope))->current($import);
$compatible = $compatible && self::canonical($current) === self::canonical($this->history($source));
$current = (new PaymentDecisions($this->db, $this->scope))->current($import,
$page === null ? null : ($page['payments'][$obligation->id] ?? []),
$page === null ? null : ($page['decisions'][$obligation->id] ?? []));
$compatible = $compatible && self::canonical($current) === self::canonical($this->history($source, $page !== null));
} catch (\DomainException $e) {
$compatible = false;
}
}
}
$confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0);
$allocated = SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id);
$allocated = $page === null ? SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id) : ($page['allocated'][$obligation->id] ?? 0);
$balance = (int) $obligation->principal_minor - $confirmed - $allocated;
if ($balance < 0) {
$compatible = false;
@@ -342,6 +344,49 @@ final class Payables
return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
}
/** Bounded source window with independent proposal/event pages; commands never use these snapshots. */
public function listingPage(array $localSources, int $proposalPage = 1, int $eventPage = 1, int $perPage = 25): array
{
if (count($localSources) > 100 || ! in_array($perPage, [25, 100], true) || $proposalPage < 1 || $eventPage < 1
|| array_filter($localSources, fn ($id) => ! is_int($id) || $id < 1)) {
throw new \InvalidArgumentException('Janela de leitura financeira inválida.');
}
return $this->db->transaction(function () use ($localSources, $proposalPage, $eventPage, $perPage) {
$this->authorizeRead === null ? ($this->authorize)('view') : ($this->authorizeRead)();
$documents = []; $current = [];
foreach ($localSources as $id) {
$s = $this->source($id);
$doc = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
$documents[] = $doc; $current[self::identity($doc)] = $doc;
}
$sources = fn () => $this->q('sources')->whereIn('local_source_id', $localSources)->select('id');
$obligations = $this->q('obligations')->whereIn('source_id', $sources())->orderBy('id')->get();
$origins = $this->q('sources')->whereIn('id', $obligations->pluck('source_id'))->get()->keyBy('id');
$ids = $obligations->pluck('id')->map(fn ($id) => (int) $id)->all();
$page = ['imports' => [], 'payments' => [], 'decisions' => [], 'allocated' => SupplierSettlements::allocatedPage($this->db, $this->scope, $ids)];
if ((new HistoricalPayments($this->db, $this->scope))->available()) {
$page['imports'] = $this->q('history_imports')->whereIn('obligation_id', $ids)->get()->keyBy('obligation_id')->all();
$page['payments'] = $this->q('imported_payments')->whereIn('obligation_id', $ids)->orderBy('legacy_payment_id')->get()->groupBy('obligation_id')->map(fn ($rows) => $rows->all())->all();
if ($this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
$page['decisions'] = $this->q('payment_decisions')->whereIn('obligation_id', $ids)->orderBy('sequence')->get()->groupBy('obligation_id')->map(fn ($rows) => $rows->all())->all();
}
}
$positions = $obligations->map(function ($obligation) use ($origins, $current, $page) {
$origin = $origins->get($obligation->source_id);
return $this->position($obligation, $origin ? ($current[$origin->source_key] ?? null) : null, $page);
})->all();
$proposals = $this->q('proposals')->whereIn('source_id', $sources())->orderByDesc('id')->paginate($perPage, ['*'], 'proposal_page', $proposalPage);
$events = $this->q('events')->whereIn('source_id', $sources())->orderByDesc('id')->paginate($perPage, ['*'], 'event_page', $eventPage);
$meta = fn ($p) => ['page' => $p->currentPage(), 'last_page' => $p->lastPage(), 'total' => $p->total(), 'per_page' => $p->perPage()];
return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $positions,
'proposals' => $proposals->getCollection(), 'events' => $events->getCollection(),
'pagination' => ['proposals' => $meta($proposals), 'events' => $meta($events)],
'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
});
}
public function listing(array $localSources): array
{
return $this->db->transaction(function () use ($localSources) {
+6 -5
View File
@@ -51,20 +51,21 @@ final class PaymentDecisions
throw new \DomainException('Pagamento ausente do histórico importado.');
}
public function current(object $import): array
public function current(object $import, ?array $payments = null, ?array $decisions = null): array
{
$capture = json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR);
if (Payables::canonical($this->rebuild($capture)) !== Payables::canonical($capture)
|| ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture)) {
|| ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture, $payments)) {
throw new \DomainException('Fotografia original da importação incompatível.');
}
if (! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
if ($decisions === null && ! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
return $capture;
}
$sequence = 0;
foreach ($this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) {
$links = $payments === null ? null : collect($payments)->keyBy('id');
foreach ($decisions ?? $this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) {
$next = json_decode($decision->payment_snapshot, true, 64, JSON_THROW_ON_ERROR);
$link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first();
$link = $links === null ? $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first() : $links->get($decision->imported_payment_id);
if ((int) $decision->sequence !== ++$sequence || $decision->previous_hash !== $capture['history_hash'] || ! $link || (int) $link->legacy_payment_id !== $next['id']) {
throw new \DomainException('Cadeia de decisões históricas incompatível.');
}
@@ -142,6 +142,25 @@ final class SupplierSettlements
return $allocated - $reversed;
}
/** Same allocation/reversal aggregation, restricted to the current read page. */
public static function allocatedPage(ConnectionInterface $db, array $scope, array $obligations): array
{
$result = array_fill_keys($obligations, 0);
if (! $db->getSchemaBuilder()->hasTable('ps_allocations')) {
return $result;
}
foreach (['ps_allocations' => 1, 'ps_reversals' => -1] as $table => $sign) {
if ($table !== 'ps_allocations' && ! $db->getSchemaBuilder()->hasTable($table)) {
continue;
}
foreach ($db->table($table)->where($scope)->whereIn('obligation_id', $obligations)->selectRaw('obligation_id, sum(amount_minor) AS amount')->groupBy('obligation_id')->get() as $row) {
$result[$row->obligation_id] += $sign * (int) $row->amount;
}
}
return $result;
}
private function unallocated(\stdClass $payment): int
{
return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor')
+6 -3
View File
@@ -2,19 +2,22 @@
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Obrigações a fornecedores — ensaio</title>
<style>body{font:16px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;margin:18px 0;border:1px solid #d5e0dc;border-radius:12px}h1{font-size:28px}h2{font-size:20px}.notice{padding:16px;background:#fff2cc}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}label{display:block;margin:12px 0}select,textarea,button{font:inherit;padding:10px;max-width:100%}textarea{display:block;width:90%}button{background:#125a4b;color:white;border:0;border-radius:6px;cursor:pointer}button:disabled{opacity:.5}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.scroll{overflow:auto}.amount{font-size:26px;font-weight:bold}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
<header><?php if(session()->get('payables_demo_entry')): ?><p><a href="/demo">Trocar função ou sair da demonstração</a></p><?php endif ?><a href="<?= $e($settings['return_url']??'/') ?>">← <?= $e($settings['return_label']??'Voltar à aplicação') ?></a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso reconhece o principal e, quando activado, preserva o histórico existente.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
<?php if(isset($data['history_receipt_notice'])): ?><p class="notice"><?= $e($data['history_receipt_notice']) ?></p><?php endif ?>
<?php $pageNav=function(string $key)use($data,$settings,$e){if(!isset($data['pagination'][$key]))return;$p=$data['pagination'][$key];$param=['documents'=>'page','proposals'=>'proposal_page','events'=>'event_page'][$key];echo '<nav aria-label="Paginação '.$e($key).'"><p>Página '.$e($p['page']).' de '.$e($p['last_page']).' · '.$e($p['total']).' registos</p>';foreach([-1=>'Anterior',1=>'Seguinte'] as $delta=>$label){$target=$p['page']+$delta;if($target<1||$target>$p['last_page'])continue;$query=array_replace($data['listing_query'],[$param=>$target]);if($key==='documents'){$query['proposal_page']=1;$query['event_page']=1;}$url=($settings['page_url']??'/supplier-payables').'?'.http_build_query($query,'','&',PHP_QUERY_RFC3986);echo '<a href="'.$e($url).'">'.$label.'</a> ';}echo '</nav>';}; ?>
<?php if(isset($data['pagination'])): ?><section><h2>Filtrar documentos</h2><form method="get" action="<?= $e($settings['page_url']??'/supplier-payables') ?>"><label>Número ou fornecedor<input name="q" maxlength="100" value="<?= $e($data['listing_query']['q']) ?>"></label><label>Reconhecimento<select name="recognition"><?php foreach(['all'=>'Todos','recognised'=>'Reconhecidos','unrecognised'=>'Por reconhecer'] as $v=>$label): ?><option value="<?= $e($v) ?>" <?= $data['listing_query']['recognition']===$v?'selected':'' ?>><?= $e($label) ?></option><?php endforeach ?></select></label><label>Documentos por página<select name="per_page"><?php foreach([25,100] as $size): ?><option value="<?= $size ?>" <?= $data['listing_query']['per_page']===$size?'selected':'' ?>><?= $size ?></option><?php endforeach ?></select></label><button>Aplicar filtros</button></form><p>Obrigações, propostas e eventos abaixo pertencem aos documentos desta página. A pesquisa e o estado são aplicados antes da paginação.</p></section><?php endif ?>
<?php if($data['payments_available']??false): ?><p><a href="/supplier-payments">Pagamentos e afectações sintéticas — sem execução bancária</a></p><?php endif ?><p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<section><h2>Facturas de origem</h2><div class="scroll"><table><thead><tr><th>Documento</th><th>Fornecedor</th><th>Base</th><th>Imposto documental</th><th>Total</th><th>Conferência / revisão</th></tr></thead><tbody>
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?></td><td><?= $e($d['supplier_name']??'Indeterminado') ?></td><td><?= isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?></td><td><?= isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?></td><td><?= isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?></td><td><?= $e($d['status']) ?><details><summary>Origem e pendências</summary><pre><?= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></td></tr><?php endforeach ?>
</tbody></table></div></section>
</tbody></table></div><?php $pageNav('documents'); ?></section>
<?php if($data['permissions']['prepare']): ?><section><h2>Preparar a obrigação</h2><form data-action="prepare"><label>Factura existente<select name="id"><?php foreach($data['documents'] as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Proposta sintética com base na factura conferida.</textarea></label><p>Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.</p><button>Preparar da origem</button></form></section><?php endif ?>
<?php $historyDocuments=array_filter($data['documents'],fn($d)=>($d['payment_count']??0)>0); if(($data['history_import_available']??false)&&$data['permissions']['prepare']&&$historyDocuments): ?><section><h2>Incorporar factura com pagamentos anteriores</h2><p>Preserva o principal integral e liga os pagamentos originais uma única vez. Só os confirmados reduzem o saldo; pendentes e rejeitados mantêm o seu estado. Não executa nem confirma pagamentos.</p><form data-action="prepare_history"><label>Factura com histórico<select name="id"><?php foreach($historyDocuments as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Passagem de responsabilidade com preservação dos pagamentos históricos.</textarea></label><button>Preparar passagem com histórico</button></form></section><?php endif ?>
<section><h2>Propostas e decisões</h2><?php if(!$data['proposals']->count()): ?><p>Ainda não há propostas.</p><?php endif ?>
<?php foreach($data['proposals'] as $p): ?><article><h3>Proposta #<?= $e($p->id) ?> · captura <?= $e($p->capture_version) ?></h3><p>Preparador #<?= $e($p->prepared_by) ?> · <?= $e($p->created_at) ?></p><p><?= $e($p->reason) ?></p><details><summary>Fotografia e pendências</summary><pre><?= $e($p->snapshot) ?></pre><pre><?= $e($p->missing) ?></pre></details>
<?php $history=json_decode($p->snapshot,true)['_history_import']??null; if($history): ?><p>Histórico proposto: confirmados <?= $money($history['confirmed_minor']) ?> · pendentes <?= $money($history['pending_minor']) ?> · rejeitados <?= $money($history['rejected_minor']) ?>. Os pendentes e rejeitados não reduzem o saldo.</p><?php endif ?>
<?php if($data['permissions']['validate']&&(!$history||($data['history_import_available']??false))): ?><form data-action="<?= $history?'validate_history':'validate' ?>"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button><?= $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?></button></form><?php endif ?></article><?php endforeach ?></section>
<?php if($data['permissions']['validate']&&(!$history||($data['history_import_available']??false))): ?><form data-action="<?= $history?'validate_history':'validate' ?>"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button><?= $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?></button></form><?php endif ?></article><?php endforeach ?><?php $pageNav('proposals'); ?></section>
<section><h2>Obrigações reconhecidas</h2><?php if(!$data['obligations']): ?><p>Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.</p><?php endif ?><div class="grid">
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><?php if($o['history_imported']??false): ?><p>Pagamentos históricos confirmados: <?= $money($o['confirmed_payments_minor']) ?></p><p>Pendentes: <?= $money($o['pending_payments_minor']) ?> · Rejeitados: <?= $money($o['rejected_payments_minor']) ?> — sem abatimento.</p><p><strong>Saldo por liquidar: <?= $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?></strong></p><?php if($data['history_decisions_available']??false): ?><p>Importação original preservada. A conferência independente dos pendentes actualiza este saldo uma única vez.</p><p><a href="/pagamentos">Conferir pagamentos pendentes →</a></p><?php else: ?><p>Importação original preservada. Conferência de pendentes não activada nesta instalação.</p><?php endif ?><?php endif ?><?php if(!($o['history_imported']??false)): ?><p>Afectações confirmadas neste percurso: <?= $money($o['allocated_payments_minor']??0) ?></p><p><strong>Saldo por liquidar: <?= $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?></strong></p><?php endif ?><?php if($o['source_conflict']??false): ?><p role="alert">Origem ou histórico divergente; saldo não presumido. O principal original permanece preservado.</p><?php endif ?><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?><?php $pageNav('events'); ?></section>
<p>Sem execução de novos pagamentos, cobrança, efeitos de stock, novo consumo orçamental ou lançamento automático. A passagem de histórico não confirma pagamentos pendentes.</p>
</main><script>
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;