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