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']??'')||(ComposedWorkshopStockBook::active()&&($b['schema']??null)==='public'))&&!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]=ComposedWorkshopStockBook::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]=ComposedWorkshopStockBook::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]=ComposedWorkshopStockBook::hasTable($table); return $this->allocationTables[$table]?$table:null; } private function closure(array $s,array $b,int $id): ?object { return ($table=$this->closureTable($b))?ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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 ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('origin_key',$origin)->exists()||(($t=$this->allocationTable($b))&&ComposedWorkshopStockBook::table($t)->where('origin_key',$origin)->exists())||(($t=$this->returnTable($b))&&ComposedWorkshopStockBook::table($t)->where('origin_key',$origin)->exists()); } private function proposalClaim(array $b,int $id): ?object { return ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('proposal_id',$id)->first()??(($t=$this->allocationTable($b))?ComposedWorkshopStockBook::table($t)->where('proposal_id',$id)->first():null)??(($t=$this->returnTable($b))?ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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(ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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(ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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)ComposedWorkshopStockBook::table($allocations)->where('movement_id',$movementId)->sum('quantity_milli'):'0'; $returned=$returns?(string)ComposedWorkshopStockBook::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(ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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(ComposedWorkshopStockBook::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key')); if($t=$this->returnTable($b))$linked=$linked->merge(ComposedWorkshopStockBook::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key')); $physicalWarehouses=collect();$physicalDeliveries=collect(); if(ComposedWorkshopStockBook::active()){ $warehouses=[];$articles=[];foreach($q->items() as $entry){$data=json_decode($entry->data,true);$map=$b['resources'][$data['resource'].'|'.$data['unit']]??null;if($map){$warehouses=[...$warehouses,...$map['warehouse_ids']];$articles[]=$map['stock_item_id'];}} $orders=array_column($q->items(),'order_id');$deliveries=[];foreach($b['eligible_deliveries'] as $id=>$known)if(in_array($known['order_id'],$orders,true))$deliveries[]=(int)$id; $physicalWarehouses=ComposedWorkshopStockBook::table($b['schema'].'.warehouses')->where('tenant_id',$b['source_tenant_id'])->whereIn('id',array_unique($warehouses))->orderBy('id')->get(['id','code','name']); $physicalDeliveries=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements as m')->join($b['schema'].'.stock_operations as o','o.id','=','m.stock_operation_id')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.movement_type'=>'issue'])->whereIn('m.stock_item_id',array_unique($articles))->whereIn('m.id',$deliveries)->whereIn('m.warehouse_id',array_unique($warehouses))->orderBy('m.id')->get(['m.id','m.stock_item_id','o.reference','m.warehouse_id','m.quantity_milli']); } foreach($q->items() as $e){ $e->movement_id=$linked->get($this->hash([$s,$e->order_id,$e->origin]))?->movement_id; if(ComposedWorkshopStockBook::active()){ $data=json_decode($e->data,true);$map=$b['resources'][$data['resource'].'|'.$data['unit']]??null; $e->warehouses=$physicalWarehouses->whereIn('id',$map['warehouse_ids']??[])->values()->all(); $deliveries=[];foreach(($b['eligible_deliveries']??[]) as $id=>$known)if($known['order_id']===$e->order_id)$deliveries[]=(int)$id; $e->deliveries=$physicalDeliveries->where('stock_item_id',$map['stock_item_id']??0)->whereIn('id',$deliveries)->whereIn('warehouse_id',$map['warehouse_ids']??[])->values()->all(); } } $claims=ComposedWorkshopStockBook::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(ComposedWorkshopStockBook::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(ComposedWorkshopStockBook::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a); $closed=$closureTable?ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::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=ComposedWorkshopStockBook::table($b['schema'].'.stock_operations')->insertGetId(['tenant_id'=>$b['source_tenant_id'],'created_by'=>ComposedWorkshopStockBook::actor($actor),'responsible_id'=>ComposedWorkshopStockBook::actor($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(ComposedWorkshopStockBook::connection(),$b['schema'],$b['source_tenant_id'],ComposedWorkshopStockBook::actor($actor),function(){$this->scope('confirm');});$out=$w->write($operation,$w->preview($operation)); } $after=$this->impact($v)['physical_after']; $claim=ComposedWorkshopStockBook::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];}); } }