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 @@
# 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.
+2
View File
@@ -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];
}
}