Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.77% covered (success)
98.77%
80 / 81
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
RunDealAutomationsAction
98.77% covered (success)
98.77%
80 / 81
66.67% covered (warning)
66.67%
2 / 3
9
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
98.73% covered (success)
98.73%
78 / 79
0.00% covered (danger)
0.00%
0 / 1
7
 fallbackActor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Sales\Application\Actions;
6
7use App\Models\User;
8use ImovelZapAi\Sales\Domain\Enums\DealPriority;
9use ImovelZapAi\Sales\Domain\Enums\DealStageKey;
10use ImovelZapAi\Sales\Domain\Enums\DealTaskType;
11use ImovelZapAi\Sales\Domain\Enums\DealTaskStatus;
12use ImovelZapAi\Sales\Infrastructure\Persistence\Eloquent\DealModel;
13use ImovelZapAi\Sales\Infrastructure\Persistence\Eloquent\DealTaskModel;
14use ImovelZapAi\Sales\Infrastructure\Persistence\Eloquent\SalesDealStageModel;
15
16final class RunDealAutomationsAction
17{
18    public function __construct(
19        private readonly CreateDealTaskAction $createTask,
20        private readonly \ImovelZapAi\Sales\Application\Services\DealActivityRecorder $activities,
21    ) {}
22
23    /**
24     * @return array{stagnation_alerts: int, proposal_reminders: int}
25     */
26    public function execute(string $tenantId, ?User $systemActor = null): array
27    {
28        $actor = $systemActor ?? $this->fallbackActor($tenantId);
29        $stagnation = 0;
30        $proposalReminders = 0;
31
32        if (config('sales.automation.stagnation_alert', true) && $actor !== null) {
33            $days = (int) config('sales.stagnation_days', 7);
34            $cutoff = now()->subDays($days);
35
36            DealModel::query()
37                ->where('tenant_id', $tenantId)
38                ->whereNull('closed_at')
39                ->where(function ($q) use ($cutoff): void {
40                    $q->where('last_activity_at', '<=', $cutoff)
41                        ->orWhere(function ($q2) use ($cutoff): void {
42                            $q2->whereNull('last_activity_at')->where('created_at', '<=', $cutoff);
43                        });
44                })
45                ->limit(50)
46                ->get()
47                ->each(function (DealModel $deal) use ($actor, &$stagnation): void {
48                    $exists = DealTaskModel::query()
49                        ->where('deal_id', $deal->id)
50                        ->where('type', DealTaskType::Alert)
51                        ->where('status', DealTaskStatus::Open)
52                        ->where('is_automatic', true)
53                        ->exists();
54
55                    if ($exists) {
56                        return;
57                    }
58
59                    $this->createTask->execute($deal, $actor, [
60                        'type' => DealTaskType::Alert->value,
61                        'title' => 'Oportunidade parada — revisar agora',
62                        'description' => 'Sem atividade recente. Nenhuma oportunidade deve ser esquecida.',
63                        'due_at' => now()->toDateTimeString(),
64                        'priority' => DealPriority::Urgent->value,
65                        'is_automatic' => true,
66                    ]);
67
68                    $this->activities->record(
69                        $deal,
70                        \ImovelZapAi\Sales\Domain\Enums\DealActivityType::AutomationAlert,
71                        'Alerta de estagnação',
72                        actorUserId: (int) $actor->id,
73                    );
74                    $stagnation++;
75                });
76        }
77
78        if ($actor !== null) {
79            $hours = (int) config('sales.automation.proposal_no_response_hours', 72);
80            $proposalStageIds = SalesDealStageModel::query()
81                ->where('tenant_id', $tenantId)
82                ->where('key', DealStageKey::Proposal->value)
83                ->pluck('id');
84
85            if ($proposalStageIds->isNotEmpty()) {
86                DealModel::query()
87                    ->where('tenant_id', $tenantId)
88                    ->whereNull('closed_at')
89                    ->whereIn('stage_id', $proposalStageIds)
90                    ->where('stage_entered_at', '<=', now()->subHours($hours))
91                    ->limit(50)
92                    ->get()
93                    ->each(function (DealModel $deal) use ($actor, &$proposalReminders): void {
94                        $exists = DealTaskModel::query()
95                            ->where('deal_id', $deal->id)
96                            ->where('type', DealTaskType::FollowUp)
97                            ->where('status', DealTaskStatus::Open)
98                            ->where('is_automatic', true)
99                            ->where('title', 'Lembrete: proposta sem resposta')
100                            ->exists();
101
102                        if ($exists) {
103                            return;
104                        }
105
106                        $this->createTask->execute($deal, $actor, [
107                            'type' => DealTaskType::FollowUp->value,
108                            'title' => 'Lembrete: proposta sem resposta',
109                            'due_at' => now()->toDateTimeString(),
110                            'priority' => DealPriority::High->value,
111                            'is_automatic' => true,
112                        ]);
113                        $proposalReminders++;
114                    });
115            }
116        }
117
118        return [
119            'stagnation_alerts' => $stagnation,
120            'proposal_reminders' => $proposalReminders,
121        ];
122    }
123
124    private function fallbackActor(string $tenantId): ?User
125    {
126        return User::query()->where('tenant_id', $tenantId)->orderBy('id')->first();
127    }
128}