Files
Investidor-V2/app/Http/Controllers/PortalManagementController.php
T
nala 4f35313d06
tests / ci (push) Canceled after 0s
novas funcionalidades dos formulario
2026-10-02 11:13:37 +02:00

302 lines
17 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\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
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'])],
]);
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
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(),
'filters' => $filters,
]);
}
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', [
'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']]);
DB::transaction(function () use ($request, $interest, $data) {
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->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' => $interest->id,
'from_status' => $interest->status, 'to_status' => $interest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => 'share_contact_with_proponent'], 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' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
]);
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
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')],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $model::query();
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']) : [],
]);
}
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
{
$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') {
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);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$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.');
}
}