Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.02% covered (success)
98.02%
99 / 101
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
AnalyzeImportAction
98.02% covered (success)
98.02%
99 / 101
66.67% covered (warning)
66.67%
2 / 3
17
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
97.70% covered (success)
97.70%
85 / 87
0.00% covered (danger)
0.00%
0 / 1
15
 persistIssue
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Importing\Application\Actions;
6
7use Illuminate\Support\Facades\DB;
8use ImovelZapAi\Importing\Application\Data\ParseIssue;
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\Application\Services\ImportMappingResolver;
14use ImovelZapAi\Importing\Domain\Enums\ImportIssueSeverity;
15use ImovelZapAi\Importing\Domain\Enums\ImportJobStatus;
16use ImovelZapAi\Importing\Domain\Enums\ImportLogEvent;
17use ImovelZapAi\Importing\Domain\Exceptions\ImportException;
18use ImovelZapAi\Importing\Infrastructure\Persistence\Eloquent\ImportErrorModel;
19use ImovelZapAi\Importing\Infrastructure\Persistence\Eloquent\ImportJobModel;
20use ImovelZapAi\Tenancy\Domain\Enums\TenantStatus;
21use ImovelZapAi\Tenancy\Infrastructure\Persistence\Eloquent\TenantModel;
22use ImovelZapAi\Tenancy\Support\TenantContext;
23
24final class AnalyzeImportAction
25{
26    public function __construct(
27        private readonly VrSyncParserInterface $parser,
28        private readonly ImportListingValidator $validator,
29        private readonly ImportMappingResolver $mappingResolver,
30        private readonly ImportFileStorage $storage,
31        private readonly ImportLogWriter $logWriter,
32    ) {}
33
34    public function execute(string $importJobId, string $tenantId): ImportJobModel
35    {
36        TenantContext::set($tenantId);
37
38        $tenant = TenantModel::query()->find($tenantId);
39        if ($tenant === null || $tenant->status !== TenantStatus::Active) {
40            throw ImportException::tenantInactive();
41        }
42
43        $job = ImportJobModel::query()
44            ->where('tenant_id', $tenantId)
45            ->whereKey($importJobId)
46            ->with('file')
47            ->firstOrFail();
48
49        try {
50            $file = $job->file;
51            if ($file === null) {
52                throw ImportException::fileMissing();
53            }
54
55            $this->storage->verifyHash($file->path, $file->sha256);
56            $absolute = $this->storage->absolutePath($file->path);
57
58            $this->logWriter->write($tenantId, $job->id, ImportLogEvent::AnalysisStarted);
59
60            $parseResult = $this->parser->parse($absolute);
61            $validationIssues = $this->validator->validate($parseResult->properties);
62            $issues = array_merge($parseResult->issues, $validationIssues);
63
64            $resolved = $this->mappingResolver->resolve($parseResult->properties, $job->mapping ?? []);
65
66            $errorListingIndexes = [];
67            $warningListingIndexes = [];
68            foreach ($issues as $issue) {
69                if ($issue->listingIndex === null) {
70                    continue;
71                }
72                if ($issue->severity === ImportIssueSeverity::Error) {
73                    $errorListingIndexes[$issue->listingIndex] = true;
74                } else {
75                    $warningListingIndexes[$issue->listingIndex] = true;
76                }
77            }
78
79            $total = count($parseResult->properties);
80            $errorCount = count($errorListingIndexes);
81            $warningCount = count($warningListingIndexes);
82            $valid = max(0, $total - $errorCount);
83
84            $missingPhotos = 0;
85            foreach ($parseResult->properties as $property) {
86                if ($property->media === []) {
87                    $missingPhotos++;
88                }
89            }
90
91            DB::transaction(function () use ($job, $tenantId, $issues, $resolved, $total, $valid, $errorCount, $warningCount, $missingPhotos): void {
92                ImportErrorModel::query()->where('import_job_id', $job->id)->delete();
93
94                foreach ($issues as $issue) {
95                    $this->persistIssue($tenantId, $job->id, $issue);
96                }
97
98                $status = $resolved['ambiguities'] === []
99                    ? ImportJobStatus::PreviewReady
100                    : ImportJobStatus::AwaitingMapping;
101
102                if ($resolved['ambiguities'] === [] && $errorCount === 0) {
103                    $status = ImportJobStatus::Ready;
104                } elseif ($resolved['ambiguities'] === []) {
105                    $status = ImportJobStatus::PreviewReady;
106                }
107
108                $job->forceFill([
109                    'status' => $status,
110                    'total_listings' => $total,
111                    'valid_listings' => $valid,
112                    'error_listings' => $errorCount,
113                    'warning_listings' => $warningCount,
114                    'mapping' => $resolved['mapping'],
115                    'preview_summary' => [
116                        'total' => $total,
117                        'valid' => $valid,
118                        'with_errors' => $errorCount,
119                        'with_warnings' => $warningCount,
120                        'missing_photos' => $missingPhotos,
121                        'unknown_neighborhoods' => $resolved['unknown_neighborhoods'],
122                        'ambiguities' => $resolved['ambiguities'],
123                    ],
124                    'preview_ready_at' => now(),
125                    'failure_message' => null,
126                ])->save();
127            });
128
129            $this->logWriter->write(
130                $tenantId,
131                $job->id,
132                ImportLogEvent::AnalysisCompleted,
133                message: 'Análise concluída.',
134            );
135        } catch (\Throwable $e) {
136            $job->forceFill([
137                'status' => ImportJobStatus::Failed,
138                'failure_message' => $e->getMessage(),
139                'completed_at' => now(),
140            ])->save();
141
142            $this->logWriter->write(
143                $tenantId,
144                $job->id,
145                ImportLogEvent::ImportFailed,
146                message: $e->getMessage(),
147            );
148
149            throw $e;
150        } finally {
151            TenantContext::clear();
152        }
153
154        return $job->fresh(['file', 'errors']);
155    }
156
157    private function persistIssue(string $tenantId, string $jobId, ParseIssue $issue): void
158    {
159        ImportErrorModel::query()->create([
160            'tenant_id' => $tenantId,
161            'import_job_id' => $jobId,
162            'severity' => $issue->severity,
163            'code' => $issue->code,
164            'external_id' => $issue->externalId,
165            'listing_index' => $issue->listingIndex,
166            'path' => $issue->path,
167            'message' => $issue->message,
168            'resolution' => null,
169            'status' => 'open',
170            'context' => $issue->context,
171        ]);
172    }
173}