Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.09% covered (success)
93.09%
175 / 188
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExecuteImportAction
93.09% covered (success)
93.09%
175 / 188
66.67% covered (warning)
66.67%
2 / 3
29.28
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
92.70% covered (success)
92.70%
165 / 178
0.00% covered (danger)
0.00%
0 / 1
27.28
 checkpoint
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Importing\Application\Actions;
6
7use App\Models\User;
8use ImovelZapAi\Importing\Application\Ports\PropertyImportWriterInterface;
9use ImovelZapAi\Importing\Application\Ports\VrSyncParserInterface;
10use ImovelZapAi\Importing\Application\Services\ImportFileStorage;
11use ImovelZapAi\Importing\Application\Services\ImportListingValidator;
12use ImovelZapAi\Importing\Application\Services\ImportLogWriter;
13use ImovelZapAi\Importing\Domain\Enums\ImportIssueSeverity;
14use ImovelZapAi\Importing\Domain\Enums\ImportItemResult;
15use ImovelZapAi\Importing\Domain\Enums\ImportJobStatus;
16use ImovelZapAi\Importing\Domain\Enums\ImportLogEvent;
17use ImovelZapAi\Importing\Domain\Exceptions\ImportException;
18use ImovelZapAi\Importing\Infrastructure\Persistence\Eloquent\ImportJobModel;
19use ImovelZapAi\Importing\Infrastructure\Persistence\Eloquent\ImportLogModel;
20use ImovelZapAi\Tenancy\Infrastructure\Persistence\Eloquent\TenantModel;
21use ImovelZapAi\Tenancy\Domain\Enums\TenantStatus;
22use ImovelZapAi\Tenancy\Support\TenantContext;
23
24final class ExecuteImportAction
25{
26    public function __construct(
27        private readonly VrSyncParserInterface $parser,
28        private readonly ImportListingValidator $validator,
29        private readonly ImportFileStorage $storage,
30        private readonly PropertyImportWriterInterface $writer,
31        private readonly ImportLogWriter $logWriter,
32    ) {}
33
34    public function execute(string $importJobId, string $tenantId): ImportJobModel
35    {
36        TenantContext::clear();
37        TenantContext::set($tenantId);
38
39        try {
40            $tenant = TenantModel::query()->find($tenantId);
41            if ($tenant === null || $tenant->status !== TenantStatus::Active) {
42                throw ImportException::tenantInactive();
43            }
44
45            $job = ImportJobModel::query()
46                ->where('tenant_id', $tenantId)
47                ->whereKey($importJobId)
48                ->with(['file', 'creator'])
49                ->firstOrFail();
50
51            $file = $job->file;
52            if ($file === null) {
53                throw ImportException::fileMissing();
54            }
55
56            $this->storage->verifyHash($file->path, $file->sha256);
57            $absolute = $this->storage->absolutePath($file->path);
58
59            $job->forceFill([
60                'status' => ImportJobStatus::Processing,
61                'processing_started_at' => $job->processing_started_at ?? now(),
62            ])->save();
63
64            $parseResult = $this->parser->parse($absolute);
65            $validationIssues = $this->validator->validate($parseResult->properties);
66
67            $blocked = [];
68            foreach ($validationIssues as $issue) {
69                if ($issue->severity === ImportIssueSeverity::Error && $issue->listingIndex !== null) {
70                    $blocked[$issue->listingIndex] = true;
71                }
72            }
73
74            $actor = $job->creator instanceof User
75                ? $job->creator
76                : User::query()->findOrFail($job->created_by);
77
78            $source = (string) config('importing.source', 'vrsync');
79            $mapping = $job->mapping ?? [];
80            $startIndex = (int) $job->checkpoint_index;
81
82            $imported = (int) $job->imported_count;
83            $updated = (int) $job->updated_count;
84            $unchanged = (int) $job->unchanged_count;
85            $ignored = (int) $job->ignored_count;
86            $failed = (int) $job->failed_count;
87            $photos = (int) $job->photos_imported;
88
89            $seenExternal = [];
90
91            foreach ($parseResult->properties as $property) {
92                if ($property->index < $startIndex) {
93                    continue;
94                }
95
96                $job->forceFill([
97                    'progress_current' => $property->index + 1,
98                    'progress_total' => count($parseResult->properties),
99                ])->save();
100
101                if (isset($blocked[$property->index])) {
102                    $failed++;
103                    $this->logWriter->write(
104                        $tenantId,
105                        $job->id,
106                        ImportLogEvent::ListingFailed,
107                        externalId: $property->externalId,
108                        listingIndex: $property->index,
109                        result: ImportItemResult::Failed->value,
110                        message: 'Listing bloqueado por erros de validação.',
111                    );
112                    $this->checkpoint($job, $property->index + 1, $imported, $updated, $unchanged, $ignored, $failed, $photos);
113                    continue;
114                }
115
116                $externalId = (string) $property->externalId;
117                if (isset($seenExternal[$externalId])) {
118                    $ignored++;
119                    $this->logWriter->write(
120                        $tenantId,
121                        $job->id,
122                        ImportLogEvent::ListingIgnored,
123                        externalId: $externalId,
124                        listingIndex: $property->index,
125                        result: ImportItemResult::Ignored->value,
126                        message: 'Duplicata idêntica no feed.',
127                    );
128                    $this->checkpoint($job, $property->index + 1, $imported, $updated, $unchanged, $ignored, $failed, $photos);
129                    continue;
130                }
131                $seenExternal[$externalId] = true;
132
133                $alreadyDone = ImportLogModel::query()
134                    ->where('import_job_id', $job->id)
135                    ->where('listing_index', $property->index)
136                    ->whereIn('event', [
137                        ImportLogEvent::ListingCreated,
138                        ImportLogEvent::ListingUpdated,
139                        ImportLogEvent::ListingUnchanged,
140                        ImportLogEvent::ListingIgnored,
141                        ImportLogEvent::ListingFailed,
142                    ])
143                    ->exists();
144
145                if ($alreadyDone) {
146                    $this->checkpoint($job, $property->index + 1, $imported, $updated, $unchanged, $ignored, $failed, $photos);
147                    continue;
148                }
149
150                $started = hrtime(true);
151
152                try {
153                    $result = $this->writer->write($tenantId, $actor, $property, $mapping, $source);
154                    $duration = (int) ((hrtime(true) - $started) / 1_000_000);
155
156                    match ($result->result) {
157                        ImportItemResult::Created => $imported++,
158                        ImportItemResult::Updated => $updated++,
159                        ImportItemResult::Unchanged => $unchanged++,
160                        ImportItemResult::Ignored => $ignored++,
161                        ImportItemResult::Failed => $failed++,
162                    };
163
164                    $photos += $result->photosImported;
165
166                    $event = match ($result->result) {
167                        ImportItemResult::Created => ImportLogEvent::ListingCreated,
168                        ImportItemResult::Updated => ImportLogEvent::ListingUpdated,
169                        ImportItemResult::Unchanged => ImportLogEvent::ListingUnchanged,
170                        ImportItemResult::Ignored => ImportLogEvent::ListingIgnored,
171                        ImportItemResult::Failed => ImportLogEvent::ListingFailed,
172                    };
173
174                    $this->logWriter->write(
175                        $tenantId,
176                        $job->id,
177                        $event,
178                        propertyId: $result->propertyId,
179                        externalId: $externalId,
180                        listingIndex: $property->index,
181                        result: $result->result->value,
182                        diffBefore: $result->diffBefore,
183                        diffAfter: $result->diffAfter,
184                        photosCount: $result->photosImported,
185                        durationMs: $duration,
186                        message: $result->message,
187                    );
188                } catch (\Throwable $e) {
189                    $failed++;
190                    $this->logWriter->write(
191                        $tenantId,
192                        $job->id,
193                        ImportLogEvent::ListingFailed,
194                        externalId: $externalId,
195                        listingIndex: $property->index,
196                        result: ImportItemResult::Failed->value,
197                        message: $e->getMessage(),
198                    );
199                }
200
201                $this->checkpoint($job, $property->index + 1, $imported, $updated, $unchanged, $ignored, $failed, $photos);
202            }
203
204            $finalStatus = $failed > 0
205                ? ImportJobStatus::CompletedWithErrors
206                : ImportJobStatus::Completed;
207
208            $startedAt = $job->processing_started_at;
209            $durationSeconds = $startedAt !== null ? now()->diffInSeconds($startedAt) : null;
210
211            $job->forceFill([
212                'status' => $finalStatus,
213                'imported_count' => $imported,
214                'updated_count' => $updated,
215                'unchanged_count' => $unchanged,
216                'ignored_count' => $ignored,
217                'failed_count' => $failed,
218                'photos_imported' => $photos,
219                'completed_at' => now(),
220                'report_summary' => [
221                    'imported' => $imported,
222                    'updated' => $updated,
223                    'unchanged' => $unchanged,
224                    'ignored' => $ignored,
225                    'failed' => $failed,
226                    'photos_imported' => $photos,
227                    'duration_seconds' => $durationSeconds,
228                ],
229            ])->save();
230
231            $this->logWriter->write(
232                $tenantId,
233                $job->id,
234                ImportLogEvent::ImportCompleted,
235                message: 'Importação finalizada.',
236            );
237
238            return $job->fresh(['errors', 'file']);
239        } catch (\Throwable $e) {
240            ImportJobModel::query()
241                ->whereKey($importJobId)
242                ->update([
243                    'status' => ImportJobStatus::Failed->value,
244                    'failure_message' => $e->getMessage(),
245                    'completed_at' => now(),
246                ]);
247
248            $this->logWriter->write(
249                $tenantId,
250                $importJobId,
251                ImportLogEvent::ImportFailed,
252                message: $e->getMessage(),
253            );
254
255            throw $e;
256        } finally {
257            TenantContext::clear();
258        }
259    }
260
261    private function checkpoint(
262        ImportJobModel $job,
263        int $nextIndex,
264        int $imported,
265        int $updated,
266        int $unchanged,
267        int $ignored,
268        int $failed,
269        int $photos,
270    ): void {
271        $job->forceFill([
272            'checkpoint_index' => $nextIndex,
273            'imported_count' => $imported,
274            'updated_count' => $updated,
275            'unchanged_count' => $unchanged,
276            'ignored_count' => $ignored,
277            'failed_count' => $failed,
278            'photos_imported' => $photos,
279        ])->save();
280    }
281}