This commit is contained in:
2026-11-02 20:29:34 +02:00
parent 340f3bb5fa
commit fcf31e8d48
181 changed files with 33383 additions and 51 deletions
+11
View File
@@ -0,0 +1,11 @@
# 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));
}
}