Files
Investidor-V2/app/Services/PortalInternalDesignation.php
T

143 lines
10 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
{
/**
* @return array<array-key, mixed>
*/
public function organizations(): array
{
return DB::table('organizations')->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();
}
/**
* @return array<array-key, mixed>|null
*/
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']];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
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.'];
});
}
/**
* @param array<array-key, mixed> $input
*/
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::whereKey($v['user_id'])->firstOrFail();
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;
});
}
}