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\Organization; |
10
|
|
|
use Application\Model\User; |
11
|
|
|
use Application\Repository\OrganizationRepository; |
12
|
|
|
use Doctrine\DBAL\Connection; |
13
|
|
|
use Ecodev\Felix\Api\Exception; |
14
|
|
|
use Laminas\Validator\EmailAddress; |
15
|
|
|
use Throwable; |
16
|
|
|
|
17
|
|
|
/** |
18
|
|
|
* Service to import users from CSV with maximal performance. |
19
|
|
|
* |
20
|
|
|
* Users are never deleted, even though technically they should be, to limit the loss of |
21
|
|
|
* data in case of human error in the incoming files. Because it could mean losing all historic |
22
|
|
|
* of purchases. |
23
|
|
|
* |
24
|
|
|
* On the other hand, organizations are **always** deleted, because they don't have any related objects, |
25
|
|
|
* and they are not editable (not even visible) in any way in the app. |
26
|
|
|
*/ |
27
|
|
|
class Importer |
28
|
|
|
{ |
29
|
|
|
private int $lineNumber = 0; |
30
|
|
|
|
31
|
|
|
private array $reviewByNumber = []; |
32
|
|
|
|
33
|
|
|
private array $countryByName = []; |
34
|
|
|
|
35
|
|
|
private Connection $connection; |
36
|
|
|
|
37
|
|
|
private int $updatedUsers = 0; |
38
|
|
|
|
39
|
|
|
private int $updatedOrganizations = 0; |
40
|
|
|
|
41
|
|
|
private int $deletedOrganizations = 0; |
42
|
|
|
|
43
|
|
|
private array $seenEmails = []; |
44
|
|
|
|
45
|
|
|
private array $seenPatterns = []; |
46
|
|
|
|
47
|
|
|
private ?int $currentUser; |
48
|
|
|
|
49
|
|
|
private array $errors = []; |
50
|
|
|
|
51
|
|
|
private array $usersParams = []; |
52
|
|
|
|
53
|
|
|
private array $organizationsParams = []; |
54
|
|
|
|
55
|
17 |
|
public function import(string $filename): array |
56
|
|
|
{ |
57
|
17 |
|
$start = microtime(true); |
58
|
17 |
|
$this->connection = _em()->getConnection(); |
59
|
17 |
|
$this->fetchReviews(); |
60
|
17 |
|
$this->fetchCountries(); |
61
|
17 |
|
$this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null; |
62
|
17 |
|
$this->updatedUsers = 0; |
63
|
17 |
|
$this->updatedOrganizations = 0; |
64
|
17 |
|
$this->deletedOrganizations = 0; |
65
|
17 |
|
$this->seenEmails = []; |
66
|
17 |
|
$this->seenPatterns = []; |
67
|
|
|
|
68
|
17 |
|
if (!file_exists($filename)) { |
69
|
1 |
|
throw new Exception('File not found: ' . $filename); |
70
|
|
|
} |
71
|
|
|
|
72
|
16 |
|
$file = fopen($filename, 'rb'); |
73
|
16 |
|
if ($file === false) { |
74
|
|
|
throw new Exception('Could not read file: ' . $filename); |
75
|
|
|
} |
76
|
|
|
|
77
|
16 |
|
$this->skipBOM($file); |
78
|
|
|
|
79
|
|
|
try { |
80
|
16 |
|
$this->connection->beginTransaction(); |
81
|
16 |
|
$this->read($file); |
82
|
|
|
|
83
|
16 |
|
$this->markToDelete(); |
84
|
16 |
|
$this->updateAllUsers(); |
85
|
16 |
|
$this->updateAllOrganizations(); |
86
|
16 |
|
$this->deleteOldOrganizations(); |
87
|
|
|
|
88
|
16 |
|
if ($this->errors) { |
89
|
11 |
|
throw new Exception(implode(PHP_EOL, $this->errors)); |
90
|
|
|
} |
91
|
|
|
|
92
|
|
|
// Give user automatic access via organization |
93
|
|
|
/** @var OrganizationRepository $organizationRepository */ |
94
|
5 |
|
$organizationRepository = _em()->getRepository(Organization::class); |
95
|
5 |
|
$organizationRepository->applyOrganizationAccesses(); |
96
|
|
|
|
97
|
5 |
|
$this->connection->commit(); |
98
|
11 |
|
} catch (Throwable $exception) { |
99
|
11 |
|
$this->connection->rollBack(); |
100
|
|
|
|
101
|
11 |
|
throw $exception; |
102
|
5 |
|
} finally { |
103
|
16 |
|
fclose($file); |
104
|
|
|
} |
105
|
|
|
|
106
|
5 |
|
$totalUsers = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM user'); |
|
|
|
|
107
|
5 |
|
$totalOrganizations = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM organization'); |
|
|
|
|
108
|
|
|
|
109
|
5 |
|
$time = round(microtime(true) - $start, 1); |
110
|
|
|
|
111
|
|
|
return [ |
112
|
5 |
|
'updatedUsers' => $this->updatedUsers, |
113
|
5 |
|
'updatedOrganizations' => $this->updatedOrganizations, |
114
|
5 |
|
'deletedOrganizations' => $this->deletedOrganizations, |
115
|
5 |
|
'totalUsers' => $totalUsers, |
116
|
5 |
|
'totalOrganizations' => $totalOrganizations, |
117
|
5 |
|
'totalLines' => $this->lineNumber, |
118
|
5 |
|
'time' => $time, |
119
|
|
|
]; |
120
|
|
|
} |
121
|
|
|
|
122
|
17 |
|
private function fetchReviews(): void |
123
|
|
|
{ |
124
|
17 |
|
$records = $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL'); |
|
|
|
|
125
|
|
|
|
126
|
17 |
|
$this->reviewByNumber = []; |
127
|
17 |
|
foreach ($records as $r) { |
128
|
17 |
|
$this->reviewByNumber[$r['review_number']] = $r['id']; |
129
|
|
|
} |
130
|
17 |
|
} |
131
|
|
|
|
132
|
17 |
|
private function fetchCountries(): void |
133
|
|
|
{ |
134
|
17 |
|
$records = $this->connection->fetchAll('SELECT id, LOWER(name) AS name FROM country'); |
|
|
|
|
135
|
|
|
|
136
|
17 |
|
$this->countryByName = []; |
137
|
17 |
|
foreach ($records as $r) { |
138
|
17 |
|
$r['name'] = $this->toUpper($r['name']); |
139
|
17 |
|
$this->countryByName[$r['name']] = $r; |
140
|
|
|
} |
141
|
17 |
|
} |
142
|
|
|
|
143
|
|
|
/** |
144
|
|
|
* @param resource $file |
145
|
|
|
*/ |
146
|
16 |
|
private function skipBOM($file): void |
147
|
|
|
{ |
148
|
|
|
// Consume BOM, but if not BOM, rewind to beginning |
149
|
16 |
|
if (fgets($file, 4) !== "\xEF\xBB\xBF") { |
150
|
14 |
|
rewind($file); |
151
|
|
|
} |
152
|
16 |
|
} |
153
|
|
|
|
154
|
|
|
/** |
155
|
|
|
* @param resource $file |
156
|
|
|
*/ |
157
|
16 |
|
private function read($file): void |
158
|
|
|
{ |
159
|
16 |
|
$this->lineNumber = 0; |
160
|
16 |
|
$expectedColumnCount = 14; |
161
|
16 |
|
while ($line = fgetcsv($file, 0, "\t")) { |
162
|
16 |
|
++$this->lineNumber; |
163
|
|
|
|
164
|
16 |
|
$actualColumnCount = count($line); |
165
|
16 |
|
if ($actualColumnCount !== $expectedColumnCount) { |
166
|
1 |
|
$this->throw("Doit avoir exactement $expectedColumnCount colonnes, mais en a " . $actualColumnCount); |
167
|
|
|
|
168
|
1 |
|
continue; |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
// un-escape all fields |
172
|
15 |
|
$line = array_map(fn ($r) => html_entity_decode($r), $line); |
173
|
|
|
|
174
|
|
|
[ |
175
|
15 |
|
$email, |
176
|
|
|
$pattern, |
177
|
|
|
$subscriptionType, |
178
|
|
|
$lastReviewNumber, |
179
|
|
|
$ignored, |
180
|
|
|
$firstName, |
181
|
|
|
$lastName, |
182
|
|
|
$street, |
183
|
|
|
$street2, |
184
|
|
|
$postcode, |
185
|
|
|
$locality, |
186
|
|
|
$country, |
187
|
|
|
$phone, |
188
|
|
|
$membership, |
189
|
|
|
] = $line; |
190
|
|
|
|
191
|
15 |
|
$email = trim($email); |
192
|
15 |
|
$pattern = trim($pattern); |
193
|
|
|
|
194
|
15 |
|
if (!$email && !$pattern) { |
195
|
1 |
|
$this->throw('Il faut soit un email, soit un pattern, mais aucun existe'); |
196
|
|
|
|
197
|
1 |
|
continue; |
198
|
|
|
} |
199
|
|
|
|
200
|
14 |
|
$lastReviewId = $this->readReviewId($lastReviewNumber); |
201
|
|
|
|
202
|
14 |
|
if ($email) { |
203
|
12 |
|
$this->assertEmail($email); |
204
|
12 |
|
$membership = $this->readMembership($membership); |
205
|
12 |
|
$country = $this->readCountryId($country); |
206
|
12 |
|
$subscriptionType = $this->readSubscriptionType($subscriptionType); |
207
|
|
|
|
208
|
12 |
|
$this->updateUser( |
209
|
12 |
|
$email, |
210
|
|
|
$subscriptionType, |
211
|
|
|
$lastReviewId, |
212
|
|
|
$membership, |
213
|
|
|
$firstName, |
214
|
|
|
$lastName, |
215
|
12 |
|
trim(implode(' ', [$street, $street2])), |
216
|
|
|
$postcode, |
217
|
|
|
$locality, |
218
|
|
|
$country, |
219
|
|
|
$phone |
220
|
|
|
); |
221
|
|
|
} |
222
|
|
|
|
223
|
14 |
|
if ($pattern) { |
224
|
4 |
|
$this->assertPattern($pattern); |
225
|
|
|
|
226
|
4 |
|
$this->updateOrganization( |
227
|
4 |
|
$pattern, |
228
|
|
|
$lastReviewId |
229
|
|
|
); |
230
|
|
|
} |
231
|
|
|
} |
232
|
16 |
|
} |
233
|
|
|
|
234
|
12 |
|
private function assertEmail(string $email): void |
235
|
|
|
{ |
236
|
12 |
|
$validator = new EmailAddress(); |
237
|
12 |
|
if (!$validator->isValid($email)) { |
238
|
1 |
|
$this->throw('Ce n\'est pas une addresse email valide : "' . $email . '"'); |
239
|
|
|
|
240
|
1 |
|
return; |
241
|
|
|
} |
242
|
|
|
|
243
|
11 |
|
if (array_key_exists($email, $this->seenEmails)) { |
244
|
1 |
|
$this->throw('L\'email "' . $email . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenEmails[$email]); |
245
|
|
|
|
246
|
1 |
|
return; |
247
|
|
|
} |
248
|
|
|
|
249
|
11 |
|
$this->seenEmails[$email] = $this->lineNumber; |
250
|
11 |
|
} |
251
|
|
|
|
252
|
4 |
|
private function assertPattern(string $pattern): void |
253
|
|
|
{ |
254
|
4 |
|
if (@preg_match('~' . $pattern . '~', '') === false) { |
255
|
1 |
|
$this->throw('Ce n\'est pas une expression régulière valide : "' . $pattern . '"'); |
256
|
|
|
|
257
|
1 |
|
return; |
258
|
|
|
} |
259
|
|
|
|
260
|
3 |
|
if (array_key_exists($pattern, $this->seenPatterns)) { |
261
|
1 |
|
$this->throw('Le pattern "' . $pattern . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenPatterns[$pattern]); |
262
|
|
|
|
263
|
1 |
|
return; |
264
|
|
|
} |
265
|
|
|
|
266
|
3 |
|
$this->seenPatterns[$pattern] = $this->lineNumber; |
267
|
3 |
|
} |
268
|
|
|
|
269
|
14 |
|
private function readReviewId(string $reviewNumber): ?string |
270
|
|
|
{ |
271
|
14 |
|
if (!$reviewNumber) { |
272
|
10 |
|
return null; |
273
|
|
|
} |
274
|
|
|
|
275
|
6 |
|
if ($reviewNumber && !preg_match('~^\d+$~', $reviewNumber)) { |
276
|
1 |
|
$this->throw('Un numéro de revue doit être entièrement numérique, mais est : "' . $reviewNumber . '"'); |
277
|
|
|
|
278
|
1 |
|
return null; |
279
|
|
|
} |
280
|
|
|
|
281
|
5 |
|
$reviewNumberNumeric = (int) $reviewNumber; |
282
|
5 |
|
if (!array_key_exists($reviewNumberNumeric, $this->reviewByNumber)) { |
283
|
2 |
|
$this->throw('Revue introuvable pour le numéro de revue : ' . $reviewNumber); |
284
|
|
|
|
285
|
2 |
|
return null; |
286
|
|
|
} |
287
|
|
|
|
288
|
3 |
|
return $this->reviewByNumber[$reviewNumberNumeric]; |
289
|
|
|
} |
290
|
|
|
|
291
|
12 |
|
private function readCountryId(string $country): ?string |
292
|
|
|
{ |
293
|
12 |
|
if (!$country) { |
294
|
9 |
|
return null; |
295
|
|
|
} |
296
|
|
|
|
297
|
|
|
// Case insensitive match |
298
|
5 |
|
$upper = $this->toUpper($country); |
299
|
5 |
|
if (array_key_exists($upper, $this->countryByName)) { |
300
|
3 |
|
return $this->countryByName[$upper]['id']; |
301
|
|
|
} |
302
|
|
|
|
303
|
|
|
// Suggest our best guess, so user can fix their data without lookup up countries manually |
304
|
2 |
|
$best = 0; |
305
|
2 |
|
$bestGuess = 0; |
306
|
2 |
|
foreach ($this->countryByName as $r) { |
307
|
2 |
|
similar_text($upper, $r['name'], $percent); |
308
|
2 |
|
if ($percent > $best) { |
309
|
2 |
|
$best = $percent; |
310
|
2 |
|
$bestGuess = $r; |
311
|
|
|
} |
312
|
|
|
} |
313
|
|
|
|
314
|
2 |
|
$this->throw('Pays "' . $country . '" introuvable. Vouliez-vous dire "' . $bestGuess['name'] . '" ?'); |
315
|
|
|
|
316
|
2 |
|
return null; |
317
|
|
|
} |
318
|
|
|
|
319
|
11 |
|
private function throw(string $message): void |
320
|
|
|
{ |
321
|
11 |
|
$this->errors[] = ('A la ligne ' . $this->lineNumber . ' : ' . $message); |
322
|
11 |
|
} |
323
|
|
|
|
324
|
16 |
|
private function deleteOldOrganizations(): void |
325
|
|
|
{ |
326
|
16 |
|
$sql = 'DELETE FROM organization WHERE should_delete'; |
327
|
16 |
|
$this->deletedOrganizations += $this->connection->executeUpdate($sql); |
|
|
|
|
328
|
16 |
|
} |
329
|
|
|
|
330
|
12 |
|
private function readMembership($membership): string |
331
|
|
|
{ |
332
|
12 |
|
if ($membership === '1') { |
333
|
1 |
|
return MembershipType::MEMBER; |
334
|
|
|
} |
335
|
|
|
|
336
|
12 |
|
return MembershipType::NONE; |
337
|
|
|
} |
338
|
|
|
|
339
|
12 |
|
private function readSubscriptionType(string $subscriptionType): ?string |
340
|
|
|
{ |
341
|
12 |
|
if (!$subscriptionType) { |
342
|
8 |
|
return null; |
343
|
|
|
} |
344
|
|
|
|
345
|
6 |
|
if ($subscriptionType === 'Web') { |
346
|
2 |
|
return ProductTypeType::DIGITAL; |
347
|
|
|
} |
348
|
|
|
|
349
|
5 |
|
if ($subscriptionType === 'Papier') { |
350
|
4 |
|
return ProductTypeType::PAPER; |
351
|
|
|
} |
352
|
|
|
|
353
|
2 |
|
if ($subscriptionType === 'Papier/web') { |
354
|
1 |
|
return ProductTypeType::BOTH; |
355
|
|
|
} |
356
|
|
|
|
357
|
1 |
|
$this->throw('Le subscriptionType est invalide : "' . $subscriptionType . '"'); |
358
|
|
|
|
359
|
1 |
|
return null; |
360
|
|
|
} |
361
|
|
|
|
362
|
12 |
|
private function updateUser(...$args): void |
363
|
|
|
{ |
364
|
12 |
|
$params = $args; |
365
|
12 |
|
$params[] = false; // web_temporary_access |
366
|
12 |
|
$params[] = false; // should_delete |
367
|
12 |
|
$params[] = ''; // password |
368
|
12 |
|
$params[] = $this->currentUser; |
369
|
|
|
|
370
|
12 |
|
array_push($this->usersParams, ...$params); |
371
|
12 |
|
++$this->updatedUsers; |
372
|
12 |
|
} |
373
|
|
|
|
374
|
16 |
|
private function updateAllUsers(): void |
375
|
|
|
{ |
376
|
16 |
|
if (!$this->updatedUsers) { |
377
|
4 |
|
return; |
378
|
|
|
} |
379
|
|
|
|
380
|
12 |
|
$placeholders = $this->placeholders($this->updatedUsers, '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'); |
381
|
|
|
|
382
|
|
|
$sql = 'INSERT INTO user ( |
383
|
|
|
email, |
384
|
|
|
subscription_type, |
385
|
|
|
subscription_last_review_id, |
386
|
|
|
membership, |
387
|
|
|
first_name, |
388
|
|
|
last_name, |
389
|
|
|
street, |
390
|
|
|
postcode, |
391
|
|
|
locality, |
392
|
|
|
country_id, |
393
|
|
|
phone, |
394
|
|
|
web_temporary_access, |
395
|
|
|
should_delete, |
396
|
|
|
password, |
397
|
|
|
creator_id, |
398
|
|
|
creation_date |
399
|
|
|
) |
400
|
12 |
|
VALUES ' . $placeholders . ' |
401
|
|
|
ON DUPLICATE KEY UPDATE |
402
|
|
|
email = VALUES(email), |
403
|
|
|
subscription_type = VALUES(subscription_type), |
404
|
|
|
subscription_last_review_id = VALUES(subscription_last_review_id), |
405
|
|
|
membership = VALUES(membership), |
406
|
|
|
first_name = VALUES(first_name), |
407
|
|
|
last_name = VALUES(last_name), |
408
|
|
|
street = VALUES(street), |
409
|
|
|
postcode = VALUES(postcode), |
410
|
|
|
locality = VALUES(locality), |
411
|
|
|
country_id = VALUES(country_id), |
412
|
|
|
phone = VALUES(phone), |
413
|
|
|
web_temporary_access = VALUES(web_temporary_access), |
414
|
|
|
should_delete = VALUES(should_delete), |
415
|
|
|
updater_id = VALUES(creator_id), |
416
|
|
|
update_date = NOW()'; |
417
|
|
|
|
418
|
12 |
|
$this->connection->executeUpdate($sql, $this->usersParams); |
|
|
|
|
419
|
12 |
|
} |
420
|
|
|
|
421
|
4 |
|
private function updateOrganization(...$args): void |
422
|
|
|
{ |
423
|
4 |
|
$params = $args; |
424
|
4 |
|
$params[] = false; // should_delete |
425
|
4 |
|
$params[] = $this->currentUser; |
426
|
|
|
|
427
|
4 |
|
array_push($this->organizationsParams, ...$params); |
428
|
|
|
|
429
|
4 |
|
++$this->updatedOrganizations; |
430
|
4 |
|
} |
431
|
|
|
|
432
|
16 |
|
private function updateAllOrganizations(): void |
433
|
|
|
{ |
434
|
16 |
|
if (!$this->updatedOrganizations) { |
435
|
12 |
|
return; |
436
|
|
|
} |
437
|
|
|
|
438
|
4 |
|
$placeholders = $this->placeholders($this->updatedOrganizations, '(?, ?, ?, ?, NOW())'); |
439
|
|
|
|
440
|
|
|
$sql = 'INSERT INTO organization (pattern, subscription_last_review_id, should_delete, creator_id, creation_date) |
441
|
4 |
|
VALUES ' . $placeholders . ' |
442
|
|
|
ON DUPLICATE KEY UPDATE |
443
|
|
|
pattern = VALUES(pattern), |
444
|
|
|
subscription_last_review_id = VALUES(subscription_last_review_id), |
445
|
|
|
updater_id = VALUES(creator_id), |
446
|
|
|
should_delete = VALUES(should_delete), |
447
|
|
|
update_date = NOW()'; |
448
|
|
|
|
449
|
4 |
|
$this->connection->executeUpdate($sql, $this->organizationsParams); |
|
|
|
|
450
|
4 |
|
} |
451
|
|
|
|
452
|
16 |
|
private function markToDelete(): void |
453
|
|
|
{ |
454
|
16 |
|
$this->connection->executeUpdate('UPDATE user SET should_delete = 1'); |
|
|
|
|
455
|
16 |
|
$this->connection->executeUpdate('UPDATE organization SET should_delete = 1'); |
|
|
|
|
456
|
16 |
|
} |
457
|
|
|
|
458
|
|
|
/** |
459
|
|
|
* To upper without any accent |
460
|
|
|
*/ |
461
|
17 |
|
private function toUpper(string $name): string |
462
|
|
|
{ |
463
|
17 |
|
$withoutAccent = iconv('UTF-8', 'ASCII//TRANSLIT', mb_strtolower($name)); |
464
|
|
|
|
465
|
17 |
|
return trim(mb_strtoupper($withoutAccent)); |
466
|
|
|
} |
467
|
|
|
|
468
|
14 |
|
private function placeholders(int $count, string $placeholder): string |
469
|
|
|
{ |
470
|
14 |
|
return implode(',' . PHP_EOL, array_fill(0, $count, $placeholder)); |
471
|
|
|
} |
472
|
|
|
} |
473
|
|
|
|
This function has been deprecated. The supplier of the function has supplied an explanatory message.
The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.