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]); } private function interestAssignmentEtag(InterestRequest $interest): string { return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR)); } public function interestAssignments(Request $request): Response|JsonResponse { abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); $rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at']) ->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]); $data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])]; return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data); } public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); $data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']); DB::transaction(function()use($request,$interest,$data){ $row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']); if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); $owner=(int)($row->metadata['portal_user_id']??0); abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.'); PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id); app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]); }); return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.'); } 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', ]); app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user()); if ($country = $filters['country'] ?? null) { $values = app(CountryCatalogue::class)->filterValues($country); $query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values); } 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 { abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $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', 'source_page', ]), 'history' => $history, 'consents' => $consents, 'assignees' => $this->interestAssignees()->get(['id', 'name']), ]); } public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse { abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $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'))], ], [ 'next_action_due_at.required_with' => 'Indique o prazo da próxima acção.', 'next_action_due_at.date' => 'Indique uma data válida para a próxima acção.', 'next_action_due_at.after_or_equal' => 'O prazo da próxima acção deve ser hoje ou uma data futura.', 'next_action.required_with' => 'Descreva a próxima acção correspondente ao prazo indicado.', ]); $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 { abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $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 { abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); $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 */ 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')->where(function ($q) use ($emails) { $q->whereIn(DB::raw('lower(email)'), $emails)->orWhereIn('id', DB::table('portal_superuser_pair')->select('first_user_id'))->orWhereIn('id', DB::table('portal_superuser_pair')->select('second_user_id')); })->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(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)) { $sector = Sector::find($filters['sector_id']); $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); } return Inertia::render('management/catalog', [ 'resource' => $resource, 'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'sectors' => $resource === 'opportunities' ? Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']) : [], '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::choices()->orderBy('name')->get(['id', 'name', 'slug']), ]); } 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'], 'editorial_published' => ['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 === 'sectors') { $data['published_at'] = $data['editorial_published'] ? ($record->published_at ?? now()) : null; unset($data['editorial_published']); } 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.'); } }