Files
Investidor-V2/tools/portal-internal-designation-service.php
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

103 lines
8.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/** Owner-directed staff assignments use existing identities, organizations and grants. */
class PortalInternalDesignation
{
public function organizations(): array
{
return DB::table('organizations')->where('source_system','portal-operator-master')->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')->where('action','operator_organization_registered')->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.');
return ['id'=>$r->id,'organization'=>$known,'message'=>'Organização existente recuperada.'];
}
$id=$r->id;
DB::table('organizations')->where('id',$id)->update(['external_id'=>$v['nuit'],'updated_at'=>now(),'version_no'=>$r->version_no+1]);
// Preserve its name/provenance. A spelling difference needs explicit reconciliation.
abort_unless($r->name===$v['name'],409,'Conservar denominação anterior até reconciliar expressamente.');
}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,'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)');
$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;
});
}
}