feature test su institution

This commit is contained in:
Giuseppe Naponiello
2026-06-22 22:14:15 +02:00
parent cc4e174880
commit fe73662903
39 changed files with 2432 additions and 1 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Etl\Contracts;
use App\Etl\ImportSummary;
/**
* Un importer ETL trasforma una porzione del DB legacy (v1) nello schema v2.
*
* Contratto comune a tutti gli importer (institutions, poi artifacts, ...): il
* comando `v1:import` li orchestra in ordine di dipendenza. Ogni import deve
* essere IDEMPOTENTE (upsert su `legacy_id`) e leggere SOLO dalla connessione
* `legacy`, scrivere SOLO sulla connessione di default (v2).
*/
interface Importer
{
/**
* Chiave breve per il filtro `--only` (es. "institutions").
*/
public function key(): string;
/**
* Etichetta leggibile per l'output del comando.
*/
public function label(): string;
/**
* Esegue l'import. In dry-run non scrive nulla, ma calcola comunque i conteggi.
*/
public function import(bool $dryRun): ImportSummary;
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Etl;
/**
* Esito di un import: conteggi e avvisi non bloccanti (da stampare nel comando).
*/
class ImportSummary
{
public int $created = 0;
public int $updated = 0;
public int $skipped = 0;
/** @var list<string> */
public array $warnings = [];
public function warn(string $message): void
{
$this->warnings[] = $message;
}
public function total(): int
{
return $this->created + $this->updated + $this->skipped;
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Etl\Importers;
use App\Enums\InstitutionLinkType;
use App\Etl\Contracts\Importer;
use App\Etl\ImportSummary;
use App\Models\Institution;
use App\Models\InstitutionLink;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Support\Facades\DB;
/**
* Importa le istituzioni da v1 (`institution`) nello schema v2.
*
* - Upsert su `legacy_id` idempotente (re-run aggiorna, non duplica).
* - `category` (v1) `category_id` (v2): mappa 1:1 perché il lookup è seedato con
* gli stessi id legacy. Categoria assente nel lookup (es. 1 "uncategorized") skip + warning.
* - `uuid` NON copiato dal legacy: lo rigenera il model (orderedUuid) alla creazione.
* - `url` (campo singolo v1) riga `institution_links` con type=official, solo se valorizzato.
* - Pulizia dati: trim su name/abbreviation/address/city/logo.
*
* NB l'auditing è già off in console (audit.console=false), quindi l'import non genera audit.
* NB la migrazione dei FILE logo (da v1 a storage/app/public) è fuori scope di questa fetta:
* qui si copia solo il valore stringa della colonna.
*/
class InstitutionImporter implements Importer
{
private const SOURCE = 'legacy';
public function key(): string
{
return 'institutions';
}
public function label(): string
{
return 'Institutions';
}
public function import(bool $dryRun): ImportSummary
{
$summary = new ImportSummary;
/** @var list<int> $validCategoryIds */
$validCategoryIds = InstitutionCategory::query()->pluck('id')->map(intval(...))->all();
if ($validCategoryIds === []) {
$summary->warn('Lookup institution_categories vuoto: esegui prima `db:seed`. Import saltato.');
return $summary;
}
$rows = DB::connection(self::SOURCE)
->table('institution')
->orderBy('id')
->get();
DB::transaction(function () use ($rows, $dryRun, $summary, $validCategoryIds): void {
foreach ($rows as $row) {
$this->importRow($row, $dryRun, $validCategoryIds, $summary);
}
});
return $summary;
}
/**
* Importa (upsert) una singola istituzione legacy. Categoria assente nel
* lookup v2 skip con warning.
*
* @param list<int> $validCategoryIds
*/
private function importRow(object $row, bool $dryRun, array $validCategoryIds, ImportSummary $summary): void
{
$categoryId = (int) $row->category;
if (! in_array($categoryId, $validCategoryIds, true)) {
$summary->warn(sprintf(
"Institution legacy #%d ('%s'): category %d assente nel lookup v2 → saltata.",
$row->id, trim((string) $row->name), $categoryId,
));
$summary->skipped++;
return;
}
if ($dryRun) {
Institution::query()->where('legacy_id', (int) $row->id)->exists()
? $summary->updated++
: $summary->created++;
return;
}
$institution = Institution::updateOrCreate(
['legacy_id' => (int) $row->id],
$this->mapAttributes($row, $categoryId),
);
$institution->wasRecentlyCreated ? $summary->created++ : $summary->updated++;
$this->syncOfficialLink($institution, (string) ($row->url ?? ''));
}
/**
* Mappa una riga legacy sugli attributi del model v2 (con pulizia dei dati).
*
* @return array<string, mixed>
*/
private function mapAttributes(object $row, int $categoryId): array
{
return [
'category_id' => $categoryId,
'name' => trim((string) $row->name),
'abbreviation' => trim((string) $row->abbreviation),
'address' => trim((string) $row->address),
'city' => trim((string) $row->city),
'lat' => $row->lat,
'lon' => $row->lon,
'logo' => trim((string) $row->logo),
'color' => $this->cleanColor($row->color),
'is_storage_place' => (bool) $row->is_storage_place,
];
}
/**
* Colore valido o fallback al default v2.
*/
private function cleanColor(mixed $color): string
{
$color = trim((string) ($color ?? ''));
return $color !== '' ? $color : '#c5cae9';
}
/**
* Sincronizza il link "official" dall'unico `url` legacy. Idempotente
* (upsert su institution_id + type). URL vuoto/blank nessun link.
*/
private function syncOfficialLink(Institution $institution, string $url): void
{
$url = trim($url);
if ($url === '') {
return;
}
InstitutionLink::updateOrCreate(
[
'institution_id' => $institution->id,
'type' => InstitutionLinkType::Official,
],
[
'url' => $url,
'sort_order' => 0,
],
);
}
}