Files
Investidor-V2/app/Http/Controllers/PortalManagementController.php
T
nala 01405cbf8a
tests / ci (push) Canceled after 0s
oportunidades 5
2026-10-10 14:15:28 +02:00

369 lines
21 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PortalManagementController extends Controller
{
private const STATES = ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'];
public function history(): Response
{
$events = DB::table('audit_events')
->leftJoin('users', 'users.id', '=', 'audit_events.actor_user_id')
->whereIn('audit_events.source_system', ['portal-management', 'investment-context', 'investment-submission', 'investment-workspace', 'investment-zones'])
->orderByDesc('audit_events.occurred_at')->orderByDesc('audit_events.id')
->select(['audit_events.id', 'audit_events.entity_id', 'entity_type', 'action', 'occurred_at', 'before_data', 'after_data', 'users.name as actor'])
->paginate(20)->through(function ($event) {
$before = json_decode($event->before_data ?? '{}', true) ?? [];
$after = json_decode($event->after_data ?? '{}', true) ?? [];
$values = $after['values'] ?? array_intersect_key($after, array_flip(['version', 'status', 'active']));
$event->before_data = ['values' => $before['values'] ?? []];
$event->after_data = ['record_id' => $after['record_id'] ?? $event->entity_id,
'changed_fields' => $after['changed_fields'] ?? array_keys($values), 'values' => $values];
return $event;
});
return Inertia::render('management/history', ['events' => $events]);
}
public function interests(Request $request): Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
'country' => ['nullable', app(CountryCatalogue::class)->rule()],
'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])],
]);
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
if ($country = $filters['country'] ?? null) {
$query->where('country', $country);
}
if ($origin = $filters['origin'] ?? null) {
if ($origin === 'unknown') {
$query->where(fn ($q) => $q->whereNull('metadata->declared_origin_country_code')->orWhereNotIn('metadata->declared_origin_country_code', array_column(app(CountryCatalogue::class)->all(), 'code')));
} else {
$query->where('metadata->declared_origin_country_code', $origin);
}
}
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%"));
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if (($filters['follow_up'] ?? null) === 'overdue') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '<', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'upcoming') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '>=', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'unassigned') {
$query->whereNull('assigned_to')->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString()->through(function ($row) {
$result = $row->toArray();
$result['declaredOrigin'] = app(CountryCatalogue::class)->describe($row->metadata['declared_origin_country_code'] ?? null);
$country = app(CountryCatalogue::class)->describe($row->country);
$result['countryName'] = $country['code'] !== null ? $country['name'] : $row->country;
unset($result['metadata']);
return $result;
}),
'filters' => $filters,
'countries' => app(CountryCatalogue::class)->all(),
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null),
]);
}
public function interest(InterestRequest $interest): Response
{
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) {
$row->note = $row->reason ? Crypt::decryptString($row->reason) : null;
unset($row->reason);
return $row;
});
$consents = DB::table('consents')->where('interest_request_id', $interest->id)
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
return Inertia::render('management/interest', [
'declaredOrigin' => app(CountryCatalogue::class)->describe($interest->metadata['declared_origin_country_code'] ?? null),
'interest' => $interest->only([
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at',
]),
'history' => $history,
'consents' => $consents,
'assignees' => $this->interestAssignees()->get(['id', 'name']),
]);
}
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{
$validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
]);
$before = $interest->status;
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
$validated['next_action'] ?? null, $validated['next_action_due_at'] ?? null, isset($validated['assigned_to']) ? (int) $validated['assigned_to'] : null,
$request->exists('assigned_to'), $request->exists('next_action') || $request->exists('next_action_due_at'));
PortalAudit::save($updated, ['status' => $updated->status, 'reviewed_at' => $updated->reviewed_at, 'reviewed_by' => $updated->reviewed_by], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($before !== $updated->status && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $updated->status]);
}
return $request->expectsJson() ? response()->json(['reference' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'contact_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de contacto revogado.');
}
private function withdrawConsent(Request $request, InterestRequest $interest, string $reason, string $purpose): void
{
DB::transaction(function () use ($request, $interest, $reason, $purpose) {
$lockedInterest = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
$consent = DB::table('consents')->where('interest_request_id', $lockedInterest->id)
->where('purpose', $purpose)->where('status', 'granted')->whereNull('deleted_at')
->lockForUpdate()->first();
if (! $consent) {
return;
}
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(), 'interest_request_id' => $lockedInterest->id,
'from_status' => $lockedInterest->status, 'to_status' => $lockedInterest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($reason),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => $purpose], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
DB::table('outbox_events')->insertOrIgnore([
'source_system' => 'portal-intake', 'external_id' => 'InterestConsentWithdrawn:'.$consent->id,
'aggregate_type' => 'interest_consent', 'aggregate_id' => $consent->id,
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
'correlation_id' => $correlationId, 'status' => 'pending',
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
'interest_id' => $lockedInterest->integration_id, 'purpose' => $purpose], JSON_THROW_ON_ERROR),
]);
});
}
/** @return Builder<User> */
private function interestAssignees(): Builder
{
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
fn (string $designation) => config('existing-business.'.$designation, []),
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
)));
return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
}
public function catalog(Request $request, string $resource): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $model::query();
if ($resource === 'opportunities') {
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$query = app(\App\Services\PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
}
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
if (isset($filters['geography_scope'])) {
$query->where('geography_scope', $filters['geography_scope']);
}
}
if ($term = trim($filters['q'] ?? '')) {
$query->where(function ($query) use ($term, $resource) {
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
->orWhereLike('slug', "%{$term}%");
if ($resource === 'opportunities') {
$query->orWhereLike('reference', "%{$term}%");
}
});
}
if ($status = $filters['status'] ?? null) {
if ($resource === 'sectors') {
$query->where('is_active', $status === 'active');
} elseif ($status === 'published') {
$query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now());
} else {
$query->where('status', $status);
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$query->where('sector_id', $filters['sector_id']);
}
return Inertia::render('management/catalog', [
'resource' => $resource,
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'filters' => $filters,
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
'geographyReviewCount' => $resource === 'opportunities'
? (clone $query)->where('geography_scope', 'unspecified')->count()
: 0,
]);
}
public function edit(string $resource, ?int $id = null): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : null;
return Inertia::render('management/editor', [
'resource' => $resource,
'record' => $record,
'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [],
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
]);
}
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
{
if ($resource === 'opportunities' && app(OpportunityManagement::class)->installed()) {
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
}
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [
'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
'short_description' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:20000'],
'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'],
'is_active' => ['required', 'boolean'],
] : [
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)],
'title' => ['required', 'string', 'max:200'],
'slug' => ['required', 'alpha_dash:ascii', 'max:220', Rule::unique('opportunities')->ignore($record->id)],
'summary' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:50000'],
'province' => ['nullable', 'string', 'max:100'],
'district' => ['nullable', 'string', 'max:100'],
'promoter_name' => ['nullable', 'string', 'max:200'],
'promoter_type' => ['nullable', 'string', 'max:100'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'estimated_investment_currency' => ['required', 'regex:/^[A-Z]{3}$/'],
'financing_modality' => ['nullable', 'string', 'max:120'],
'expected_impact' => ['nullable', 'string', 'max:10000'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities') {
$rules['geography_scope'] = ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])];
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
$rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0'];
}
$data = $request->validate($rules);
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
throw ValidationException::withMessages([
'status' => 'Esta proposta educativa aguarda validação institucional da lacuna e da resposta formativa. A revisão interna não autoriza publicação.',
]);
}
if ($resource === 'opportunities') {
$labels = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso',
'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
$data['maturity_label'] = $labels[$data['maturity_level'] ?? 0] ?? null;
$data['published_at'] = $data['status'] === 'published' ? ($record->published_at ?? now()) : null;
}
DB::transaction(function () use ($record, $data, $request, $resource) {
if ($resource === 'opportunities' && $request->hasAny(array_keys(InvestmentGeography::FIELDS))) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
if ($record->exists) {
$current = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail();
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
}
$path = app(InvestmentGeography::class)->selection($data, true);
if (($data['geography_scope'] ?? null) === 'national' && $path !== []) {
throw ValidationException::withMessages(['geography_scope' => 'O âmbito nacional não pode ter uma localização territorial específica.']);
}
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
if ($path !== [] && empty($data['geography_scope'])) {
$data['geography_scope'] = 'specific';
}
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
} else {
unset($data['territory_version']);
}
PortalAudit::save($record, $data, $request->user()->id);
});
return redirect('/management/'.$resource)->with('success', 'Registo guardado.');
}
}