105 lines
5.7 KiB
PHP
105 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ai;
|
|
|
|
use App\Models\CrmTask;
|
|
use App\Models\InterestRequest;
|
|
use App\Models\Lead;
|
|
use App\Models\Opportunity;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class AssistiveRules
|
|
{
|
|
/** @param array<string, mixed> $input
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function recommend(array $input): array
|
|
{
|
|
$query = Opportunity::published();
|
|
$factors = [];
|
|
foreach (['sector_id', 'province', 'maturity_level'] as $field) {
|
|
if (isset($input[$field]) && $input[$field] !== '') {
|
|
$query->where($field, $input[$field]);
|
|
$factors[] = $field;
|
|
}
|
|
}
|
|
if ($factors === []) {
|
|
return [];
|
|
}
|
|
|
|
return array_values($query->orderBy('id')->limit(10)->get()->map(fn ($opportunity) => [
|
|
'record_type' => 'opportunity', 'record_id' => $opportunity->id, 'title' => $opportunity->title,
|
|
'source_url' => '/oportunidades/'.$opportunity->slug,
|
|
'score' => count($factors), 'score_max' => 3, 'score_meaning' => 'matched_declared_filters',
|
|
'favourable_factors' => $factors, 'incompatibilities' => [],
|
|
'eliminatory_conditions' => ['not_published', 'mismatch_with_any_selected_filter'],
|
|
'missing_data' => array_filter(['budget', 'time_horizon', 'risk_profile',
|
|
$opportunity->last_validated_at ? null : 'validation_date']),
|
|
'source_date' => $opportunity->last_validated_at?->toIso8601String(),
|
|
'semantic_similarity' => null,
|
|
])->all());
|
|
}
|
|
|
|
/** @return list<array<string, mixed>> */
|
|
public function classify(int $id): array
|
|
{
|
|
$interest = InterestRequest::findOrFail($id);
|
|
$consent = DB::table('consents')->where('subject_ref', $interest->reference)->where('purpose', 'ai_assistance')
|
|
->where('status', 'granted')->whereNotNull('granted_at')->where('granted_at', '<=', now())->whereNull('deleted_at')->whereNull('withdrawn_at')->exists();
|
|
abort_unless($consent, 422, 'É necessário consentimento específico registado para assistência sobre este pedido.');
|
|
|
|
// No name, email, phone, message or sensitive attribute is used.
|
|
return [[
|
|
'record_type' => 'interest', 'record_id' => $interest->id,
|
|
'source_url' => '/management/interests/'.$interest->id,
|
|
'sector_declared' => $interest->sector_interest,
|
|
'investment_range_declared' => $interest->investment_range,
|
|
'intention' => $interest->opportunity_id ? 'interest_in_identified_opportunity' : 'general_information',
|
|
'priority' => 'human_review_required', 'suggested_owner' => null,
|
|
'next_action' => config('investmoz_ai.classification.next_action'),
|
|
'missing_data' => ['confirmed_capacity', 'time_horizon', 'responsible_person', 'institutional_priority'],
|
|
'source_date' => $interest->created_at?->toIso8601String(),
|
|
]];
|
|
}
|
|
|
|
/** @return list<array<string, mixed>> */
|
|
public function quality(): array
|
|
{
|
|
$alerts = [];
|
|
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereNull('owner_user_id')->orderBy('id')->limit(20)->get(['id']) as $lead) {
|
|
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_owner', 'source_url' => '/management/crm/leads/'.$lead->id];
|
|
}
|
|
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])
|
|
->whereNotExists(CrmTask::availableForWork()->whereColumn('tasks.lead_id', 'leads.id')->selectRaw('1')->toBase())
|
|
->orderBy('id')->limit(20)->get(['id']) as $lead) {
|
|
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_next_action', 'source_url' => '/management/crm/leads/'.$lead->id];
|
|
}
|
|
foreach (DB::table('ai_sources')->where('valid_until', '<', now()->toDateString())->orderBy('id')->limit(20)->get(['id', 'valid_until']) as $source) {
|
|
$alerts[] = ['record_type' => 'source', 'record_id' => $source->id, 'rule' => 'expired_source', 'valid_until' => $source->valid_until];
|
|
}
|
|
foreach (DB::table('documents')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
|
|
->select('sha256')->selectRaw('count(*) as copies')->groupBy('sha256')->havingRaw('count(*) > 1')->limit(20)->get() as $duplicate) {
|
|
$alerts[] = ['record_type' => 'document_group', 'rule' => 'duplicate_document_checksum', 'copies' => (int) $duplicate->copies];
|
|
}
|
|
foreach (DB::table('geo_geometries')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
|
|
->whereRaw('NOT ST_IsValid(geom)')->limit(20)->get(['id']) as $geometry) {
|
|
$alerts[] = ['record_type' => 'geometry', 'record_id' => $geometry->id, 'rule' => 'invalid_geometry'];
|
|
}
|
|
|
|
return $alerts;
|
|
}
|
|
|
|
/** @return list<array<string, mixed>> */
|
|
public function sla(): array
|
|
{
|
|
return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->availableForWork()->where('due_at', '<', now())->orderBy('due_at')->limit(50)
|
|
->get()->map(fn ($task) => [
|
|
'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task',
|
|
'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id,
|
|
'source_url' => $task->lead_id ? '/management/crm/leads/'.$task->lead_id : null,
|
|
'suggested_action' => 'review_owner_deadline_and_dependencies',
|
|
'impact' => 'not_estimated', 'priority' => 'human_review_required',
|
|
])->all());
|
|
}
|
|
}
|