feat: consolidar desenvolvimentos do portal InvestMoz

This commit is contained in:
2026-11-03 23:32:32 +02:00
parent 1da273f1f3
commit 36c8cadb49
345 changed files with 110714 additions and 2799 deletions
+64 -26
View File
@@ -10,9 +10,14 @@ use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue;
use App\Services\CrmNextAction;
use App\Services\CrmTaskCreation;
use App\Services\CrmTaskPreparations;
use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit;
use App\Services\VirtualDesk;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -23,6 +28,12 @@ use Inertia\Response;
class CrmController extends Controller
{
private const VERSION_MESSAGES = [
'version.required' => 'Actualize os dados para recuperar a versão deste registo antes de gravar. As suas edições foram conservadas.',
'version.integer' => 'A versão do registo não foi reconhecida. Recupere os dados actuais.',
'version.min' => 'A versão do registo não foi reconhecida. Recupere os dados actuais.',
];
public function investors(Request $request): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
@@ -43,10 +54,12 @@ class CrmController extends Controller
public function saveInvestor(Request $request, Investor $investor): RedirectResponse
{
$data = $request->validate(['legal_name' => ['required', 'string', 'max:200'],
$data = $request->validate(['version' => 'required|integer|min:1', 'legal_name' => ['required', 'string', 'max:200'],
'investor_type' => ['required', Rule::in(['domestic', 'foreign', 'institutional', 'individual'])],
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]]);
PortalAudit::save($investor, $data, $request->user()->id);
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
PortalAudit::save($investor, $data, $request->user()->id, $version);
return back()->with('success', 'Investidor actualizado.');
}
@@ -54,15 +67,17 @@ class CrmController extends Controller
public function saveContact(Request $request, Investor $investor, Contact $contact): RedirectResponse
{
abort_unless(DB::table('investor_contacts')->where('investor_id', $investor->id)->where('contact_id', $contact->id)->exists(), 404);
$data = $request->validate(['full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']]);
$data = $request->validate(['version' => 'required|integer|min:1', 'full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']], self::VERSION_MESSAGES);
$data['email'] = strtolower(trim($data['email']));
DB::transaction(function () use ($contact, $data, $request) {
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($contact, $data, $request, $version) {
DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$data['email']]);
if (Contact::where('id', '!=', $contact->id)->whereRaw('lower(trim(email)) = ?', [$data['email']])->exists()) {
throw ValidationException::withMessages(['email' => 'Já existe outro contacto com este email. Reveja os registos antes de os fundir.']);
}
PortalAudit::save($contact, $data, $request->user()->id);
PortalAudit::save($contact, $data, $request->user()->id, $version);
});
return back()->with('success', 'Contacto actualizado.');
@@ -82,7 +97,7 @@ class CrmController extends Controller
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->availableForWork()->where('due_at', '<', now())]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('title', "%{$term}%")
->orWhereHas('investor', fn ($investor) => $investor->whereLike('legal_name', "%{$term}%")));
@@ -127,29 +142,37 @@ class CrmController extends Controller
return redirect('/management/crm/leads/'.$lead->id)->with('success', 'Pedido convertido em lead com responsável e próxima acção.');
}
public function show(Lead $lead): Response
public function show(Request $request, Lead $lead): Response
{
$lead->load(['investor', 'owner:id,name', 'tasks' => fn ($q) => $q->orderBy('due_at')]);
$caseIds = $lead->tasks->pluck('desk_case_id')->filter();
$closedCases = $caseIds->isEmpty() ? [] : DB::table('desk_cases')->whereIn('id', $caseIds)->whereNotNull('closed_at')->pluck('id')->all();
foreach ($lead->tasks as $task) {
$task->setAttribute('can_update', ! in_array($task->getAttribute('desk_case_id'), $closedCases, true));
}
$contacts = DB::table('investor_contacts')->join('contacts', 'contacts.id', '=', 'investor_contacts.contact_id')
->where('investor_id', $lead->investor_id)->whereNull('contacts.deleted_at')->get(['contacts.id', 'full_name', 'email', 'phone']);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners()]);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners(), 'taskCreation' => app(CrmTaskCreation::class)->context($request->user(), $lead),
'nextAction' => ['future_tasks' => app(CrmNextAction::class)->future($lead)->count()]]);
}
public function update(Request $request, Lead $lead): RedirectResponse
{
$data = $request->validate(['owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
$data = $request->validate(['version' => 'required|integer|min:1', 'owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
'status' => ['required', Rule::in(['new', 'qualified', 'disqualified'])],
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($lead, $data, $request) {
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($lead, $data, $request, $version) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if ($lead->status === 'converted') {
throw ValidationException::withMessages(['status' => 'O lead já foi convertido numa negociação.']);
}
if ($data['status'] !== 'disqualified' && ! $lead->tasks()->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
if ($data['status'] !== 'disqualified' && ! app(CrmNextAction::class)->future($lead)->exists()) {
throw ValidationException::withMessages(['status' => 'Registe uma próxima acção com prazo futuro antes de activar ou qualificar o lead.']);
}
PortalAudit::save($lead, $data, $request->user()->id);
PortalAudit::save($lead, $data, $request->user()->id, $version);
});
return back()->with('success', 'Lead actualizado.');
@@ -157,24 +180,39 @@ class CrmController extends Controller
public function task(Request $request, Lead $lead): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:200'], 'due_at' => ['required', 'date', 'after:now'],
'assignee_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))]]);
PortalAudit::save(new CrmTask, [...$data, 'lead_id' => $lead->id, 'status' => 'open'], $request->user()->id);
$result = app(CrmTaskCreation::class)->create($request->user(), $lead, $request->all(), $this->owners()->pluck('id')->all());
return back()->with('success', 'Tarefa criada.');
return redirect('/management/crm/leads/'.$lead->id)->with('success', $result['replayed'] ? 'A tarefa já tinha sido registada. A criação não foi repetida.' : 'Tarefa criada.');
}
public function taskPreparation(Request $request, Lead $lead): JsonResponse
{
// Preserve the user's partial text; business validation still occurs only on task creation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
return response()->json(app(CrmTaskPreparations::class)->save($request->user(), $lead, $input));
}
public function updateTask(Request $request, Lead $lead, CrmTask $task): RedirectResponse
{
abort_unless($task->lead_id === $lead->id, 404);
$data = $request->validate(['status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]]);
DB::transaction(function () use ($lead, $task, $data, $request) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if (in_array($lead->status, ['new', 'qualified'], true) && in_array($data['status'], ['done', 'cancelled'], true)
&& ! $lead->tasks()->where('id', '!=', $task->id)->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
throw ValidationException::withMessages(['status' => 'Crie a próxima acção antes de encerrar a última tarefa de um lead activo.']);
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($lead, $task, $data, $request, $version) {
if ($task->getAttribute('desk_case_id')) {
$case = DB::table('desk_cases')->where('id', $task->getAttribute('desk_case_id'))->lockForUpdate()->first();
abort_unless($case !== null, 404);
app(VirtualDesk::class)->assertOpen($case);
}
PortalAudit::save($task, $data, $request->user()->id);
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
$task = CrmTask::lockForUpdate()->findOrFail($task->id);
abort_unless($task->lead_id === $lead->id, 404);
app(CrmNextAction::class)->assertMayFinish($lead, $task, $data['status']);
PortalAudit::save($task, $data, $request->user()->id, $version);
});
return back()->with('success', 'Tarefa actualizada.');