Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.91% covered (success)
98.91%
91 / 92
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeDealStageAction
98.91% covered (success)
98.91%
91 / 92
66.67% covered (warning)
66.67%
2 / 3
22
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
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
1 / 1
16
 syncInterestWonLost
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Sales\Application\Actions;
6
7use App\Models\User;
8use Illuminate\Support\Facades\DB;
9use ImovelZapAi\Conversations\Application\Actions\UpdatePersonInterestStageAction;
10use ImovelZapAi\Properties\Domain\Enums\PropertyInterestStatus;
11use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\PropertyInterestModel;
12use ImovelZapAi\Sales\Application\Services\DealActivityRecorder;
13use ImovelZapAi\Sales\Application\Services\DealHistoryWriter;
14use ImovelZapAi\Sales\Application\Services\DefaultPipelineSeeder;
15use ImovelZapAi\Sales\Domain\Enums\DealActivityType;
16use ImovelZapAi\Sales\Domain\Enums\DealHistoryEvent;
17use ImovelZapAi\Sales\Domain\Enums\DealLostReason;
18use ImovelZapAi\Sales\Domain\Enums\DealStageKey;
19use ImovelZapAi\Sales\Domain\Events\DealLost;
20use ImovelZapAi\Sales\Domain\Events\DealStageChanged;
21use ImovelZapAi\Sales\Domain\Events\DealWon;
22use ImovelZapAi\Sales\Domain\Exceptions\DealException;
23use ImovelZapAi\Sales\Infrastructure\Persistence\Eloquent\DealModel;
24use ImovelZapAi\Sales\Infrastructure\Persistence\Eloquent\SalesDealStageModel;
25
26final class ChangeDealStageAction
27{
28    public function __construct(
29        private readonly DefaultPipelineSeeder $pipelineSeeder,
30        private readonly DealActivityRecorder $activities,
31        private readonly DealHistoryWriter $history,
32        private readonly UpdatePersonInterestStageAction $updateInterestStage,
33        private readonly CreateDealTaskAction $createTask,
34    ) {}
35
36    public function execute(
37        DealModel $deal,
38        User $actor,
39        string $stageKey,
40        ?string $lostReason = null,
41        ?string $lostNotes = null,
42    ): DealModel {
43        if ($deal->isClosed() && ! in_array($stageKey, [DealStageKey::Won->value, DealStageKey::Lost->value], true)) {
44            throw DealException::alreadyClosed();
45        }
46
47        $from = $deal->stage ?? SalesDealStageModel::query()->find($deal->stage_id);
48        $to = $this->pipelineSeeder->stageByKey($deal->tenant_id, $stageKey, $deal->pipeline_id);
49
50        if ($from !== null && $from->id === $to->id) {
51            return $deal;
52        }
53
54        return DB::transaction(function () use ($deal, $actor, $from, $to, $lostReason, $lostNotes): DealModel {
55            $before = [
56                'stage_id' => $deal->stage_id,
57                'stage_key' => $from?->key,
58            ];
59
60            $deal->forceFill([
61                'stage_id' => $to->id,
62                'stage_entered_at' => now(),
63                'won_at' => $to->is_won ? now() : null,
64                'lost_at' => $to->is_lost ? now() : null,
65                'lost_reason' => $to->is_lost
66                    ? (DealLostReason::tryFrom((string) $lostReason)?->value ?? $lostReason)
67                    : null,
68                'lost_notes' => $to->is_lost ? $lostNotes : null,
69                'closed_at' => $to->isTerminal() ? now() : null,
70            ])->save();
71
72            $this->activities->record(
73                $deal,
74                DealActivityType::StageChanged,
75                'Estágio: '.($from?->name ?? '?').' → '.$to->name,
76                actorUserId: (int) $actor->id,
77                meta: ['from' => $from?->key, 'to' => $to->key],
78            );
79
80            if ($to->key === DealStageKey::Proposal->value) {
81                $this->activities->record(
82                    $deal,
83                    DealActivityType::ProposalSent,
84                    'Proposta enviada',
85                    actorUserId: (int) $actor->id,
86                );
87            }
88
89            if ($to->key === DealStageKey::VisitDone->value) {
90                $this->activities->record(
91                    $deal,
92                    DealActivityType::VisitCompleted,
93                    'Visita realizada',
94                    actorUserId: (int) $actor->id,
95                );
96
97                if (config('sales.automation.visit_done_creates_task', true)) {
98                    $this->createTask->execute($deal->fresh(), $actor, [
99                        'type' => 'follow_up',
100                        'title' => 'Atualizar oportunidade após visita',
101                        'due_at' => now()->addDay()->toDateTimeString(),
102                        'is_automatic' => true,
103                        'priority' => 'high',
104                    ]);
105                }
106            }
107
108            $this->history->write(
109                $deal->tenant_id,
110                $deal->id,
111                DealHistoryEvent::StageChanged,
112                actorUserId: (int) $actor->id,
113                before: $before,
114                after: ['stage_id' => $to->id, 'stage_key' => $to->key],
115                message: 'Estágio alterado.',
116            );
117
118            DealStageChanged::dispatch(
119                $deal->tenant_id,
120                $deal->id,
121                (string) ($from?->key ?? ''),
122                (string) $to->key,
123            );
124
125            if ($to->is_won) {
126                $this->activities->record($deal, DealActivityType::Won, 'Negócio concluído', actorUserId: (int) $actor->id);
127                $this->history->write($deal->tenant_id, $deal->id, DealHistoryEvent::Won, actorUserId: (int) $actor->id, message: 'Concluído.');
128                $this->syncInterestWonLost($deal, PropertyInterestStatus::ClosedWon);
129                DealWon::dispatch($deal->tenant_id, $deal->id);
130
131                if (config('sales.automation.won_starts_post_sale', true)) {
132                    $this->createTask->execute($deal->fresh(), $actor, [
133                        'type' => 'post_sale',
134                        'title' => 'Iniciar pós-venda',
135                        'due_at' => now()->addDays(2)->toDateTimeString(),
136                        'is_automatic' => true,
137                    ]);
138                }
139            }
140
141            if ($to->is_lost) {
142                $this->activities->record($deal, DealActivityType::Lost, 'Negócio perdido', $lostNotes, (int) $actor->id);
143                $this->history->write($deal->tenant_id, $deal->id, DealHistoryEvent::Lost, actorUserId: (int) $actor->id, message: $lostReason);
144                $this->syncInterestWonLost($deal, PropertyInterestStatus::Lost);
145                DealLost::dispatch($deal->tenant_id, $deal->id, $lostReason);
146            }
147
148            return $deal->fresh(['stage', 'tasks', 'contact', 'property']);
149        });
150    }
151
152    private function syncInterestWonLost(DealModel $deal, PropertyInterestStatus $status): void
153    {
154        if ($deal->property_interest_id === null) {
155            return;
156        }
157
158        $interest = PropertyInterestModel::query()->find($deal->property_interest_id);
159        if ($interest === null) {
160            return;
161        }
162
163        if ($interest->status === $status || ($interest->status?->canTransitionTo($status) ?? false)) {
164            $this->updateInterestStage->execute($deal->tenant_id, $interest, $status);
165        }
166    }
167}