Files
SGF/app/Modules/Workshop/WorkshopStock.php
T
2026-11-02 20:29:34 +02:00

230 lines
28 KiB
PHP

<?php
namespace App\Modules\Workshop;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\CompanyContext;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require_once __DIR__.'/../../../packages/inventory-issue/bootstrap.php';
require_once __DIR__.'/../../../packages/inventory-return/bootstrap.php';
final class WorkshopStock
{
private array $allocationTables=[];
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function scope(string $right): array
{
WorkshopWorkflow::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($s['unit_id'],403);
$actor=$this->context->userId();$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.'.$right] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);return $s;
}
private function binding(array $s): array
{
$b=config('workshop_stock.synthetic_sources.'.implode(':',$s));
abort_unless($b&&preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$b['schema']??'')&&!isset($b['connection'])
&&($b['lots']??true)===false&&($b['reservations']??true)===false&&($b['spare_parts']??true)===false,503,'Fonte local de consumo ainda não ligada ou fora do recorte sem lotes/reservas/peças.');return $b;
}
private function lock(array $s): void {DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();}
private function allocationTable(array $b): ?string
{
$table=$b['schema'].'.ws_allocations';
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);
return $this->allocationTables[$table]?$table:null;
}
private function returnTable(array $b): ?string
{
$table=$b['schema'].'.ws_returns';if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);return $this->allocationTables[$table]?$table:null;
}
private function closureTable(array $b): ?string
{
$table=$b['schema'].'.ws_closures';
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);
return $this->allocationTables[$table]?$table:null;
}
private function closure(array $s,array $b,int $id): ?object
{
return ($table=$this->closureTable($b))?DB::table($table)->where($s)->where('proposal_id',$id)->first():null;
}
private function proposalResult(array $s,array $b,object $p): array
{
$closed=$this->closure($s,$b,(int)$p->id);$claim=$this->proposalClaim($b,(int)$p->id);
return ['id'=>(int)$p->id,'impact'=>$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR)),
'reason'=>$p->reason,'evidence'=>$p->evidence,'source_hash'=>$p->source_hash,
'state'=>$closed?->state??($claim?'confirmed':'pending'),'state_version'=>($closed||$claim)?2:1,
'movement_id'=>$claim?(int)$claim->movement_id:null,'replayed'=>true];
}
public function close(int $id,string $action,int $version,string $sourceHash,string $reason): array
{
abort_unless(in_array($action,['withdrawn','rejected'],true),422);
$right=$action==='withdrawn'?'prepare':'confirm';$s=$this->scope($right);$b=$this->binding($s);$actor=$this->context->userId();
return DB::transaction(function()use($id,$action,$version,$sourceHash,$reason,$right,$s,$b,$actor){
$this->lock($s);$this->scope($right);$table=$this->closureTable($b);abort_unless($table,503,'Encerramento de propostas ainda não instalado nesta fonte.');
$p=DB::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);
abort_unless($action==='withdrawn'?(int)$p->preparer_id===$actor:(int)$p->preparer_id!==$actor,403,'Retirada exige o preparador; recusa exige outra identidade autorizada.');
abort_unless($version===1&&hash_equals($p->source_hash,$sourceHash),409,'A proposta apresentada mudou. Consulte novamente o histórico.');
if($old=$this->closure($s,$b,$id)){
abort_unless($old->state===$action&&(int)$old->actor_id===$actor&&$old->reason===$reason,409,'Proposta já encerrada por outra decisão.');
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>(int)$old->id,'replayed'=>true,'physical_effect_milli'=>'0'];
}
abort_if($this->proposalClaim($b,$id),409,'Tratamento já confirmado; exige correcção física própria.');
// Closing an obsolete preview deliberately does not execute or revalidate its physical effect.
$decision=DB::table($table)->insertGetId($s+['proposal_id'=>$id,'actor_id'=>$actor,'source_hash'=>$sourceHash,'reason'=>$reason,'before_state'=>'pending','state'=>$action,'state_version'=>2,'created_at'=>now()]);
Audit::record('workshop_stock.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['area'=>'workshop_stock_proposal','record_id'=>$id,'decision_id'=>$decision,'version'=>2,'review_hash'=>$sourceHash,'reason'=>$reason]);
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>$decision,'replayed'=>false,'physical_effect_milli'=>'0'];
});
}
private function originClaimed(array $b,string $origin): bool
{
return DB::table($b['schema'].'.ws_claims')->where('origin_key',$origin)->exists()||(($t=$this->allocationTable($b))&&DB::table($t)->where('origin_key',$origin)->exists())||(($t=$this->returnTable($b))&&DB::table($t)->where('origin_key',$origin)->exists());
}
private function proposalClaim(array $b,int $id): ?object
{
return DB::table($b['schema'].'.ws_claims')->where('proposal_id',$id)->first()??(($t=$this->allocationTable($b))?DB::table($t)->where('proposal_id',$id)->first():null)??(($t=$this->returnTable($b))?DB::table($t)->where('proposal_id',$id)->first():null);
}
private function source(array $s,array $b,int $id,int $version,int $warehouse,bool $lock=false,bool $existingDelivery=false,string $event='consumed'): array
{
$q=DB::table('wo_entries AS e')->join('wo_orders AS o','o.id','=','e.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->where('e.id',$id)->select('e.*');if($lock)$q->lockForUpdate();
$e=$q->first();abort_unless($e,403);$d=json_decode($e->data,true,512,JSON_THROW_ON_ERROR);
abort_unless(in_array((int)$e->order_id,$b['eligible_order_ids'],true),409,'Origem não reconciliada para este circuito; não se incorporam observações antigas automaticamente.');
abort_unless($e->kind==='material'&&($d['event']??null)===$event&&($d['owner']??null)==='company',422,'Ocorrência ou propriedade incompatível com o tratamento escolhido.');
abort_unless((int)$e->version===$version&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists(),409,'Origem antiga ou fora da autorização. Faça nova conferência.');
$decision=DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->first();abort_unless($decision&&(int)$decision->actor_id!==(int)$e->actor_id,409,'Consumo sem verificação técnica independente.');
$map=$b['resources'][$d['resource'].'|'.$d['unit']]??null;abort_unless($map&&in_array($warehouse,$map['warehouse_ids'],true),403,'Sem correspondência explícita de recurso/unidade/armazém.');
$item=DB::table($b['schema'].'.stock_items')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$map['stock_item_id']])->first();
abort_unless($item&&$item->unit===$map['stock_unit']&&strtoupper(trim($item->unit))===$d['unit'],409,'Unidade de stock incompatível; sem conversão automática.');
abort_unless(DB::table($b['schema'].'.warehouses')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$warehouse])->exists(),403);
try{$quantity=BigDecimal::of((string)$e->quantity)->multipliedBy(1000)->toScale(0)->toInt();}catch(\Brick\Math\Exception\MathException){abort(422,'Quantidade não representável exactamente em milésimos.');}abort_unless($quantity>0,422);
$origin=hash('sha256',json_encode([$s,$e->order_id,$e->origin],JSON_THROW_ON_ERROR));
// Do not deduct consumption again after an unlinked physical delivery/issue.
$unknownQuery=DB::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$item->id,'m.warehouse_id'=>$warehouse])->where('m.quantity_milli','<',0)
->whereNotExists(fn($x)=>$x->selectRaw('1')->from($b['schema'].'.ws_claims AS c')->whereColumn('c.movement_id','m.id'));
$accounted='0';if($t=$this->allocationTable($b))$accounted.=' + (SELECT coalesce(sum(a.quantity_milli),0) FROM '.$t.' AS a WHERE a.movement_id=m.id)';
if($t=$this->returnTable($b))$accounted.=' + (SELECT coalesce(sum(r.quantity_milli),0) FROM '.$t.' AS r WHERE r.parent_movement_id=m.id)';
$unknownQuery->whereRaw('('.$accounted.') < -m.quantity_milli');
$unknown=$unknownQuery->exists();
abort_if($unknown&&!$existingDelivery,409,'Existe saída física sem correspondência reconciliada. Não se volta a abater a entrega como consumo.');
$book=DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$item->id,'warehouse_id'=>$warehouse])->selectRaw('coalesce(sum(quantity_milli),0)::text AS quantity,coalesce(max(id),0)::text AS revision')->first();
abort_unless($existingDelivery||BigDecimal::of($book->quantity)->isGreaterThanOrEqualTo($quantity),409,'Stock físico insuficiente.');
return ['entry'=>(array)$e,'decision'=>(array)$decision,'binding'=>$b,'origin_key'=>$origin,'stock_item_id'=>(int)$item->id,'warehouse_id'=>$warehouse,'quantity_milli'=>$quantity,'unit'=>$item->unit,'book'=>(array)$book];
}
private function hash(array $v): string {return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
private function impact(array $v): array
{
$linked=isset($v['delivery']);$return=$linked&&($v['delivery']['returning']??false);$mode=$linked?($return?'return_existing_delivery':(($v['delivery']['partial']??false)?'allocate_existing_delivery':'link_existing_delivery')):'new_issue';
return ['mode'=>$mode,'physical_before'=>$v['book']['quantity'],'quantity_milli'=>$linked?'0':(string)$v['quantity_milli'],'return_quantity_milli'=>$return?(string)$v['quantity_milli']:null,'linked_quantity_milli'=>$linked?(string)$v['quantity_milli']:null,'unallocated_before'=>$v['delivery']['remaining_milli']??null,'unallocated_after'=>isset($v['delivery']['remaining_milli'])?(string)BigDecimal::of($v['delivery']['remaining_milli'])->minus($v['quantity_milli']):null,'existing_movement_id'=>$v['delivery']['movement']['id']??null,'physical_after'=>$return?(string)BigDecimal::of($v['book']['quantity'])->plus($v['quantity_milli']):($linked?$v['book']['quantity']:(string)BigDecimal::of($v['book']['quantity'])->minus($v['quantity_milli'])),'source_version'=>(int)$v['entry']['version'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']];
}
private function delivery(array $v,array $b,int $movementId,bool $lock=false,bool $partial=false,bool $returning=false): array
{
abort_unless(($b['eligible_deliveries'][$movementId]['order_id']??null)===(int)$v['entry']['order_id'],403,'Entrega sem elegibilidade explícita para esta ordem.');
$q=DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$movementId,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
$m=$q->first();abort_unless($m,403);
$q=DB::table($b['schema'].'.stock_operations')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$m->stock_operation_id,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
$o=$q->first();abort_unless($o&&$o->kind==='issue'&&$m->movement_type==='issue'&&$m->equipment_id===null&&$m->spare_part_id===null,409,'Movimento incompatível com entrega simples.');
$snapshot=json_decode($m->snapshot,true,512,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['inventory_operation_hash']??null)===$this->hash((array)$o),409,'Entrega sem fotografia íntegra do escritor comum; exige conferência histórica própria.');
abort_if(DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']])->whereNotIn('movement_type',['receipt_in','issue','workshop_return_in'])->exists(),409,'Livro com estornos, transferências ou ajustes fora deste ensaio simples.');
$returns=$this->returnTable($b);
$q=DB::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$v['stock_item_id'],'m.warehouse_id'=>$v['warehouse_id'],'m.movement_type'=>'workshop_return_in']);
if($returns)$q->whereNotExists(fn($x)=>$x->selectRaw('1')->from($returns.' AS r')->whereColumn('r.movement_id','m.id'));
abort_if($q->exists(),409,'Entrada de devolução sem decisão rastreável.');
$total=BigDecimal::of((string)$m->quantity_milli)->negated();
abort_unless($total->isPositive()&&$total->isEqualTo((string)$o->quantity_milli),409,'Quantidade da entrega incompatível com a operação.');
$allocations=$this->allocationTable($b);$used=$allocations?(string)DB::table($allocations)->where('movement_id',$movementId)->sum('quantity_milli'):'0';
$returned=$returns?(string)DB::table($returns)->where('parent_movement_id',$movementId)->sum('quantity_milli'):'0';$remaining=$total->minus($used)->minus($returned);
if($partial||$returning){abort_unless($returning?(($b['stock_returns']??false)&&$returns):(($b['partial_delivery_links']??false)&&$allocations),503,'Tratamento de repartição/devolução não configurado neste ensaio.');abort_unless($remaining->isGreaterThanOrEqualTo($v['quantity_milli']),409,'Quantidade não associada insuficiente. Faça nova conferência.');}
else abort_unless($total->isEqualTo($v['quantity_milli'])&&BigDecimal::of($used)->isZero()&&BigDecimal::of($returned)->isZero(),409,'Entrega e consumo devem ter a mesma quantidade integral; use conferência de repartição quando aplicável.');
abort_unless(\Carbon\CarbonImmutable::parse($m->moved_at)->lessThanOrEqualTo(\Carbon\CarbonImmutable::parse($v['entry']['starts_at'])),409,'A entrega deve preceder o consumo observado.');
abort_if(DB::table($b['schema'].'.ws_claims')->where('movement_id',$movementId)->exists(),409,'Entrega já ligada a outra decisão.');
$v['delivery']=['movement'=>(array)$m,'operation'=>(array)$o,'partial'=>$partial,'returning'=>$returning,'allocated_milli'=>$used,'returned_milli'=>$returned,'remaining_milli'=>(string)$remaining];return $v;
}
public function prepareDelivery(array $d,string $key,bool $partial=false,bool $returning=false): array
{
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();$request=['mode'=>$returning?'return_existing_delivery':($partial?'allocate_existing_delivery':'link_existing_delivery')]+$d;
return DB::transaction(function()use($s,$b,$actor,$d,$key,$request,$partial,$returning){$this->lock($s);$this->scope('prepare');
$old=DB::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true,true,$returning?'returned':'consumed');
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já ligada; exige correcção própria.');
$v=$this->delivery($v,$b,$d['movement_id'],true,$partial,$returning);
$id=DB::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],'command_key'=>$key,'request_hash'=>$this->hash($request),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
Audit::record($returning?'workshop_stock.return_prepared':'workshop_stock.delivery_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version'],'movement_id'=>$d['movement_id'],'physical_effect'=>$returning?(string)$v['quantity_milli']:'0']);
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
}
public function listing(int $page=1,int $proposalPage=1,string $status='all'): array
{
$s=$this->scope('view');$b=$this->binding($s);
$q=DB::table('wo_entries AS e')->join('wo_orders AS o','o.id','=','e.order_id')->join('wo_decisions AS d','d.entry_id','=','e.id')
->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->whereIn('o.id',$b['eligible_order_ids'])
->where(['e.kind'=>'material','e.outside_authority'=>false,'d.decision'=>'verified'])->whereRaw("e.data::jsonb->>'owner'='company'")->whereRaw(($b['stock_returns']??false)?"e.data::jsonb->>'event' IN ('consumed','returned')":"e.data::jsonb->>'event'='consumed'")
->whereNotExists(fn($x)=>$x->selectRaw('1')->from('wo_entries AS n')->whereColumn('n.previous_id','e.id'))
->select(['e.id','e.order_id','e.origin','e.version','e.quantity','e.data'])->orderBy('e.id')->paginate(25,['*'],'page',$page);
$proposalQuery=DB::table($b['schema'].'.ws_proposals AS p')->where($s);
$closureTable=$this->closureTable($b);
if(in_array($status,['withdrawn','rejected'],true)){
if($closureTable)$proposalQuery->whereExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id')->where('z.state',$status));
else $proposalQuery->whereRaw('false');
}
if(in_array($status,['pending','confirmed'],true))$proposalQuery->where(function($query)use($b,$status){
$tables=array_filter([$b['schema'].'.ws_claims',$this->allocationTable($b),$this->returnTable($b)]);
foreach($tables as $i=>$t){$method=$status==='confirmed'?($i===0?'whereExists':'orWhereExists'):'whereNotExists';$query->$method(fn($x)=>$x->selectRaw('1')->from($t.' AS c')->whereColumn('c.proposal_id','p.id'));}
});
if($status==='pending'&&$closureTable)$proposalQuery->whereNotExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id'));
$proposalPager=$proposalQuery->orderByDesc('p.id')->paginate(25,['p.id','p.entry_id','p.entry_version','p.preparer_id','p.reason','p.evidence','p.snapshot','p.source_hash','p.created_at'],'proposal_page',$proposalPage);$proposals=$proposalPager->getCollection();
$keys=array_map(fn($e)=>$this->hash([$s,$e->order_id,$e->origin]),$q->items());$linked=DB::table($b['schema'].'.ws_claims')->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key');
if($t=$this->allocationTable($b))$linked=$linked->merge(DB::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
if($t=$this->returnTable($b))$linked=$linked->merge(DB::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
foreach($q->items() as $e)$e->movement_id=$linked->get($this->hash([$s,$e->order_id,$e->origin]))?->movement_id;
$claims=DB::table($b['schema'].'.ws_claims')->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id'])->keyBy('proposal_id');
if($t=$this->allocationTable($b))foreach(DB::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
if($t=$this->returnTable($b))foreach(DB::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
$closed=$closureTable?DB::table($closureTable)->where($s)->whereIn('proposal_id',$proposals->pluck('id'))->get()->keyBy('proposal_id'):collect();
foreach($proposals as $p){$p->impact=$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR));unset($p->snapshot);$p->movement_id=$claims->get($p->id)?->movement_id;$p->closure=$closed->get($p->id);$p->state=$p->closure?->state??($p->movement_id?'confirmed':'pending');$p->state_version=$p->state==='pending'?1:2;
$p->can_withdraw=$closureTable&&$p->state==='pending'&&(int)$p->preparer_id===$this->context->userId();$p->can_review=$p->state==='pending'&&(int)$p->preparer_id!==$this->context->userId();}
return ['sources'=>$q->items(),'pagination'=>['page'=>$q->currentPage(),'last_page'=>$q->lastPage(),'total'=>$q->total()],
'proposals'=>$proposals,'closure_supported'=>(bool)$closureTable,'proposal_pagination'=>['page'=>$proposalPager->currentPage(),'last_page'=>$proposalPager->lastPage(),'total'=>$proposalPager->total(),'status'=>$status],'notice'=>'Ensaio local: decisões individuais de saída, ligação, repartição e devolução. Nenhum efeito é automático; correcções físicas de material consumido continuam separadas.'];
}
public function prepare(array $d,string $key): array
{
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();
return DB::transaction(function()use($s,$b,$actor,$d,$key){$this->lock($s);$this->scope('prepare');
$old=DB::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true);
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida; correcções exigem compensação própria.');
$id=DB::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],
'command_key'=>$key,'request_hash'=>$this->hash($d),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
Audit::record('workshop_stock.prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version']]);
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
}
public function confirm(int $id,string $reason,bool $receivedInWarehouse=false): array
{
$s=$this->scope('confirm');$b=$this->binding($s);$actor=$this->context->userId();
return DB::transaction(function()use($s,$b,$actor,$id,$reason,$receivedInWarehouse){$this->lock($s);$this->scope('confirm');
$p=DB::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);abort_if((int)$p->preparer_id===$actor,403,'Confirmação deve ser independente da preparação.');
abort_if($this->closure($s,$b,$id),409,'Proposta retirada ou recusada. Faça nova conferência; este original permanece encerrado.');
$old=$this->proposalClaim($b,$id);if($old)return ['ok'=>true,'replayed'=>true,'movement_id'=>(int)$old->movement_id,'claim_id'=>(int)$old->id];
$frozen=json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR);$link=isset($frozen['delivery']);$returning=$link&&($frozen['delivery']['returning']??false);$v=$this->source($s,$b,(int)$p->entry_id,(int)$p->entry_version,$frozen['warehouse_id'],true,$link,$returning?'returned':'consumed');
abort_unless(!$returning||$receivedInWarehouse,422,'Confirme expressamente a recepção física no armazém.');
$partial=$link&&($frozen['delivery']['partial']??false);
if($link)$v=$this->delivery($v,$b,(int)$frozen['delivery']['movement']['id'],true,$partial,$returning);
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida por outra decisão.');
abort_unless(hash_equals($p->source_hash,$this->hash($v)),409,'Origem, mapa ou livro mudou. Faça nova conferência.');
if($link&&!$returning){$operation=(int)$v['delivery']['operation']['id'];$out=['movement_id'=>(int)$v['delivery']['movement']['id']];}
else {
$operation=DB::table($b['schema'].'.stock_operations')->insertGetId(['tenant_id'=>$b['source_tenant_id'],'created_by'=>$actor,'responsible_id'=>$actor,'warehouse_id'=>$v['warehouse_id'],'stock_item_id'=>$v['stock_item_id'],
'kind'=>$returning?'workshop_return':'issue','reference'=>'SGF-WO-'.$v['origin_key'],'idempotency_key'=>(string)Str::uuid(),'payload_hash'=>$p->source_hash,'destination'=>'workshop-origin:'.$v['origin_key'],'reason'=>$reason,
'quantity_milli'=>$v['quantity_milli'],'theoretical_milli'=>$v['book']['quantity'],'ledger_version'=>$v['book']['revision'],'recorded_at'=>now(),
'snapshot'=>json_encode(($returning?['delivery_movement_id'=>(int)$v['delivery']['movement']['id'],'delivery_movement_hash'=>$this->hash($v['delivery']['movement']),'received_in_warehouse'=>$receivedInWarehouse]:[])+['workshop_origin'=>$v['entry'],'verification'=>$v['decision'],'proposal_id'=>$id,'preparer_id'=>$p->preparer_id,'scope'=>$s,'evidence'=>$p->evidence],JSON_THROW_ON_ERROR)]);
$writer=$returning?\Tempo\Inventory\DeliveryReturnWriter::class:\Tempo\Inventory\IssueMovementWriter::class;
$w=new $writer(DB::connection(),$b['schema'],$b['source_tenant_id'],$actor,function(){$this->scope('confirm');});$out=$w->write($operation,$w->preview($operation));
}
$after=$this->impact($v)['physical_after'];
$claim=DB::table($returning?$this->returnTable($b):($partial?$this->allocationTable($b):$b['schema'].'.ws_claims'))->insertGetId($s+($returning?['quantity_milli'=>$v['quantity_milli'],'parent_movement_id'=>(int)$v['delivery']['movement']['id']]:($partial?['quantity_milli'=>$v['quantity_milli']]:[]))+['origin_key'=>$v['origin_key'],'proposal_id'=>$id,'entry_id'=>$p->entry_id,'entry_version'=>$p->entry_version,'operation_id'=>$operation,'movement_id'=>$out['movement_id'],
'preparer_id'=>$p->preparer_id,'confirmer_id'=>$actor,'reason'=>$reason,'before_milli'=>$v['book']['quantity'],'after_milli'=>$after,'source_hash'=>$p->source_hash,'created_at'=>now()]);
Audit::record($returning?'workshop_stock.return_confirmed':($partial?'workshop_stock.delivery_allocated':($link?'workshop_stock.delivery_linked':'workshop_stock.confirmed')),$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['claim_id'=>$claim,'proposal_id'=>$id,'entry_id'=>$p->entry_id,'version'=>$p->entry_version,'movement_id'=>$out['movement_id'],'linked_quantity'=>$link?(string)$v['quantity_milli']:null,'remaining_after'=>($partial||$returning)?$this->impact($v)['unallocated_after']:null,'before'=>$v['book']['quantity'],'after'=>$after,'reason'=>$reason]);
return ['ok'=>true,'replayed'=>false,'movement_id'=>$out['movement_id'],'claim_id'=>$claim];});
}
}