Failed Conditions
Push — master ( 72937b...3aa1f5 )
by Sam
11:02
created

Importer::readMembership()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5.0342

Importance

Changes 0
Metric Value
cc 5
eloc 8
nc 4
nop 1
dl 0
loc 17
ccs 8
cts 9
cp 0.8889
crap 5.0342
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Service;
6
7
use Application\DBAL\Types\MembershipType;
8
use Application\DBAL\Types\ProductTypeType;
9
use Application\Model\User;
10
use Doctrine\DBAL\Connection;
11
use Ecodev\Felix\Api\Exception;
12
use Laminas\Validator\EmailAddress;
13
use Throwable;
14
15
/**
16
 * Service to import users from CSV with maximal performance.
17
 *
18
 * Users are never deleted, even though technically they should be, to limit the loss of
19
 * data in case of human error in the incoming files. Because it could mean losing all historic
20
 * of purchases.
21
 *
22
 * On the other hand, organizations are **always** deleted, because they don't have any related objects,
23
 * and they are not editable (not even visible) in any way in the app.
24
 */
25
class Importer
26
{
27
    private int $lineNumber = 0;
28
29
    private array $reviewByNumber = [];
30
31
    private array $countryByName = [];
32
33
    private Connection $connection;
34
35
    private int $updatedUsers = 0;
36
37
    private int $updatedOrganizations = 0;
38
39
    private int $deletedOrganizations = 0;
40
41
    private array $seenEmails = [];
42
43
    private array $seenPatterns = [];
44
45
    private ?int $currentUser;
46
47 10
    public function import(string $filename): array
48
    {
49 10
        $start = microtime(true);
50 10
        $this->connection = _em()->getConnection();
51 10
        $this->fetchReviews();
52 10
        $this->fetchCountries();
53 10
        $this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null;
54 10
        $this->updatedUsers = 0;
55 10
        $this->updatedOrganizations = 0;
56 10
        $this->deletedOrganizations = 0;
57 10
        $this->seenEmails = [];
58 10
        $this->seenPatterns = [];
59
60 10
        $file = fopen($filename, 'rb');
61 10
        if ($file === false) {
62
            throw new Exception('Could not read file: ' . $filename);
63
        }
64
65
        try {
66 10
            $this->connection->beginTransaction();
67 10
            $this->markToDelete();
68 10
            $this->read($file);
69 1
            $this->deleteOldOrganizations();
70 1
            $this->connection->commit();
71 9
        } catch (Throwable $exception) {
72 9
            $this->connection->rollBack();
73
74 9
            throw $exception;
75 1
        } finally {
76 10
            fclose($file);
77
        }
78
79 1
        $totalUsers = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM user');
80 1
        $totalOrganizations = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM organization');
81
82 1
        $time = round(microtime(true) - $start, 1);
83
84
        return [
85 1
            'updatedUsers' => $this->updatedUsers,
86 1
            'updatedOrganizations' => $this->updatedOrganizations,
87 1
            'deletedOrganizations' => $this->deletedOrganizations,
88 1
            'totalUsers' => $totalUsers,
89 1
            'totalOrganizations' => $totalOrganizations,
90 1
            'totalLines' => $this->lineNumber,
91 1
            'time' => $time,
92
        ];
93
    }
94
95 10
    private function fetchReviews(): void
96
    {
97 10
        $records = $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL');
98
99 10
        $this->reviewByNumber = [];
100 10
        foreach ($records as $r) {
101 10
            $this->reviewByNumber[$r['review_number']] = $r['id'];
102
        }
103 10
    }
104
105 10
    private function fetchCountries(): void
106
    {
107 10
        $records = $this->connection->fetchAll('SELECT id, name, UPPER(name) AS upper FROM country');
108
109 10
        $this->countryByName = [];
110 10
        foreach ($records as $r) {
111 10
            $this->countryByName[$r['upper']] = $r;
112
        }
113 10
    }
114
115
    /**
116
     * @param resource $file
117
     */
118 10
    private function read($file): void
119
    {
120 10
        $this->lineNumber = 0;
121 10
        $expectedColumnCount = 12;
122 10
        while ($line = fgetcsv($file)) {
123 10
            ++$this->lineNumber;
124
125 10
            $actualColumnCount = count($line);
126 10
            if ($actualColumnCount !== $expectedColumnCount) {
127 1
                $this->throw("Doit avoir exactement $expectedColumnCount colonnes, mais en a " . $actualColumnCount);
128
            }
129
130
            [
131 9
                $email,
132
                $pattern,
133
                $subscriptionType,
134
                $lastReviewNumber,
135
                $membership,
136
                $firstName,
137
                $lastName,
138
                $street,
139
                // $wtf,
140
                $postcode,
141
                $locality,
142
                $country,
143
                $phone,
144
            ] = $line;
145
146 9
            if ($email && $pattern) {
147
                $this->throw('Il faut soit un email, soit un pattern, mais les deux existent');
148 9
            } elseif (!$email && !$pattern) {
149 1
                $this->throw('Il faut soit un email, soit un pattern, mais aucun existe');
150
            }
151
152 8
            $lastReviewId = $this->readReviewId($lastReviewNumber);
153
154 6
            if ($email) {
155 6
                $this->assertEmail($email);
156 5
                $membership = $this->readMembership($membership);
157 4
                $country = $this->readCountryId($country);
158 3
                $subscriptionType = $this->readSubscriptionType($subscriptionType);
159
160 2
                $this->updateUser(
161 2
                    $email,
162
                    $subscriptionType,
163
                    $lastReviewId,
164
                    $membership,
165
                    $firstName,
166
                    $lastName,
167
                    $street,
168
                    $postcode,
169
                    $locality,
170
                    $country,
171
                    $phone
172
                );
173 1
            } elseif ($pattern) {
174 1
                $this->assertPattern($pattern);
175
176 1
                $this->updateOrganization(
177 1
                    $pattern,
178
                    $lastReviewId
179
                );
180
            } else {
181
                $this->throw("L'email suivant n'est ni une addresse email valide, ni un expression régulière valide : " . $email);
182
            }
183
        }
184 1
    }
185
186
    private function readDate(string $date): ?string
0 ignored issues
show
Unused Code introduced by
The method readDate() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
187
    {
188
        if (!$date) {
189
            return null;
190
        }
191
192
        if (!preg_match('~^\d{4}-\d{2}-\d{2}$~', $date)) {
193
            $this->throw('La date devrait avoir le format YYYY-MM-DD, mais est : ' . $date);
194
        }
195
196
        return $date;
197
    }
198
199 6
    private function assertEmail(string $email): void
200
    {
201 6
        $validator = new EmailAddress();
202 6
        if (!$validator->isValid($email)) {
203 1
            $this->throw("Ce n'est pas une addresse email valide : " . $email);
204
        }
205
206 5
        if (array_key_exists($email, $this->seenEmails)) {
207 1
            $this->throw('L\'email "' . $email . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenEmails[$email]);
208
        }
209
210 5
        $this->seenEmails[$email] = $this->lineNumber;
211 5
    }
212
213 1
    private function assertPattern(string $pattern): void
214
    {
215 1
        if (@preg_match('~' . $pattern . '~', '') === false) {
216
            $this->throw("Ce n'est pas une expression régulière valide : " . $pattern);
217
        }
218
219 1
        if (array_key_exists($pattern, $this->seenPatterns)) {
220
            $this->throw('Le pattern "' . $pattern . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenPatterns[$pattern]);
221
        }
222
223 1
        $this->seenPatterns[$pattern] = $this->lineNumber;
224 1
    }
225
226 8
    private function readReviewId(string $reviewNumber): ?string
227
    {
228 8
        if (!$reviewNumber) {
229 5
            return null;
230
        }
231
232 4
        if ($reviewNumber && !preg_match('~^\d+$~', $reviewNumber)) {
233 1
            $this->throw('Un numéro de revue doit être entièrement numérique, mais est : ' . $reviewNumber);
234
        }
235
236 3
        $reviewNumberNumeric = (int) $reviewNumber;
237 3
        if (!array_key_exists($reviewNumberNumeric, $this->reviewByNumber)) {
238 1
            $this->throw('Revue introuvable pour le numéro de revue : ' . $reviewNumber);
239
        }
240
241 2
        return $this->reviewByNumber[$reviewNumberNumeric];
242
    }
243
244 4
    private function readCountryId(string $country): ?string
245
    {
246 4
        if (!$country) {
247 3
            return null;
248
        }
249
250
        // Case insensitive match
251 2
        $upper = trim(mb_strtoupper($country));
252 2
        if (array_key_exists($upper, $this->countryByName)) {
253 1
            return $this->countryByName[$upper]['id'];
254
        }
255
256
        // Suggest our best guess, so user can fix their data without lookup up countries manually
257 1
        $best = 0;
258 1
        $bestGuess = 0;
259 1
        foreach ($this->countryByName as $r) {
260 1
            similar_text($upper, $r['upper'], $percent);
261 1
            if ($percent > $best) {
262 1
                $best = $percent;
263 1
                $bestGuess = $r;
264
            }
265
        }
266
267 1
        $this->throw('Pays "' . $country . '" introuvable. Vouliez-vous dire "' . $bestGuess['name'] . '" ?');
268
    }
269
270 9
    private function throw(string $message): void
271
    {
272 9
        throw new Exception('A la ligne ' . $this->lineNumber . ' : ' . $message);
273
    }
274
275 1
    private function deleteOldOrganizations(): void
276
    {
277 1
        $sql = 'DELETE FROM organization WHERE should_delete';
278 1
        $this->deletedOrganizations += $this->connection->executeUpdate($sql);
279 1
    }
280
281 5
    private function readMembership($membership): string
282
    {
283 5
        if ($membership === '' || $membership === 'Non membre') {
284 4
            return MembershipType::NONE;
285
        }
286
287 2
        if ($membership === 'Membre (cotisation pay&#233;e)') {
288 1
            return MembershipType::PAYED;
289
        }
290
291 2
        if ($membership === 'Membre (cotistaion due)') {
292 1
            return MembershipType::DUE;
293
        }
294
295 1
        $this->throw('Le membership aux artisans est invalide : ' . $membership);
296
297
        return MembershipType::NONE;
298
    }
299
300 3
    private function readSubscriptionType(string $subscriptionType): ?string
301
    {
302 3
        if (!$subscriptionType) {
303 2
            return null;
304
        }
305
306 2
        if ($subscriptionType === 'Web') {
307 1
            return ProductTypeType::DIGITAL;
308
        }
309
310 2
        if ($subscriptionType === 'Papier') {
311 1
            return ProductTypeType::PAPER;
312
        }
313
314 1
        if ($subscriptionType === 'Papier&#47;web') {
315
            return ProductTypeType::BOTH;
316
        }
317
318 1
        $this->throw('Le subscriptionType est invalide : ' . $subscriptionType);
319
    }
320
321 2
    private function updateUser(...$args): void
322
    {
323 2
        $sql = 'INSERT INTO user (
324
                            email,
325
                            subscription_type,
326
                            subscription_last_review_id,
327
                            membership,
328
                            first_name,
329
                            last_name,
330
                            street,
331
                            postcode,
332
                            locality,
333
                            country_id,
334
                            phone,
335
                            web_temporary_access,
336
                            should_delete,
337
                            password,
338
                            creator_id,
339
                            creation_date
340
                        )
341
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
342
                        ON DUPLICATE KEY UPDATE
343
                            email = VALUES(email),
344
                            subscription_type = VALUES(subscription_type),
345
                            subscription_last_review_id = VALUES(subscription_last_review_id),
346
                            membership = VALUES(membership),
347
                            first_name = VALUES(first_name),
348
                            last_name = VALUES(last_name),
349
                            street = VALUES(street),
350
                            postcode = VALUES(postcode),
351
                            locality = VALUES(locality),
352
                            country_id = VALUES(country_id),
353
                            phone = VALUES(phone),
354
                            web_temporary_access = VALUES(web_temporary_access),
355
                            should_delete = VALUES(should_delete),
356
                            updater_id = VALUES(creator_id),
357
                            update_date = NOW()';
358
359 2
        $params = $args;
360 2
        $params[] = false; // web_temporary_access
361 2
        $params[] = false; // should_delete
362 2
        $params[] = ''; // password
363 2
        $params[] = $this->currentUser;
364
365 2
        $changed = $this->connection->executeUpdate($sql, $params);
366
367 2
        if ($changed) {
368 2
            ++$this->updatedUsers;
369
        }
370 2
    }
371
372 1
    private function updateOrganization(...$args): void
373
    {
374 1
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, creator_id, creation_date)
375
                        VALUES (?, ?, ?, NOW())
376
                        ON DUPLICATE KEY UPDATE
377
                        pattern = VALUES(pattern),
378
                        subscription_last_review_id = VALUES(subscription_last_review_id),
379
                        updater_id = VALUES(creator_id),
380
                        update_date = NOW()';
381
382 1
        $params = $args;
383 1
        $params[] = $this->currentUser;
384
385 1
        $changed = $this->connection->executeUpdate($sql, $params);
386
387 1
        if ($changed) {
388 1
            ++$this->updatedOrganizations;
389
        }
390 1
    }
391
392 10
    private function markToDelete(): void
393
    {
394 10
        $this->connection->executeUpdate('UPDATE user SET should_delete = 1');
395 10
        $this->connection->executeUpdate('UPDATE organization SET should_delete = 1');
396 10
    }
397
}
398