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