Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.05% covered (success)
96.05%
73 / 76
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
CreateProposalAction
96.05% covered (success)
96.05%
73 / 76
50.00% covered (danger)
50.00%
1 / 2
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
96.00% covered (success)
96.00%
72 / 75
0.00% covered (danger)
0.00%
0 / 1
8
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Proposals\Application\Actions;
6
7use App\Models\User;
8use Illuminate\Support\Facades\DB;
9use Illuminate\Support\Str;
10use ImovelZapAi\Conversations\Infrastructure\Persistence\Eloquent\ContactModel;
11use ImovelZapAi\Proposals\Application\Services\DefaultProposalTemplateSeeder;
12use ImovelZapAi\Proposals\Application\Services\ProposalHistoryWriter;
13use ImovelZapAi\Proposals\Application\Services\ProposalPdfGenerator;
14use ImovelZapAi\Proposals\Application\Services\ProposalTemplateRenderer;
15use ImovelZapAi\Proposals\Domain\Enums\ProposalHistoryEvent;
16use ImovelZapAi\Proposals\Domain\Enums\ProposalStatus;
17use ImovelZapAi\Proposals\Domain\Events\ProposalCreated;
18use ImovelZapAi\Proposals\Domain\Exceptions\ProposalException;
19use ImovelZapAi\Proposals\Infrastructure\Persistence\Eloquent\ProposalModel;
20use ImovelZapAi\Proposals\Infrastructure\Persistence\Eloquent\ProposalRecipientModel;
21use ImovelZapAi\Proposals\Infrastructure\Persistence\Eloquent\ProposalTemplateModel;
22use ImovelZapAi\Proposals\Infrastructure\Persistence\Eloquent\ProposalVersionModel;
23use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\PropertyModel;
24use ImovelZapAi\Sales\Application\Actions\ChangeDealStageAction;
25use ImovelZapAi\Sales\Domain\Enums\DealStageKey;
26use ImovelZapAi\Sales\Infrastructure\Persistence\Eloquent\DealModel;
27
28final class CreateProposalAction
29{
30    public function __construct(
31        private readonly DefaultProposalTemplateSeeder $templates,
32        private readonly ProposalTemplateRenderer $renderer,
33        private readonly ProposalPdfGenerator $pdf,
34        private readonly ProposalHistoryWriter $history,
35        private readonly ChangeDealStageAction $changeDealStage,
36    ) {}
37
38    /**
39     * @param  array{
40     *     contact_id: string,
41     *     deal_id?: string|null,
42     *     property_id?: string|null,
43     *     owner_id?: string|null,
44     *     template_id?: string|null,
45     *     title?: string|null,
46     *     amount?: float|string|null,
47     *     commission_percent?: float|string|null,
48     *     intro_message?: string|null,
49     *     expires_at?: string|null,
50     *     purpose?: string|null,
51     * }  $data
52     */
53    public function execute(string $tenantId, User $actor, array $data): ProposalModel
54    {
55        return DB::transaction(function () use ($tenantId, $actor, $data): ProposalModel {
56            $contact = ContactModel::query()->where('tenant_id', $tenantId)->whereKey($data['contact_id'])->firstOrFail();
57
58            $deal = null;
59            if (! empty($data['deal_id'])) {
60                $deal = DealModel::query()->where('tenant_id', $tenantId)->whereKey($data['deal_id'])->firstOrFail();
61            }
62
63            $propertyId = $data['property_id'] ?? $deal?->property_id;
64            if ($propertyId !== null) {
65                PropertyModel::query()->where('tenant_id', $tenantId)->whereKey($propertyId)->firstOrFail();
66            }
67
68            $template = ! empty($data['template_id'])
69                ? ProposalTemplateModel::query()->where('tenant_id', $tenantId)->whereKey($data['template_id'])->firstOrFail()
70                : $this->templates->ensureForTenant($tenantId, $actor);
71
72            $number = 'P-'.now()->format('ymd').'-'.Str::upper(Str::random(4));
73            $title = $data['title'] ?? ('Proposta — '.($contact->display_name ?: 'Cliente'));
74
75            $proposal = ProposalModel::query()->create([
76                'tenant_id' => $tenantId,
77                'deal_id' => $deal?->id,
78                'contact_id' => $contact->id,
79                'property_id' => $propertyId,
80                'owner_id' => $data['owner_id'] ?? null,
81                'template_id' => $template->id,
82                'number' => $number,
83                'title' => $title,
84                'status' => ProposalStatus::Draft,
85                'amount' => $data['amount'] ?? $deal?->amount,
86                'currency' => 'BRL',
87                'commission_percent' => $data['commission_percent'] ?? null,
88                'intro_message' => $data['intro_message'] ?? 'Segue nossa proposta comercial para sua análise.',
89                'expires_at' => $data['expires_at'] ?? now()->addDays((int) config('proposals.default_expiry_days', 7)),
90                'created_by' => $actor->id,
91                'responsible_user_id' => $actor->id,
92            ]);
93
94            ProposalRecipientModel::query()->create([
95                'tenant_id' => $tenantId,
96                'proposal_id' => $proposal->id,
97                'contact_id' => $contact->id,
98                'name' => $contact->display_name,
99                'role' => 'client',
100            ]);
101
102            $proposal->load(['contact', 'property.neighborhood', 'property.city', 'responsible']);
103            $snapshot = $this->renderer->buildSnapshot($proposal, 1);
104            $html = $this->renderer->render($template, $proposal, $snapshot);
105
106            $version = ProposalVersionModel::query()->create([
107                'tenant_id' => $tenantId,
108                'proposal_id' => $proposal->id,
109                'version' => 1,
110                'snapshot' => $snapshot,
111                'html_body' => $html,
112                'change_summary' => 'Versão inicial',
113                'validation_code' => Str::upper(Str::random(10)),
114                'created_by' => $actor->id,
115            ]);
116
117            $pdf = $this->pdf->generate($proposal, $version, '');
118            $version->forceFill(['pdf_disk' => $pdf['disk'], 'pdf_path' => $pdf['path']])->save();
119
120            $proposal->forceFill(['current_version_id' => $version->id])->save();
121
122            $this->history->write(
123                $tenantId,
124                $proposal->id,
125                ProposalHistoryEvent::Created,
126                actorType: 'broker',
127                actorUserId: (int) $actor->id,
128                message: 'Proposta criada.',
129            );
130            $this->history->write(
131                $tenantId,
132                $proposal->id,
133                ProposalHistoryEvent::PdfGenerated,
134                actorType: 'system',
135                message: 'PDF da v1 gerado.',
136            );
137
138            if ($deal !== null && ! $deal->isClosed()) {
139                try {
140                    $this->changeDealStage->execute($deal, $actor, DealStageKey::Proposal->value);
141                } catch (\Throwable) {
142                    // deal already in terminal or same stage
143                }
144            }
145
146            ProposalCreated::dispatch($tenantId, $proposal->id);
147
148            return $proposal->fresh(['currentVersion', 'contact', 'property', 'deal']);
149        });
150    }
151}