This commit is contained in:
2026-11-02 20:27:11 +02:00
parent b44378b899
commit badaada043
300 changed files with 17458 additions and 1056 deletions
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/ReceiptMovementWriter.php';
+8
View File
@@ -0,0 +1,8 @@
{
"id": "tempo-inventory-receipt-write",
"version": "0.1.0",
"files": {
"bootstrap.php": "b6ea8939536008c9277724216df2a968f416d9e7374189587a1172c02ecacb75",
"src/ReceiptMovementWriter.php": "279cec2ec915e27cf431355fca1e36f21cb3c15a77d02834723a41ec7a6f8f40"
}
}
@@ -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));
}
}