Files
Investidor-V2/app/Services/Ai/InvestorGuidance.php
T

258 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];
// Pin the linked owner-scoped draft instead of opening another project's current draft.
// The receiving API still enforces ownership; no private payload is carried in the URL.
if ($case->simulation_id && $context['financial_draft'] !== null) {
$actions['simulator'] = '/simulador-fiscal?draft='.rawurlencode($case->simulation_id);
$actions['plan'] = $actions['simulator'];
}
$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']);
});
}
}