256 lines
18 KiB
PHP
256 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ai;
|
|
|
|
use App\Models\CrmTask;
|
|
use App\Models\InvestmentSubmission;
|
|
use App\Models\SimulationFormDraft;
|
|
use App\Models\User;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\VirtualDesk;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class InvestorGuidance
|
|
{
|
|
public function __construct(private VirtualDesk $desk, private InvestorGuidanceRules $rules) {}
|
|
|
|
public function installed(): bool
|
|
{
|
|
return Schema::hasColumn('tasks', 'desk_case_id');
|
|
}
|
|
|
|
/** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator. */
|
|
/** @return array<string,mixed> */
|
|
public function context(User $actor, \stdClass $case): array
|
|
{
|
|
$base = $this->desk->decode($case->payload);
|
|
$facts = Arr::only($base['guidance']['assumptions'] ?? [], InvestorGuidanceRules::FIELDS);
|
|
$consulted = [];
|
|
$unavailable = [];
|
|
$financialDraft = null;
|
|
$checks = [];
|
|
$revision = ['case' => (int) $case->version];
|
|
$submission = $case->submission_id ? InvestmentSubmission::where('id', $case->submission_id)->where('user_id', $actor->id)->first() : null;
|
|
if ($submission) {
|
|
$input = $submission->payload;
|
|
$mapped = ['project_title' => $input['title'] ?? $case->title, 'project_summary' => $input['objective'] ?? '',
|
|
'activity' => $input['activity_description'] ?? '', 'schedule' => $input['schedule'] ?? ''];
|
|
$facts = [...$mapped, ...$facts];
|
|
$consulted[] = ['module' => 'investment_submission', 'id' => $submission->id, 'version' => $submission->version, 'date' => $submission->updated_at?->toIso8601String(), 'status' => 'investor_declaration'];
|
|
$revision['submission'] = $submission->version;
|
|
} elseif ($case->submission_id) {
|
|
$unavailable[] = 'investment_submission_permission_or_availability';
|
|
}
|
|
if ($case->simulation_id) {
|
|
$draft = SimulationFormDraft::where('id', $case->simulation_id)->where('owner_user_id', $actor->id)->whereNull('deleted_at')
|
|
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->first();
|
|
if ($draft) {
|
|
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
|
$mapped = Arr::only($economic, InvestorGuidanceRules::FIELDS);
|
|
$mapped['activity'] = $economic['selection_labels']['activity_id'] ?? $economic['products_services'] ?? '';
|
|
// A market assumption is not evidence of demand.
|
|
if (! empty($economic['market_source'])) {
|
|
$mapped['market_evidence'] = $economic['market_source'];
|
|
}
|
|
$mapped['location'] = $economic['selection_labels']['province_id'] ?? '';
|
|
$mapped['location_evidence'] = $economic['location_rationale'] ?? '';
|
|
if (! empty($economic['skills_needed'])) {
|
|
$mapped['resource_plan'] = $economic['skills_needed'];
|
|
}
|
|
$checks = $this->catalogueChecks($economic);
|
|
$facts = [...$mapped, ...$facts];
|
|
// Only economic fields needed by existing deterministic calculators, never personal_snapshot.
|
|
$financialDraft = Arr::only($economic, ['business_plan_snapshot', 'financial_tools_snapshot', 'currency']);
|
|
$consulted[] = ['module' => 'simulation_form_draft', 'id' => $draft->id, 'version' => $draft->version_no, 'hash' => $draft->snapshot_sha256,
|
|
'date' => $draft->updated_at?->toIso8601String(), 'status' => 'declared_assumptions_and_versioned_snapshots'];
|
|
$revision['simulation'] = [$draft->version_no, $draft->snapshot_sha256];
|
|
} else {
|
|
$unavailable[] = 'simulation_draft_permission_or_availability';
|
|
}
|
|
}
|
|
$facts['project_title'] = $facts['project_title'] ?? $case->title;
|
|
$evidence = app(InvestorGuidanceEvidence::class)->forCase($actor, $case, $this->desk);
|
|
$fingerprint = hash('sha256', json_encode([InvestorGuidanceRules::VERSION, $revision, Arr::sortRecursive($facts), $checks, $evidence], JSON_THROW_ON_ERROR));
|
|
|
|
return ['facts' => $facts, 'consulted' => $consulted, 'unavailable' => $unavailable, 'financial_draft' => $financialDraft, 'fingerprint' => $fingerprint, 'catalogue_checks' => $checks, 'evidence' => $evidence];
|
|
}
|
|
|
|
/** @param array<string,mixed> $economic
|
|
* @return list<array<string,mixed>> */
|
|
private function catalogueChecks(array $economic): array
|
|
{
|
|
$checks = [];
|
|
if (! empty($economic['activity_id'])) {
|
|
$activity = DB::table('economic_activities as a')->join('reference_catalog_versions as v', 'v.id', '=', 'a.catalog_version_id')
|
|
->where('a.id', $economic['activity_id'])->where('a.status', 'published')->where('v.status', 'published')
|
|
->whereDate('a.valid_from', '<=', today())->where(fn ($q) => $q->whereNull('a.valid_to')->orWhereDate('a.valid_to', '>', today()))
|
|
->first(['a.id', 'a.sector_id', 'a.catalog_version_id']);
|
|
$classification = ! $activity || empty($economic['sector_id']) ? 'insufficient_information'
|
|
: ((int) $activity->sector_id === (int) $economic['sector_id'] ? 'catalogue_consistent' : 'documented_incompatibility');
|
|
$checks[] = ['code' => 'activity_sector', 'classification' => $classification, 'restriction' => false,
|
|
'explanation' => match ($classification) {
|
|
'documented_incompatibility' => 'A actividade e o sector seleccionados não correspondem no catálogo publicado consultado.',
|
|
'catalogue_consistent' => 'Actividade e sector correspondem no catálogo consultado; isto não prova mercado ou elegibilidade.',
|
|
default => 'Sem referência actual autorizada suficiente para verificar actividade e sector.',
|
|
}, 'source' => $activity ? ['module' => 'economic_activities', 'id' => $activity->id, 'version_id' => $activity->catalog_version_id] : null];
|
|
}
|
|
$parents = ['district_id' => 'province_id', 'admin_post_id' => 'district_id', 'locality_id' => 'admin_post_id'];
|
|
foreach ($parents as $child => $parent) {
|
|
if (empty($economic[$child])) {
|
|
continue;
|
|
}
|
|
$location = app(InvestmentGeography::class)->publicReferences()->where('id', $economic[$child])->first(['id', 'parent_id']);
|
|
$classification = ! $location || empty($economic[$parent]) ? 'insufficient_information'
|
|
: ($location->parent_id === $economic[$parent] ? 'catalogue_consistent' : 'documented_incompatibility');
|
|
$checks[] = ['code' => 'territory_'.$child, 'classification' => $classification, 'restriction' => false,
|
|
'explanation' => match ($classification) {
|
|
'documented_incompatibility' => 'A hierarquia territorial declarada contradiz a referência publicada consultada.',
|
|
'catalogue_consistent' => 'Hierarquia territorial coerente na referência consultada; disponibilidade e infraestrutura continuam por confirmar.',
|
|
default => 'Referência territorial insuficiente ou indisponível para esta verificação.',
|
|
}, 'source' => $location ? ['module' => 'geo_locations', 'id' => $location->id, 'parent_id' => $location->parent_id] : null];
|
|
}
|
|
|
|
return $checks;
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function view(User $actor, string $id): array
|
|
{
|
|
$case = $this->desk->case($actor, $id);
|
|
abort_unless($this->installed(), 503, 'A extensão do acompanhamento aguarda instalação autenticada.');
|
|
$context = $this->context($actor, $case);
|
|
$choices = [];
|
|
foreach (DB::table('desk_events')->where('case_id', $id)->where('action', 'recommendation_reviewed')->orderBy('event_order')->get() as $event) {
|
|
$data = $this->desk->decode($event->payload);
|
|
$choices[$data['code']] = [...$data, 'actor_id' => $event->actor_id, 'date' => $event->created_at, 'stale' => $data['context_hash'] !== $context['fingerprint']];
|
|
}
|
|
$actions = ['opportunities' => '/oportunidades', 'locations' => '/oportunidades?view=map', 'partners' => '/parcerias',
|
|
'documents' => '/documentos', 'plan' => $case->submission_id && (int) $case->user_id === $actor->id ? '/investidor/manifestacoes/'.$case->submission_id : '/balcao-virtual/dossiers/'.$id,
|
|
'simulator' => '/simulador-fiscal', 'support' => '/balcao-virtual/dossiers/'.$id];
|
|
// The simulator recovers its current owner-scoped draft; do not pass private payload in URLs.
|
|
if ($case->simulation_id && $context['financial_draft'] !== null) {
|
|
$actions['plan'] = '/simulador-fiscal';
|
|
}
|
|
$phases = $this->rules->evaluate($context['facts'], $context['evidence']);
|
|
foreach ($phases as &$phase) {
|
|
foreach ($phase['recommendations'] as &$recommendation) {
|
|
$recommendation['review'] = $choices[$recommendation['code']] ?? null;
|
|
$recommendation['href'] = $actions[$recommendation['action']];
|
|
}
|
|
unset($recommendation);
|
|
}
|
|
unset($phase);
|
|
$tasks = DB::table('tasks as t')->leftJoin('users as u', 'u.id', '=', 't.assignee_user_id')->where('t.desk_case_id', $id)->whereNull('t.deleted_at')
|
|
->when(! $this->desk->owns($actor, $case, false) && ! $actor->can('manage-portal'), fn ($q) => $q->where('t.assignee_user_id', $actor->id))
|
|
->get(['t.id', 't.title', 't.status', 't.due_at', 't.version_no', 't.desk_request_id', 't.accepted_by', 't.accepted_at', 't.assignee_user_id', 'u.name as assignee_name', 't.external_sync_status']);
|
|
|
|
return ['version' => $case->version, 'rules_version' => InvestorGuidanceRules::VERSION, 'computed_at' => now()->toIso8601String(),
|
|
...$context, 'phases' => $phases, 'tasks' => $tasks, 'actions' => $actions, 'can_write' => $this->desk->owns($actor, $case),
|
|
'external_ai' => ['status' => 'not_configured', 'model' => null, 'consulted' => []],
|
|
'calculator_versions' => ['business_financial_plan' => hash_file('sha256', resource_path('js/lib/business-financial-plan.ts')), 'financial_calculators' => hash_file('sha256', resource_path('js/lib/financial-calculators.ts')), 'project_dossier' => hash_file('sha256', resource_path('js/lib/project-dossier.ts'))],
|
|
'crm' => ['internal' => 'available', 'external' => 'out_of_scope', 'reason' => 'O acompanhamento usa o CRM interno existente. A sincronização externa está fora desta fase; nenhum contacto, mensagem ou documento é transmitido.']];
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function assumptions(User $actor, string $id, array $input): void
|
|
{
|
|
$rules = ['version' => 'required|integer|min:1', 'assumptions' => ['required', 'array:'.implode(',', InvestorGuidanceRules::FIELDS)]];
|
|
foreach (InvestorGuidanceRules::FIELDS as $field) {
|
|
$rules['assumptions.'.$field] = in_array($field, ['expected_start_date', 'ready_date', 'funding_ready_date', 'completion_date'], true) ? 'nullable|date_format:Y-m-d' : 'nullable|string|max:3000';
|
|
}
|
|
$data = Validator::make($input, $rules)->validate();
|
|
DB::transaction(function () use ($actor, $id, $data) {
|
|
$case = $this->desk->case($actor, $id, true, true);
|
|
abort_if($case->closed_at, 409);
|
|
abort_unless((int) $case->version === (int) $data['version'], 409, 'Dossier alterado; recarregue antes de corrigir.');
|
|
$base = $this->desk->decode($case->payload);
|
|
$base['guidance']['assumptions'] = $data['assumptions'];
|
|
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($base), 'version' => $case->version + 1, 'updated_at' => now()]);
|
|
$this->desk->event(DB::table('desk_cases')->where('id', $id)->first(), $actor, 'assumptions_corrected', ['assumptions' => $data['assumptions'], 'origin' => 'investor_correction_not_official_data']);
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function feedback(User $actor, string $id, array $input): void
|
|
{
|
|
$data = Validator::make($input, ['code' => 'required|string|max:100', 'context_hash' => 'required|string|size:64',
|
|
'decision' => ['required', Rule::in(['accepted', 'rejected', 'corrected'])], 'reason' => 'required|string|min:3|max:3000'])->validate();
|
|
DB::transaction(function () use ($actor, $id, $data) {
|
|
$case = $this->desk->case($actor, $id, true, true);
|
|
abort_if($case->closed_at, 409);
|
|
$view = $this->view($actor, $id);
|
|
abort_unless(hash_equals($view['fingerprint'], $data['context_hash']), 409, 'Pressupostos alterados; reveja a recomendação actual.');
|
|
$recommendations = array_merge(...array_column($view['phases'], 'recommendations'));
|
|
abort_unless(in_array($data['code'], array_column($recommendations, 'code'), true), 422);
|
|
$this->desk->event($case, $actor, 'recommendation_reviewed', [...$data, 'rules_version' => InvestorGuidanceRules::VERSION, 'creates_task' => false]);
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function acceptTask(User $actor, string $id, array $input): CrmTask
|
|
{
|
|
$data = Validator::make($input, ['code' => 'required|string|max:100', 'context_hash' => 'required|string|size:64', 'consent' => 'accepted',
|
|
'due_at' => 'required|date|after:now', 'request_id' => 'nullable|uuid'])->validate();
|
|
|
|
return DB::transaction(function () use ($actor, $id, $data) {
|
|
$case = $this->desk->case($actor, $id, true, true);
|
|
abort_if($case->closed_at, 409);
|
|
$view = $this->view($actor, $id);
|
|
abort_unless(hash_equals($view['fingerprint'], $data['context_hash']), 409);
|
|
$recommendations = array_merge(...array_column($view['phases'], 'recommendations'));
|
|
$suggestion = collect($recommendations)->firstWhere('code', $data['code']);
|
|
abort_unless($suggestion && ($suggestion['review']['decision'] ?? null) === 'accepted' && ! $suggestion['review']['stale'], 422, 'Aceite a recomendação actual antes de assumir a tarefa.');
|
|
if ($data['request_id'] ?? null) {
|
|
$request = $this->desk->request($actor, $data['request_id'], true);
|
|
abort_unless($request->case_id === $id, 422);
|
|
}
|
|
// One accepted action per suggestion, even after corrections or retries.
|
|
$existing = CrmTask::where('desk_case_id', $id)->where('guidance_key', $data['code'])->first();
|
|
if ($existing) {
|
|
abort_unless($existing->getAttribute('desk_request_id') === ($data['request_id'] ?? null)
|
|
&& (int) $existing->getAttribute('accepted_by') === $actor->id && $existing->due_at !== null && $existing->due_at->equalTo(Carbon::parse($data['due_at'])), 409, 'A tarefa já foi assumida com outros dados; reveja o registo existente.');
|
|
|
|
return $existing;
|
|
}
|
|
$task = CrmTask::create(['desk_case_id' => $id, 'desk_request_id' => $data['request_id'] ?? null, 'guidance_key' => $data['code'],
|
|
'title' => $suggestion['explanation'], 'status' => 'open', 'due_at' => $data['due_at'], 'assignee_user_id' => $actor->id,
|
|
'accepted_by' => $actor->id, 'accepted_at' => now(), 'external_sync_status' => 'pending_authorization',
|
|
'source_system' => 'investor-guidance', 'data_classification' => 'confidential']);
|
|
$this->desk->event($case, $actor, 'guidance_task_accepted', ['task_id' => $task->id, 'code' => $data['code'], 'context_hash' => $data['context_hash'], 'due_at' => $data['due_at'], 'scope' => 'internal_crm']);
|
|
|
|
return $task;
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function updateTask(User $actor, string $id, string $taskId, array $input): void
|
|
{
|
|
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])],
|
|
'reason' => 'required|string|min:3|max:3000', 'assignee_user_id' => 'nullable|integer|exists:users,id', 'due_at' => 'nullable|date|after:now'])->validate();
|
|
DB::transaction(function () use ($actor, $id, $taskId, $data) {
|
|
$case = $this->desk->case($actor, $id);
|
|
$task = CrmTask::where('desk_case_id', $id)->whereKey($taskId)->lockForUpdate()->firstOrFail();
|
|
abort_unless($actor->can('manage-portal') || (int) $task->getAttribute('assignee_user_id') === $actor->id, 403);
|
|
abort_unless((int) $task->getAttribute('version_no') === (int) $data['version'], 409);
|
|
$updates = ['status' => $data['status'], 'version_no' => $data['version'] + 1];
|
|
if ($data['assignee_user_id'] ?? null) {
|
|
abort_unless($actor->can('manage-portal'), 403);
|
|
$assignee = User::where('id', $data['assignee_user_id'])->firstOrFail();
|
|
abort_unless($assignee->hasVerifiedEmail(), 422, 'Responsável sem conta verificada.');
|
|
abort_unless($assignee->can('manage-portal') || DB::table('desk_requests')->where('case_id', $id)->where('analyst_id', $assignee->id)->exists() || $this->desk->owns($assignee, $case), 422, 'Responsável sem acesso ao dossier.');
|
|
$updates['assignee_user_id'] = $assignee->id;
|
|
}
|
|
if ($data['due_at'] ?? null) {
|
|
$updates['due_at'] = $data['due_at'];
|
|
}
|
|
$task->update($updates);
|
|
$this->desk->event($case, $actor, 'guidance_task_updated', ['task_id' => $task->id, ...$data, 'kind' => $actor->can('manage-portal') ? 'responsible_response' : 'investor_action']);
|
|
});
|
|
}
|
|
}
|