Roberto Santini 1週間前
コミット
90f8d06de9
1個のファイルの変更381行の追加39行の削除
  1. 381
    39
      app/Console/Commands/ImportaRedmine.php

+ 381
- 39
app/Console/Commands/ImportaRedmine.php ファイルの表示

@@ -13,34 +13,26 @@ use Throwable;
13 13
 class ImportaRedmine extends Command
14 14
 {
15 15
     protected $signature = 'redmine:import
16
-                            {project? : ID o identifier del progetto Redmine}
17
-                            {azienda? : ID, ragione sociale o email dell\'azienda locale}
16
+                            {project? : ID o identifier del progetto Redmine (se omesso, importa tutti i progetti)}
17
+                            {azienda? : ID, ragione sociale o email dell\'azienda locale (se omesso, viene creata/riusata dal progetto)}
18 18
                             {--list-projects : Elenca i progetti visibili in Redmine}
19 19
                             {--url= : URL Redmine (override REDMINE_URL)}
20 20
                             {--api-key= : API key (override REDMINE_API_KEY)}
21 21
                             {--dry-run : Mostra cosa verrebbe importato, senza scrivere}
22 22
                             {--update : Aggiorna segnalazioni già importate da questo progetto}
23
-                            {--con-sottoprogetti : Include anche i sotto-progetti}
23
+                            {--con-sottoprogetti : Include anche i sotto-progetti (solo import di un singolo progetto)}
24 24
                             {--no-attachments : Non scaricare gli allegati}
25 25
                             {--no-tempi : Non importare i time entry}
26 26
                             {--crea-utenti : Crea anche gli autori di journal/tempi se non esistono (contatti e assegnatari vengono sempre creati)}
27 27
                             {--user-fallback= : ID utente locale se non c\'è match}
28 28
                             {--tariffa= : €/ora per calcolare il costo dai tempi}
29 29
                             {--contratto= : ID del contratto servizio da assegnare alle segnalazioni}
30
-                            {--limit= : Importa al massimo N issue}';
30
+                            {--limit= : Importa al massimo N issue (per progetto)}';
31 31
 
32
-    protected $description = 'Importa issue, tempi, costi e allegati da un progetto Redmine 6.x verso un\'azienda locale';
32
+    protected $description = 'Importa issue, tempi, costi e allegati da Redmine 6.x. Senza progetto importa tutti i progetti e crea le aziende mancanti.';
33 33
 
34 34
     public function handle(): int
35 35
     {
36
-        if (! $this->option('list-projects') && (! $this->argument('project') || ! $this->argument('azienda'))) {
37
-            $this->error('Specifica progetto Redmine e azienda locale, oppure usa --list-projects.');
38
-            $this->line('Esempio: php artisan redmine:import NOME_PROGETTO 12');
39
-            $this->line('         php artisan redmine:import 42 "ElephanTech" --dry-run');
40
-
41
-            return self::INVALID;
42
-        }
43
-
44 36
         try {
45 37
             $client = RedmineClient::fromConfig(
46 38
                 $this->option('url') ?: null,
@@ -59,9 +51,20 @@ class ImportaRedmine extends Command
59 51
         $projectArg = $this->argument('project');
60 52
         $aziendaArg = $this->argument('azienda');
61 53
 
54
+        if ($projectArg) {
55
+            return $this->importSingleProject($client, (string) $projectArg, $aziendaArg ? (string) $aziendaArg : null);
56
+        }
57
+
58
+        if ($aziendaArg) {
59
+            $this->warn('Senza progetto l\'azienda indicata viene ignorata: ogni progetto Redmine ha la propria azienda.');
60
+        }
61
+
62
+        return $this->importAllProjects($client);
63
+    }
64
+
65
+    protected function importSingleProject(RedmineClient $client, string $projectArg, ?string $aziendaArg): int
66
+    {
62 67
         try {
63
-            $azienda = $this->resolveAzienda((string) $aziendaArg);
64
-            $contratto = $this->resolveContratto($this->option('contratto'), $azienda);
65 68
             $project = $client->project($projectArg);
66 69
         } catch (Throwable $e) {
67 70
             $this->error($e->getMessage());
@@ -75,24 +78,185 @@ class ImportaRedmine extends Command
75 78
             return self::FAILURE;
76 79
         }
77 80
 
78
-        $this->info('Redmine:  '.$client->baseUrl());
79
-        $this->info('Progetto: #'.$project['id'].' '.$project['name'].' ('.$project['identifier'].')');
80
-        $this->info('Azienda:  #'.$azienda->id.' '.($azienda->ragione_sociale ?: $azienda->nome));
81
-        if ($contratto) {
82
-            $this->info('Contratto: #'.$contratto->id.' '.$contratto->label);
81
+        try {
82
+            $azienda = $aziendaArg !== null
83
+                ? $this->resolveAzienda($aziendaArg)
84
+                : $this->matchAziendaForProject(
85
+                    trim((string) ($project['identifier'] ?? '')),
86
+                    trim((string) ($project['name'] ?? $project['identifier'] ?? '')),
87
+                );
88
+            $contratto = ($aziendaArg !== null && $azienda)
89
+                ? $this->resolveContratto($this->option('contratto'), $azienda)
90
+                : null;
91
+        } catch (Throwable $e) {
92
+            $this->error($e->getMessage());
93
+
94
+            return self::FAILURE;
95
+        }
96
+
97
+        if ($aziendaArg === null && $this->option('contratto')) {
98
+            $this->warn('--contratto è ignorato se l\'azienda non è indicata esplicitamente.');
99
+        }
100
+
101
+        $this->printHeader($client, $project, $azienda, $contratto);
102
+
103
+        if (! $this->option('no-interaction') && ! $this->confirm('Procedo con l\'import?', true)) {
104
+            $this->comment('Annullato.');
105
+
106
+            return self::SUCCESS;
107
+        }
108
+
109
+        if ($azienda === null) {
110
+            try {
111
+                $azienda = $this->findOrCreateAzienda($project);
112
+            } catch (Throwable $e) {
113
+                $this->error($e->getMessage());
114
+
115
+                return self::FAILURE;
116
+            }
117
+        } else {
118
+            $this->rememberRedmineProject($azienda, $project);
119
+        }
120
+
121
+        $result = $this->runImport($client, $project, $azienda, $contratto?->id, (bool) $this->option('con-sottoprogetti'));
122
+        $this->printStats($result['stats']);
123
+
124
+        return $result['ok'] ? self::SUCCESS : self::FAILURE;
125
+    }
126
+
127
+    protected function importAllProjects(RedmineClient $client): int
128
+    {
129
+        try {
130
+            $projects = $client->projects();
131
+        } catch (Throwable $e) {
132
+            $this->error($e->getMessage());
133
+
134
+            return self::FAILURE;
83 135
         }
136
+
137
+        if ($projects === []) {
138
+            $this->warn('Nessun progetto visibile su '.$client->baseUrl());
139
+
140
+            return self::SUCCESS;
141
+        }
142
+
143
+        if ($this->option('con-sottoprogetti')) {
144
+            $this->warn('--con-sottoprogetti è ignorato nell\'import di tutti i progetti: ogni progetto (anche i sotto-progetti) viene importato nella propria azienda.');
145
+        }
146
+        if ($this->option('contratto')) {
147
+            $this->warn('--contratto è ignorato nell\'import di tutti i progetti.');
148
+        }
149
+
150
+        $preview = [];
151
+        $jobs = [];
152
+        foreach ($projects as $project) {
153
+            if (empty($project['id'])) {
154
+                continue;
155
+            }
156
+            $identifier = trim((string) ($project['identifier'] ?? ''));
157
+            $name = trim((string) ($project['name'] ?? $identifier));
158
+            try {
159
+                $azienda = $this->matchAziendaForProject($identifier, $name);
160
+            } catch (Throwable $e) {
161
+                $this->error('#'.($project['id'] ?? '?').' '.($project['name'] ?? '').': '.$e->getMessage());
162
+
163
+                return self::FAILURE;
164
+            }
165
+            $jobs[] = ['project' => $project, 'azienda' => $azienda];
166
+            $preview[] = [
167
+                $project['id'] ?? '',
168
+                $project['identifier'] ?? '',
169
+                $project['name'] ?? '',
170
+                $azienda?->id ? '#'.$azienda->id : '—',
171
+                $azienda ? ($azienda->ragione_sociale ?: $azienda->nome) : $name,
172
+                $azienda ? 'esistente' : 'da creare',
173
+            ];
174
+        }
175
+
176
+        $this->info('Redmine: '.$client->baseUrl());
177
+        $this->info('Progetti da importare: '.count($jobs));
84 178
         if ($this->option('dry-run')) {
85 179
             $this->warn('Modalità dry-run: nessuna scrittura sul database.');
86 180
         }
181
+        $this->table(['ID', 'Identifier', 'Progetto', 'Azienda ID', 'Azienda', 'Stato'], $preview);
87 182
 
88
-        if (! $this->option('no-interaction') && ! $this->confirm('Procedo con l\'import?', true)) {
183
+        if (! $this->option('no-interaction') && ! $this->confirm('Procedo con l\'import di '.count($jobs).' progetti?', true)) {
89 184
             $this->comment('Annullato.');
90 185
 
91 186
             return self::SUCCESS;
92 187
         }
93 188
 
189
+        $totals = $this->emptyStats();
190
+        $hadErrors = false;
191
+        $projectRows = [];
192
+
193
+        foreach ($jobs as $index => $job) {
194
+            $project = $job['project'];
195
+            try {
196
+                $azienda = $job['azienda'] ?? $this->findOrCreateAzienda($project);
197
+                if ($job['azienda']) {
198
+                    $this->rememberRedmineProject($azienda, $project);
199
+                }
200
+            } catch (Throwable $e) {
201
+                $this->error($e->getMessage());
202
+                $hadErrors = true;
203
+                $projectRows[] = [
204
+                    $project['identifier'] ?? $project['id'],
205
+                    0, 0, 0, 0, 'errore azienda',
206
+                ];
207
+                continue;
208
+            }
209
+            $this->newLine();
210
+            $this->info(sprintf(
211
+                '[%d/%d] #%s %s → azienda #%s %s',
212
+                $index + 1,
213
+                count($jobs),
214
+                $project['id'],
215
+                $project['name'] ?? $project['identifier'],
216
+                $azienda->id ?: '—',
217
+                $azienda->ragione_sociale ?: $azienda->nome,
218
+            ));
219
+
220
+            $result = $this->runImport($client, $project, $azienda, null, false);
221
+            $stats = $result['stats'];
222
+            $this->mergeStats($totals, $stats);
223
+            if (! $result['ok']) {
224
+                $hadErrors = true;
225
+            }
226
+            $projectRows[] = [
227
+                $project['identifier'] ?? $project['id'],
228
+                $stats['issues'],
229
+                $stats['created'],
230
+                $stats['updated'],
231
+                $stats['skipped'],
232
+                $stats['errors'] === [] ? 'ok' : count($stats['errors']).' errori',
233
+            ];
234
+        }
235
+
236
+        $this->newLine();
237
+        $this->info('Riepilogo per progetto');
238
+        $this->table(['Progetto', 'Issue', 'Create', 'Aggiornate', 'Saltate', 'Esito'], $projectRows);
239
+        $this->info('Totale');
240
+        $this->printStats($totals);
241
+
242
+        return $hadErrors || $totals['errors'] !== [] ? self::FAILURE : self::SUCCESS;
243
+    }
244
+
245
+    /**
246
+     * @param  array<string, mixed>  $project
247
+     * @return array{ok: bool, stats: array<string, mixed>}
248
+     */
249
+    protected function runImport(
250
+        RedmineClient $client,
251
+        array $project,
252
+        Azienda $azienda,
253
+        ?int $contrattoServizioId,
254
+        bool $includeSubprojects,
255
+    ): array {
94 256
         $tariffa = $this->option('tariffa');
95 257
         $limit = $this->option('limit');
258
+        $empty = $this->emptyStats();
259
+        $empty['project'] = $project;
96 260
 
97 261
         $service = new RedmineImportService(
98 262
             client: $client,
@@ -104,7 +268,7 @@ class ImportaRedmine extends Command
104 268
             creaUtenti: (bool) $this->option('crea-utenti'),
105 269
             hourlyRate: $tariffa !== null && $tariffa !== '' ? (float) $tariffa : null,
106 270
             userFallbackId: $this->option('user-fallback') ? (int) $this->option('user-fallback') : null,
107
-            contrattoServizioId: $contratto?->id,
271
+            contrattoServizioId: $contrattoServizioId,
108 272
             onProgress: function (int $current, int $total, int $issueId) {
109 273
                 if ($current === 1) {
110 274
                     $this->output->progressStart($total);
@@ -119,7 +283,7 @@ class ImportaRedmine extends Command
119 283
         try {
120 284
             $stats = $service->import(
121 285
                 $project['id'],
122
-                (bool) $this->option('con-sottoprogetti'),
286
+                $includeSubprojects,
123 287
                 $limit !== null && $limit !== '' ? (int) $limit : null,
124 288
             );
125 289
         } catch (Throwable $e) {
@@ -127,39 +291,215 @@ class ImportaRedmine extends Command
127 291
                 $this->error($e->getTraceAsString());
128 292
             }
129 293
             $this->error($e->getMessage());
294
+            $empty['errors'][] = $e->getMessage();
130 295
 
131
-            return self::FAILURE;
296
+            return ['ok' => false, 'stats' => $empty];
297
+        }
298
+
299
+        return ['ok' => $stats['errors'] === [], 'stats' => $stats];
300
+    }
301
+
302
+    /**
303
+     * @param  array<string, mixed>  $project
304
+     */
305
+    protected function findOrCreateAzienda(array $project): Azienda
306
+    {
307
+        $identifier = trim((string) ($project['identifier'] ?? ''));
308
+        $name = trim((string) ($project['name'] ?? $identifier));
309
+        if ($name === '') {
310
+            throw new RuntimeException('Progetto Redmine #'.($project['id'] ?? '?').' senza nome né identifier.');
132 311
         }
133 312
 
313
+        $existing = $this->matchAziendaForProject($identifier, $name);
314
+        if ($existing) {
315
+            $this->rememberRedmineProject($existing, $project);
316
+
317
+            return $existing;
318
+        }
319
+
320
+        if ($this->option('dry-run')) {
321
+            $azienda = new Azienda();
322
+            $azienda->ragione_sociale = $name;
323
+            $azienda->nome = $name;
324
+            $azienda->note = $this->redmineNote($project);
325
+
326
+            return $azienda;
327
+        }
328
+
329
+        $azienda = new Azienda();
330
+        $azienda->ragione_sociale = $name;
331
+        $azienda->nome = $name;
332
+        $azienda->note = $this->redmineNote($project);
333
+        $azienda->save();
334
+
335
+        $this->info('  Azienda creata: #'.$azienda->id.' '.$name.' ('.$identifier.')');
336
+
337
+        return $azienda;
338
+    }
339
+
340
+    protected function matchAziendaForProject(string $identifier, string $name): ?Azienda
341
+    {
342
+        if ($identifier !== '') {
343
+            $byToken = Azienda::query()
344
+                ->where('note', 'like', '%'.$this->redmineIdentifierToken($identifier).'%')
345
+                ->first();
346
+            if ($byToken) {
347
+                return $byToken;
348
+            }
349
+        }
350
+
351
+        $matches = Azienda::query()
352
+            ->where(function ($q) use ($name, $identifier) {
353
+                $q->where('ragione_sociale', $name)
354
+                    ->orWhere('nome', $name);
355
+                if ($identifier !== '' && $identifier !== $name) {
356
+                    $q->orWhere('ragione_sociale', $identifier)
357
+                        ->orWhere('nome', $identifier);
358
+                }
359
+            })
360
+            ->get();
361
+
362
+        if ($matches->count() === 1) {
363
+            return $matches->first();
364
+        }
365
+
366
+        if ($matches->count() > 1) {
367
+            $elenco = $matches->map(fn (Azienda $a) => '#'.$a->id.' '.($a->ragione_sociale ?: $a->nome))->implode(', ');
368
+            throw new RuntimeException('Azienda ambigua per il progetto "'.$name.'". Specifica l\'ID. Trovate: '.$elenco);
369
+        }
370
+
371
+        return null;
372
+    }
373
+
374
+    /**
375
+     * @param  array<string, mixed>  $project
376
+     */
377
+    protected function rememberRedmineProject(Azienda $azienda, array $project): void
378
+    {
379
+        $token = $this->redmineIdentifierToken((string) ($project['identifier'] ?? ''));
380
+        if ($token === '' || str_contains((string) $azienda->note, $token)) {
381
+            return;
382
+        }
383
+        if ($this->option('dry-run') || ! $azienda->exists) {
384
+            return;
385
+        }
386
+
387
+        $note = trim((string) $azienda->note);
388
+        $azienda->note = ($note !== '' ? $note."\n" : '').$this->redmineNote($project);
389
+        $azienda->save();
390
+    }
391
+
392
+    /**
393
+     * @param  array<string, mixed>  $project
394
+     */
395
+    protected function redmineNote(array $project): string
396
+    {
397
+        $identifier = (string) ($project['identifier'] ?? '');
398
+
399
+        return sprintf(
400
+            '[redmine-import] project_id=%s %s',
401
+            $project['id'] ?? '',
402
+            $this->redmineIdentifierToken($identifier),
403
+        );
404
+    }
405
+
406
+    protected function redmineIdentifierToken(string $identifier): string
407
+    {
408
+        $identifier = trim($identifier);
409
+
410
+        return $identifier === '' ? '' : 'identifier="'.$identifier.'"';
411
+    }
412
+
413
+    /**
414
+     * @param  array<string, mixed>  $project
415
+     */
416
+    protected function printHeader(RedmineClient $client, array $project, ?Azienda $azienda, ?ContrattoServizio $contratto): void
417
+    {
418
+        $this->info('Redmine:  '.$client->baseUrl());
419
+        $this->info('Progetto: #'.$project['id'].' '.$project['name'].' ('.$project['identifier'].')');
420
+        if ($azienda) {
421
+            $this->info('Azienda:  #'.$azienda->id.' '.($azienda->ragione_sociale ?: $azienda->nome));
422
+        } else {
423
+            $this->info('Azienda:  verrà creata da «'.($project['name'] ?? $project['identifier']).'»');
424
+        }
425
+        if ($contratto) {
426
+            $this->info('Contratto: #'.$contratto->id.' '.$contratto->label);
427
+        }
428
+        if ($this->option('dry-run')) {
429
+            $this->warn('Modalità dry-run: nessuna scrittura sul database.');
430
+        }
431
+    }
432
+
433
+    /**
434
+     * @param  array<string, mixed>  $stats
435
+     */
436
+    protected function printStats(array $stats): void
437
+    {
134 438
         $this->newLine();
135 439
         $this->table(
136 440
             ['Voce', 'Valore'],
137 441
             [
138
-                ['Issue Redmine', $stats['issues']],
139
-                ['Segnalazioni create', $stats['created']],
140
-                ['Segnalazioni aggiornate', $stats['updated']],
141
-                ['Segnalazioni saltate (già importate)', $stats['skipped']],
142
-                ['Annotazioni', $stats['annotazioni']],
143
-                ['Time entry', $stats['tempi']],
144
-                ['Costi', $stats['costi']],
145
-                ['Allegati', $stats['allegati']],
146
-                ['Todo / checklist', $stats['todos']],
147
-                ['Utenti creati', $stats['utenti_creati']],
442
+                ['Issue Redmine', $stats['issues'] ?? 0],
443
+                ['Segnalazioni create', $stats['created'] ?? 0],
444
+                ['Segnalazioni aggiornate', $stats['updated'] ?? 0],
445
+                ['Segnalazioni saltate (già importate)', $stats['skipped'] ?? 0],
446
+                ['Annotazioni', $stats['annotazioni'] ?? 0],
447
+                ['Time entry', $stats['tempi'] ?? 0],
448
+                ['Costi', $stats['costi'] ?? 0],
449
+                ['Allegati', $stats['allegati'] ?? 0],
450
+                ['Todo / checklist', $stats['todos'] ?? 0],
451
+                ['Utenti creati', $stats['utenti_creati'] ?? 0],
148 452
             ],
149 453
         );
150 454
 
151
-        foreach ($stats['warnings'] as $warning) {
455
+        foreach ($stats['warnings'] ?? [] as $warning) {
152 456
             $this->warn($warning);
153 457
         }
154
-        foreach ($stats['errors'] as $error) {
458
+        foreach ($stats['errors'] ?? [] as $error) {
155 459
             $this->error($error);
156 460
         }
157 461
 
158
-        if ($stats['skipped'] > 0 && ! $this->option('update')) {
462
+        if (($stats['skipped'] ?? 0) > 0 && ! $this->option('update')) {
159 463
             $this->comment('Le issue già importate sono state saltate. Usa --update per riallinearle.');
160 464
         }
465
+    }
466
+
467
+    /**
468
+     * @return array<string, mixed>
469
+     */
470
+    protected function emptyStats(): array
471
+    {
472
+        return [
473
+            'issues' => 0,
474
+            'created' => 0,
475
+            'updated' => 0,
476
+            'skipped' => 0,
477
+            'annotazioni' => 0,
478
+            'tempi' => 0,
479
+            'costi' => 0,
480
+            'allegati' => 0,
481
+            'todos' => 0,
482
+            'utenti_creati' => 0,
483
+            'warnings' => [],
484
+            'errors' => [],
485
+        ];
486
+    }
161 487
 
162
-        return $stats['errors'] === [] ? self::SUCCESS : self::FAILURE;
488
+    /**
489
+     * @param  array<string, mixed>  $totals
490
+     * @param  array<string, mixed>  $stats
491
+     */
492
+    protected function mergeStats(array &$totals, array $stats): void
493
+    {
494
+        foreach (['issues', 'created', 'updated', 'skipped', 'annotazioni', 'tempi', 'costi', 'allegati', 'todos', 'utenti_creati'] as $key) {
495
+            $totals[$key] += (int) ($stats[$key] ?? 0);
496
+        }
497
+        foreach ($stats['warnings'] ?? [] as $warning) {
498
+            $totals['warnings'][] = $warning;
499
+        }
500
+        foreach ($stats['errors'] ?? [] as $error) {
501
+            $totals['errors'][] = $error;
502
+        }
163 503
     }
164 504
 
165 505
     protected function listProjects(RedmineClient $client): int
@@ -185,7 +525,9 @@ class ImportaRedmine extends Command
185 525
         }
186 526
 
187 527
         $this->table(['ID', 'Identifier', 'Nome', 'Stato'], $rows);
188
-        $this->comment('Usa l\'ID o l\'identifier come primo argomento di redmine:import.');
528
+        $this->comment('php artisan redmine:import                 importa tutti i progetti e crea le aziende');
529
+        $this->comment('php artisan redmine:import IDENTIFIER     importa un progetto (crea l\'azienda se manca)');
530
+        $this->comment('php artisan redmine:import IDENTIFIER 12  importa un progetto verso un\'azienda esistente');
189 531
 
190 532
         return self::SUCCESS;
191 533
     }

読み込み中…
キャンセル
保存