validate();} private function q(array $p){return DB::table('sc_catalogue_versions')->where($p['scope']);} private function latest(array $p){return $this->q($p)->whereNotExists(function($q){$q->selectRaw('1')->from('sc_catalogue_versions as newer')->whereColumn('newer.record_key','sc_catalogue_versions.record_key')->whereColumn('newer.tenant_id','sc_catalogue_versions.tenant_id')->whereColumn('newer.company_id','sc_catalogue_versions.company_id')->whereColumn('newer.unit_id','sc_catalogue_versions.unit_id')->whereColumn('newer.version','>','sc_catalogue_versions.version');});} private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed { abort_unless(config('stock_catalogue.enabled')===true,404);CompositionRuntime::assert();$physical=CompositionRuntime::physical();$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];abort_unless(ModuleRecords::active()==='stock',403); $file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/stock-catalogue-binding.json';$pin=config('stock_catalogue.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);$p=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($p['composition']===$physical['composition']&&$p['scope']===$scope,403); $host=(int)$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[])); try{return $rt->run($right,function($a,$b,$binding)use($p,$host,$generation,$right,$fn){ $a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);abort_unless($a->tenant_id===$p['scope']['tenant_id']&&!empty($b['warehouse_ids']),403); if($right!=='stock.view'){$prepare=$right==='stock.items.prepare';abort_unless($host===$p[$prepare?'preparer_host_id':'reviewer_host_id']&&$a->id===$p[$prepare?'preparer_native_id':'reviewer_native_id'],403);} $meta=DB::table('sc_catalogue_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$p['composition']&&hash_equals($meta->source_hash,(string)config('stock_catalogue.schema_sha256')),503,'Schema de catálogo não conferido.'); return $fn($a,$p,$host,$generation,$this->hash([$binding,$p])); });}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Catálogo ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');} } private function input(array $v):array { if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:2000']);$d['record_key']=strtolower($d['record_key']);$d['version']=(int)$d['version']; if($d['action']==='prepare'){$data=$d['data'];foreach(['code','name','unit']as$key)if(isset($data[$key])&&is_string($data[$key]))$data[$key]=trim($data[$key]);$d['data']=$this->exact($data,['code'=>['required','string','max:30','regex:/^[A-Z0-9][A-Z0-9_.-]{1,29}$/D'],'name'=>'required|string|min:3|max:150','unit'=>['required','string','max:12','regex:/^[A-Z][A-Z0-9]{0,11}$/D']]);} else $d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d; } private function basis(array $p,string $key,bool $lock=false):array { $last=$this->q($p)->where('record_key',$key)->orderByDesc('version')->first();$approved=$this->q($p)->where(['record_key'=>$key,'state'=>'approved'])->orderByDesc('version')->first();$item=null;if($approved){$q=DB::table('stock_items')->where(['tenant_id'=>$p['scope']['tenant_id'],'id'=>$approved->stock_item_id]);if($lock)$q->lockForUpdate();$item=$q->first(['id','code','name','unit']);abort_unless($item,409,'Artigo de origem indisponível.');$saved=json_decode($approved->data,true);abort_unless($item->code===$saved['code']&&$item->unit===$saved['unit']&&$item->name===$saved['name'],409,'Catálogo alterado fora desta versão; exige conferência.');} return ['version'=>(int)($last?->version??0),'last'=>$last?(array)$last:null,'approved'=>$approved?(array)$approved:null,'item'=>$item?(array)$item:null]; } private function execute(object $a,array $p,int $host,array $d,array $before):array { abort_unless($before['version']===$d['version'],409,'A versão mudou. Confira novamente.');$last=$before['last'];$approved=$before['approved'];$item=$before['item'];$data=$d['data'];$itemId=$item['id']??null;$willCreate=false;$willRename=false; if($d['action']==='prepare'){ abort_unless($host===$p['preparer_host_id'],403);$state='prepared';$prepared=$host;$preparedNative=$a->id; if($approved){abort_unless($data['code']===$item['code']&&$data['unit']===$item['unit'],422,'Código e unidade são imutáveis.');abort_if($data['name']===$item['name'],422,'Indique uma alteração de designação.');} else abort_if(DB::table('stock_items')->where(['tenant_id'=>$a->tenant_id,'code'=>$data['code']])->exists(),409,'Código já existente; não associar artigo anterior automaticamente.'); }else{ abort_unless($last&&$last['state']==='prepared'&&$host!==$last['prepared_host_id']&&$a->id!==$last['prepared_native_id'],403,'Revisão exige preparação alheia actual.');$state=$data['decision'];$data=json_decode($last['data'],true);$prepared=$last['prepared_host_id'];$preparedNative=$last['prepared_native_id']; if($state==='approved'){ if($item){abort_unless($data['code']===$item['code']&&$data['unit']===$item['unit'],409);$changed=DB::table('stock_items')->where(['id'=>$itemId,'tenant_id'=>$a->tenant_id,'code'=>$item['code'],'unit'=>$item['unit'],'name'=>$item['name']])->update(['name'=>$data['name'],'updated_at'=>now()->utc()]);abort_unless($changed===1,409);$willRename=true;} else{abort_if(DB::table('stock_items')->where(['tenant_id'=>$a->tenant_id,'code'=>$data['code']])->exists(),409,'Código entretanto utilizado; confira novamente.');$itemId=DB::table('stock_items')->insertGetId(['tenant_id'=>$a->tenant_id,'created_by'=>$a->id,'code'=>$data['code'],'name'=>$data['name'],'unit'=>$data['unit'],'created_at'=>now()->utc(),'updated_at'=>now()->utc()]);$willCreate=true;} } } $version=$before['version']+1;$id=DB::table('sc_catalogue_versions')->insertGetId($p['scope']+['record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_host_id'=>$prepared,'prepared_native_id'=>$preparedNative,'host_actor_id'=>$host,'actor_id'=>$a->id,'stock_item_id'=>$itemId,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_at'=>now()->utc()]); $impact=['before'=>['version'=>$before['version'],'state'=>$last['state']??null,'item'=>$item],'after'=>['version'=>$version,'state'=>$state,'data'=>$data],'creates_article'=>$willCreate,'renames_owned_article'=>$willRename,'stock_movements'=>0,'journal_entries'=>0,'initial_balance'=>null]; return ['id'=>$id,'record_key'=>$d['record_key'],'version'=>$version,'state'=>$state,'stock_item_id'=>$itemId,'impact'=>$impact]; } public function preview(Request $r,ReceiptRuntime $rt) { $d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$rt,'stock.items.'.($d['action']==='prepare'?'prepare':'review'),function($a,$p,$host,$generation,$binding)use($d,$key){$before=$this->basis($p,$d['record_key'],true);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($a,$p,$host,$d,$before);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$host,'generation'=>$generation,'binding'=>$binding,'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($before),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']])->header('Cache-Control','no-store');}); } public function confirm(Request $r,ReceiptRuntime $rt) { $body=$this->exact($r->all(),['preview'=>'required|string|max:65536']);try{$cut=json_decode(Crypt::decryptString($body['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($cut)&&is_array($cut['input']??null),409);$d=$this->input($cut['input']);return$this->run($r,$rt,'stock.items.'.($d['action']==='prepare'?'prepare':'review'),function($a,$p,$host,$generation,$binding)use($cut,$d){abort_unless(($cut['actor']??null)===$host&&($cut['generation']??null)===$generation&&($cut['binding']??null)===$binding&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia/contexto alterados.');$scope=$p['scope']+['host_actor_id'=>$host,'command_key'=>$cut['key']];$hash=$this->hash($d);$old=DB::table('sc_catalogue_commands')->where($scope)->first();if($old){abort_unless(hash_equals($old->request_hash,$hash)&&hash_equals($old->binding_hash,$binding),409,'Chave utilizada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true]);}$before=$this->basis($p,$d['record_key'],true);abort_unless(hash_equals($cut['basis'],$this->hash($before)),409,'Catálogo alterado desde a prévia.');$out=$this->execute($a,$p,$host,$d,$before);abort_unless($out['impact']===$cut['impact'],409,'Impacto alterado.');app(RequisitionWorkflow::class)->audit($a,'sgf.stock.catalogue.command',null,$before,['host_actor_id'=>$host,'record_key'=>$d['record_key'],'operation_key'=>$cut['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('sc_catalogue_commands')->insert($scope+['actor_id'=>$a->id,'request_hash'=>$hash,'binding_hash'=>$binding,'input'=>json_encode($d,JSON_THROW_ON_ERROR),'result'=>json_encode($out,JSON_THROW_ON_ERROR),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');}); } public function receipt(Request $r,ReceiptRuntime $rt,string $key) { abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$rt,'stock.view',function($a,$p,$host,$g,$binding)use($key){$old=DB::table('sc_catalogue_commands')->where($p['scope']+['host_actor_id'=>$host,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);return response()->json(['result'=>json_decode($old->result,true),'reason'=>json_decode($old->input,true)['reason'],'created_at'=>$old->created_at,'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');}); } public function screen(Request $r,ReceiptRuntime $rt){abort_if($r->query()!==[],422);return$this->run($r,$rt,'stock.view',fn()=>response()->view('stock-catalogue-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));} public function index(Request $r,ReceiptRuntime $rt) { $d=$this->exact($r->query(),['view'=>'sometimes|in:items,proposals,history','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:prepared,approved,refused','record_key'=>'required_if:view,history|prohibited_unless:view,history|uuid','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$hostDb=DB::connection();return$this->run($r,$rt,'stock.view',function($a,$p,$host)use($d,$hostDb){$view=$d['view']??'proposals';$size=(int)($d['per_page']??25);$page=(int)($d['page']??1);if($view==='items'){$approved=$this->q($p)->where('state','approved')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('sc_catalogue_versions as next')->whereColumn('next.record_key','sc_catalogue_versions.record_key')->whereColumn('next.version','>','sc_catalogue_versions.version')->whereColumn('next.tenant_id','sc_catalogue_versions.tenant_id')->whereColumn('next.company_id','sc_catalogue_versions.company_id')->whereColumn('next.unit_id','sc_catalogue_versions.unit_id')->where('next.state','approved'));$q=DB::table('stock_items as i')->where('i.tenant_id',$a->tenant_id)->leftJoinSub($approved,'v','v.stock_item_id','=','i.id')->select(['i.id','i.code','i.name','i.unit','v.record_key','v.version','v.state']);$code='i.code';$name='i.name';$order='i.id';} else{$q=$view==='history'?$this->q($p)->where('record_key',$d['record_key']):$this->latest($p);if($view==='history')abort_unless((clone$q)->exists(),403,'Histórico fora deste contexto.');if(isset($d['state']))$q->where('state',$d['state']);$code="data->>'code'";$name="data->>'name'";$order='id';} if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($v)=>$v->whereRaw("$code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("$name ILIKE ? ESCAPE '!'",[$needle]));}$rows=$q->orderByDesc($order)->paginate($size,['*'],'page',$page);foreach($rows->items()as$row)if(isset($row->data))$row->data=json_decode($row->data,true);return response()->json(['view'=>$view,'records'=>$rows,'capabilities'=>['prepare'=>$host===$p['preparer_host_id']&&$hostDb->table('permission_grants')->where($p['scope']+['user_id'=>$host,'permission'=>'stock.items.prepare'])->exists(),'review'=>$host===$p['reviewer_host_id']&&$hostDb->table('permission_grants')->where($p['scope']+['user_id'=>$host,'permission'=>'stock.items.review'])->exists()],'notice'=>'Artigos existentes sem proveniência deste percurso são apenas consultados. Preparar não cria artigo ou saldo.'])->header('Cache-Control','no-store');}); } }