run($right,$work); } catch(\Illuminate\Database\QueryException $e){ if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e; return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação; não crie outra chave.'],503)->header('Cache-Control','no-store')->header('Retry-After','5'); } } private function guard(): void { abort_unless(config('stock_operations.enabled') === true,404); } private function hash(array $value): string { return hash('sha256',json_encode($value,JSON_THROW_ON_ERROR)); } private function generation(): string { return $this->hash(request()->session()->get('company_context',[])); } private function operator(object $actor): object { $actor=app(CurrentActor::class)->lock($actor,['warehouse']); abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor),403); return $actor; } private function entities(array $data,array $binding,object $actor): array { $warehouses=array_values(array_unique([$data['warehouse_id'],...($data['kind']==='transfer'?[$data['destination_warehouse_id']]:[])])); foreach($warehouses as $id) abort_unless(in_array($id,$binding['warehouse_ids']??[],true),403,'Armazém fora do contexto autorizado.'); $item=StockItem::where('tenant_id',$actor->tenant_id)->findOrFail($data['stock_item_id']); $rows=Warehouse::where('tenant_id',$actor->tenant_id)->whereIn('id',$warehouses)->orderBy('id')->get(); abort_unless($rows->count()===count($warehouses),403); $responsible=User::where('tenant_id',$actor->tenant_id)->findOrFail($data['responsible_id']); $basis=['item'=>$item->only(['id','code','name','unit']),'warehouses'=>$rows->map->only(['id','code','name'])->all(), 'responsible'=>$responsible->only(['id','name']), 'balances'=>array_map(fn($id)=>['warehouse_id'=>$id]+app(StockWorkflow::class)->balance($item->id,$id),$warehouses)]; if($data['kind']==='adjustment'){ $count=StockOperation::where('tenant_id',$actor->tenant_id)->where('kind','count')->where('stock_item_id',$item->id)->where('warehouse_id',$data['warehouse_id'])->findOrFail($data['count_id']); $basis['count']=$count->toArray();$basis['count_applied']=StockOperation::where('tenant_id',$actor->tenant_id)->where('count_id',$count->id)->exists(); } return $basis; } private function execute(array $data,Request $http,object $actor): array { $input=Request::create('/embedded/stock-operation','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']); $input->setUserResolver(fn($guard=null)=>$actor); if($http->hasSession()) $input->setLaravelSession($http->session()); $response=(new \Tempo\Purchasing\Embedded\Http\Controllers\StockOperationController)->store($input,app(StockWorkflow::class)); $operation=StockOperation::findOrFail($response->getData(true)['id']); $difference=match($operation->kind){'count'=>0,'adjustment'=>(int)$operation->quantity_milli-(int)$operation->theoretical_milli,default=>-(int)$operation->quantity_milli}; return ['operation'=>$operation,'impact'=>[ 'kind'=>$operation->kind,'reference'=>$operation->reference,'quantity_milli'=>$operation->quantity_milli, 'stock_item_id'=>$operation->stock_item_id,'warehouse_id'=>$operation->warehouse_id, 'destination_warehouse_id'=>$operation->destination_warehouse_id,'destination'=>$operation->destination, 'theoretical_milli'=>$operation->theoretical_milli,'snapshot'=>$operation->snapshot, 'change_milli'=>$difference,'quantity_after_milli'=>(int)$operation->theoretical_milli+$difference, 'destination_quantity_after_milli'=>$operation->kind==='transfer'?app(StockWorkflow::class)->balance($operation->stock_item_id,$operation->destination_warehouse_id)['quantity']:null, 'reason'=>$operation->reason,'financial_posting'=>false, ]]; } public function screen(Request $r,ReceiptRuntime $rt) { $this->guard();$access=$this->run($rt,'stock.view',fn($a)=>$this->operator($a)); if($access instanceof \Symfony\Component\HttpFoundation\Response)return $access; return response()->view('stock-operations-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()])->header('Cache-Control','no-store'); } public function index(Request $r,ReceiptRuntime $rt) { $this->guard();$d=$r->validate(['view'=>'required|in:items,warehouses,people,equipment,counts,history','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','warehouse_id'=>'required_if:view,counts|prohibited_unless:view,counts|integer|min:1','stock_item_id'=>'required_if:view,counts|prohibited_unless:view,counts|integer|min:1']); abort_if(array_diff(array_keys($r->query()),array_keys($d)),422); return $this->run($rt,'stock.view',function($a,$b)use($d){ $a=$this->operator($a);$kind=$d['view']; if($kind==='counts')abort_unless(in_array((int)$d['warehouse_id'],$b['warehouse_ids']??[],true),403); $q=match($kind){'items'=>StockItem::where('tenant_id',$a->tenant_id),'warehouses'=>Warehouse::where('tenant_id',$a->tenant_id)->whereIn('id',$b['warehouse_ids']??[]),'people'=>User::where('tenant_id',$a->tenant_id),'equipment'=>\Tempo\Purchasing\Embedded\Models\Equipment::where('tenant_id',$a->tenant_id),'counts'=>StockOperation::where('tenant_id',$a->tenant_id)->where('kind','count')->where('warehouse_id',$d['warehouse_id'])->where('stock_item_id',$d['stock_item_id'])->whereNotIn('id',StockOperation::where('tenant_id',$a->tenant_id)->whereNotNull('count_id')->select('count_id')),default=>StockOperation::where('tenant_id',$a->tenant_id)->whereIn('warehouse_id',$b['warehouse_ids']??[])->where(fn($q)=>$q->whereNull('destination_warehouse_id')->orWhereIn('destination_warehouse_id',$b['warehouse_ids']??[]))}; if(($d['search']??'')!==''){ $search='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%'; $q->whereRaw((in_array($kind,['history','counts'],true)?'reference':($kind==='equipment'?'description':'name'))." ILIKE ? ESCAPE '!'",[$search]); } $fields=match($kind){'items'=>['id','code','name','unit'],'warehouses'=>['id','code','name'],'people'=>['id','name'],'equipment'=>['id','code','description as name'],'counts'=>['id','reference as name','quantity_milli','ledger_version'],default=>['id','kind','reference','stock_item_id','warehouse_id','destination_warehouse_id','quantity_milli','reason','recorded_at']}; return response()->json($q->orderBy('id',$kind==='history'?'desc':'asc')->paginate((int)($d['per_page']??25),$fields,'page',(int)($d['page']??1)))->header('Cache-Control','no-store'); }); } public function basis(Request $r,ReceiptRuntime $rt) { $this->guard();$d=$this->data($r->query()); return $this->run($rt,'stock.view',function($a,$b)use($d){$a=$this->operator($a);$basis=$this->entities($d,$b,$a);return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis)])->header('Cache-Control','no-store');}); } private function data(array $data): array { // Returns retain their own declaration and independent reception workflow. $validated=Validator::make($data,[ 'kind'=>'required|in:issue,transfer,count,adjustment','warehouse_id'=>'required|integer|min:1','stock_item_id'=>'required|integer|min:1', 'responsible_id'=>'required|integer|min:1','destination_warehouse_id'=>'required_if:kind,transfer|prohibited_unless:kind,transfer|integer|min:1', 'count_id'=>'required_if:kind,adjustment|prohibited_unless:kind,adjustment|integer|min:1', ])->after(function($v)use($data){if(array_diff(array_keys($data),['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id']))$v->errors()->add('data','Campos não reconhecidos.');})->validate(); foreach(['warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'] as $key) if(isset($validated[$key])) $validated[$key]=(int)$validated[$key]; return $validated; } public function preview(Request $r,ReceiptRuntime $rt) { $this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','data'=>'required|array:kind,warehouse_id,stock_item_id,responsible_id,destination_warehouse_id,quantity,reference,reason,destination,equipment_id,count_id,ledger_version']); abort_if(array_diff(array_keys($r->all()),array_keys($d)),422); $d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation(); return $this->run($rt,'stock.operations.prepare',function($a,$b,$binding)use($d,$r,$generation){ $a=$this->operator($a);$data=$d['data'];$keys=['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id']; $basis=$this->entities($this->data(array_intersect_key($data,array_flip($keys))),$b,$a); abort_unless(hash_equals($this->hash($basis),$d['revision']),409,'O saldo ou o contexto mudou. Confira novamente.'); abort_if(StockOperation::where('idempotency_key',$d['operation_key'])->exists(),409,'Operação já registada. Consulte o histórico.'); $db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction(); try{$out=$this->execute($data+['idempotency_key'=>$d['operation_key']],$r,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();} $cut=$d+['actor'=>$a->id,'binding'=>$binding,'generation'=>$generation,'observed'=>time(),'impact'=>$this->hash($out['impact'])]; return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'basis'=>$basis,'impact'=>$out['impact']])->header('Cache-Control','no-store'); }); } public function confirm(Request $r,ReceiptRuntime $rt) { $this->guard();$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422); try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');} $generation=$this->generation(); return $this->run($rt,'stock.operations.confirm',function($a,$b,$binding,$origin)use($cut,$generation,$r){ $a=$this->operator($a); abort_unless(($cut['actor']??null)===$a->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia expirada ou contexto alterado.'); $keys=['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id']; $basis=$this->entities($this->data(array_intersect_key($cut['data'],array_flip($keys))),$b,$a); $hash=$this->hash([$cut['operation_key'],$cut['data']]); $old=AuditLog::where('tenant_id',$a->tenant_id)->where('user_id',$a->id)->where('action','sgf.stock.command')->where('after->operation_key',$cut['operation_key'])->first(); if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$binding,409);return response()->json($old->after['result']+['replayed'=>true])->header('Cache-Control','no-store');} abort_unless(hash_equals($this->hash($basis),$cut['revision']),409,'Movimentos posteriores à conferência. Actualize o saldo.'); abort_if(StockOperation::where('idempotency_key',$cut['operation_key'])->exists(),409,'Chave utilizada fora desta conferência.'); $out=$this->execute($cut['data']+['idempotency_key'=>$cut['operation_key']],$r,$a); abort_unless(hash_equals($cut['impact'],$this->hash($out['impact'])),409,'Dependências alteradas. Confira novamente.'); $result=['operation_id'=>$out['operation']->id,'kind'=>$out['operation']->kind,'reference'=>$out['operation']->reference]; app(RequisitionWorkflow::class)->audit($a,'sgf.stock.command',null,$basis,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$binding,'reason'=>$out['operation']->reason,'result'=>$result,'impact'=>$out['impact']]); return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store'); }); } }