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 @@
# 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];
}
}