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