whereIn(DB::raw('id::text'), DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered')->selectRaw("after_data->>'organization_id'"))->whereNull('deleted_at') ->get(['id','name','external_id','version_no'])->map(fn($r)=>$this->organization($r->id))->filter()->values()->all(); } public function organization(?string $id): ?array { if ($id===null) return null; $r=DB::table('organizations')->where('id',$id)->whereNull('deleted_at')->first(); if (!$r) return null; $event=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered') ->where('after_data->organization_id',$id)->orderByDesc('occurred_at')->first(); if (!$event) return null; $data=json_decode($event->after_data,true,512,JSON_THROW_ON_ERROR); if (($data['organization']['name'] ?? null)!==$r->name || ($data['organization']['nuit'] ?? null)!==$r->external_id) return null; return ['id'=>$r->id,'version'=>$r->version_no,...$data['organization'],'source_reference'=>$data['source_reference']]; } public function registerOrganization(User $actor,array $input): array { app(PortalProfileAdministration::class)->administrative($actor); $v=Validator::make($input,['operation_key'=>'required|uuid','name'=>'required|string|max:200','nuit'=>'required|string|regex:/^[0-9]{9}$/', 'contact'=>'required|string|regex:/^\+258[0-9]{9}$/','director'=>'required|string|max:200', 'function'=>'required|in:portal_owner_operator','source_reference'=>'required|string|min:20|max:1000'])->validate(); return DB::transaction(function()use($actor,$v){ DB::select('SELECT pg_advisory_xact_lock(101026,40001)'); $payload=array_intersect_key($v,array_flip(['name','nuit','contact','director','function'])); $existingEvent=DB::table('audit_events')->where('source_system','portal-profiles-v1')->whereIn('action',['operator_organization_registered','operator_organization_registration_recovered'])->where('after_data->operation_key',$v['operation_key'])->first(); if($existingEvent){ $e=json_decode($existingEvent->after_data,true); abort_unless($e['organization']==$payload && $e['source_reference']===$v['source_reference'],409,'Esta chave corresponde a outro cadastro.'); return ['id'=>$e['organization_id'],'organization'=>$this->organization($e['organization_id']),'message'=>'Cadastro recuperado; não repetido.']; } $names=array_unique([$v['name'],preg_replace('/\s*,\s*/',' ',$v['name'])]); $matches=DB::table('organizations')->where(fn($q)=>$q->where('external_id',$v['nuit'])->orWhereIn('name',$names)->orWhere('name',str_replace(' Lda',', Lda',$v['name'])))->lockForUpdate()->get(); abort_if($matches->count()>1,409,'Correspondência ambígua no cadastro mestre; não criar duplicado.'); $r=$matches->first(); if($r){ abort_if($r->deleted_at!==null || ($r->external_id!==null && $r->external_id!==$v['nuit']),409,'Identidade ou estado da organização exige reconciliação.'); $known=$this->organization($r->id); if($known){ abort_unless(array_intersect_key($known,$payload)==$payload,409,'Dados do cadastro já existente diferem; preservar a versão anterior.'); app(PortalProfileAccess::class)->audit($actor,'operator_organization_registration_recovered',['operation_key'=>$v['operation_key'],'organization_id'=>$r->id,'organization'=>$payload,'source_reference'=>$v['source_reference']]); return ['id'=>$r->id,'organization'=>$known,'message'=>'Organização existente recuperada.']; } $id=$r->id; $before=['name'=>$r->name,'external_id'=>$r->external_id,'source_system'=>$r->source_system,'version_no'=>$r->version_no]; abort_unless(in_array($r->name,$names,true) || $r->name===str_replace(' Lda',', Lda',$v['name']),409,'Denominação divergente exige reconciliação expressa.'); DB::table('organizations')->where('id',$id)->update(['name'=>$v['name'],'external_id'=>$v['nuit'],'updated_at'=>now(),'version_no'=>$r->version_no+1]); // Reconcile only the explicit comma alias; preserve previous values in audit. }else{ $id=(string)Str::uuid(); DB::table('organizations')->insert(['id'=>$id,'name'=>$v['name'],'external_id'=>$v['nuit'],'source_system'=>'portal-operator-master','organization_type'=>'private']); } app(PortalProfileAccess::class)->audit($actor,'operator_organization_registered',['operation_key'=>$v['operation_key'],'organization_id'=>$id, 'organization'=>$payload,'previous_master_values'=>$before??null,'source_reference'=>$v['source_reference'],'metadata_storage'=>'existing_audit_snapshot','no_corporate_membership_created'=>true]); return ['id'=>$id,'organization'=>$this->organization($id),'message'=>'Organização operadora registada no cadastro mestre. Nenhuma representação ou papel foi atribuído.']; }); } public function grant(User $actor,array $input): string { app(PortalProfileAdministration::class)->administrative($actor); $v=Validator::make($input,['operation_key'=>'required|uuid','user_id'=>'required|integer|exists:users,id','role'=>['required',Rule::in(['promoter','validator','publisher'])], 'operator_organization_id'=>'required|uuid|exists:organizations,id','organization_id'=>'nullable|uuid|exists:organizations,id', 'opportunity_types'=>'required|array|min:1','opportunity_types.*'=>['required',Rule::in(OpportunityCanonicalContract::BASE_CATEGORIES)], 'confirmed_internal_designation'=>'required|accepted','reason'=>'required|string|min:20|max:1000', 'responsibilities'=>'sometimes|array|max:0','expires_at'=>'nullable|date|after:now'])->validate(); abort_if((int)$v['user_id']===$actor->id,403,'Não pode aprovar a sua atribuição.'); $subject=User::findOrFail($v['user_id']); abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret!==null && $subject->two_factor_confirmed_at!==null,422,'Confirmar contacto e o próprio MFA antes da atribuição interna.'); $organization=$this->organization($v['operator_organization_id']); abort_unless($organization!==null,422,'Selecione a organização operadora cadastrada; não exige comprovativos de Promotor externo.'); if($v['role']==='promoter')abort_unless(($v['organization_id']??null)===$v['operator_organization_id'],422,'O vínculo interno é à organização operadora, não uma representação de gestão empresarial.'); return DB::transaction(function()use($actor,$v){ DB::select('SELECT pg_advisory_xact_lock(101026,40002)'); $subject=User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail(); abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret!==null && $subject->two_factor_confirmed_at!==null && $this->organization($v['operator_organization_id'])!==null,422,'Conta ou organização já não reúne as condições da atribuição.'); $e=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','role_granted')->where('after_data->operation_key',$v['operation_key'])->first(); $payload=array_intersect_key($v,array_flip(['user_id','role','operator_organization_id','organization_id','opportunity_types','reason','expires_at'])); if($e){$d=json_decode($e->after_data,true);abort_unless($d['designation']==$payload,409,'Chave usada numa atribuição diferente.');abort_unless(DB::table('portal_role_assignments')->where('id',$d['assignment_id'])->whereNull('revoked_at')->exists(),409,'A atribuição foi revogada.');return $d['assignment_id'];} $duplicate=DB::table('portal_role_assignments')->where('user_id',$v['user_id'])->where('role',$v['role'])->where('source','internal-portal-designation')->whereNull('revoked_at')->where('starts_at','<=',now())->where(fn($q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now()))->exists(); abort_if($duplicate,409,'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.'); $id=(string)Str::uuid(); app(PortalProfileAdministration::class)->insertGrant($id,$actor,[...$v,'source'=>'internal-portal-designation']); app(PortalProfileAccess::class)->audit($actor,'role_granted',['assignment_id'=>$id,'subject_user_id'=>$v['user_id'],'role'=>$v['role'], 'operation_key'=>$v['operation_key'],'designation'=>$payload,'operator_organization_id'=>$v['operator_organization_id'], 'reason'=>$v['reason'],'authority'=>'portal-owner-internal-designation','external_promoter_documents_required'=>false,'no_corporate_membership_created'=>true]); return $id; }); } }