366 lines
21 KiB
PHP
366 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') {
|
|
$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'
|
|
? Opportunity::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.');
|
|
}
|
|
}
|