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,91 @@
<?php
namespace App\Console\Commands;
use App\Etl\Contracts\Importer;
use App\Etl\Importers\InstitutionImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* ETL idempotente dal DB legacy (v1) allo schema v2.
*
* Orchestra gli importer in ORDINE DI DIPENDENZA (institutions prima delle entità
* che le referenziano, es. artifacts). Ogni importer legge dalla connessione
* `legacy` e scrive su quella di default. Serve sia per i refresh di sviluppo
* sia per la migrazione di cutover.
*/
class V1ImportCommand extends Command
{
protected $signature = 'v1:import
{--only=* : Limit to specific importers (e.g. --only=institutions)}
{--dry-run : Read and count without writing}';
protected $description = 'Import data from the legacy (v1) database into the v2 schema (idempotent)';
/**
* Importer in ordine di dipendenza.
*
* @var list<class-string<Importer>>
*/
private array $importers = [
InstitutionImporter::class,
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$only = array_map('strtolower', (array) $this->option('only'));
try {
DB::connection('legacy')->getPdo();
} catch (Throwable $e) {
$this->error('Legacy connection unavailable: '.$e->getMessage());
$this->line('Set DB_LEGACY_* in the environment (see config/database.php → connections.legacy).');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('DRY-RUN: no data will be written.');
}
$hadError = false;
foreach ($this->importers as $class) {
/** @var Importer $importer */
$importer = app($class);
if ($only !== [] && ! in_array($importer->key(), $only, true)) {
continue;
}
$this->info("{$importer->label()}");
try {
$summary = $importer->import($dryRun);
} catch (Throwable $e) {
$this->error(" {$importer->key()} failed: ".$e->getMessage());
$hadError = true;
continue;
}
foreach ($summary->warnings as $warning) {
$this->warn(' ! '.$warning);
}
$this->line(sprintf(
' %screated %d, updated %d, skipped %d (total %d)',
$dryRun ? '[dry-run] ' : '',
$summary->created,
$summary->updated,
$summary->skipped,
$summary->total(),
));
}
return $hadError ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Enums;
/**
* Tipologia di risorsa web collegata a un'istituzione.
*
* Vocabolario stabile, controllato dallo sviluppatore: backed enum (non MySQL
* ENUM lookup table). Gli artifact useranno un enum separato (ArtifactLinkType)
* con un proprio vocabolario.
*/
enum InstitutionLinkType: string
{
case Official = 'official';
case Ticketing = 'ticketing';
case Social = 'social';
case Catalogue = 'catalogue';
case Other = 'other';
/**
* Etichetta leggibile per la UI (inglese: lingua ufficiale dell'applicazione).
*/
public function label(): string
{
return match ($this) {
self::Official => 'Official website',
self::Ticketing => 'Online ticketing',
self::Social => 'Social media',
self::Catalogue => 'Online catalogue',
self::Other => 'Other resource',
};
}
}

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,
],
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreInstitutionCategoryRequest;
use App\Http\Requests\UpdateInstitutionCategoryRequest;
use App\Http\Traits\ApiResponse;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Http\JsonResponse;
/**
* CRUD del lookup categorie di istituzione (scrittura riservata agli Admin).
*
* NB gli id 2/3/4/6 sono allineati al legacy e usati dall'ETL: cancellarne uno
* in uso è bloccato (409). Nessun soft delete su questo lookup.
*/
class InstitutionCategoryController extends Controller
{
use ApiResponse;
/**
* Elenco delle categorie.
*/
public function index(): JsonResponse
{
return $this->collectionResponse(InstitutionCategory::orderBy('value')->get());
}
/**
* Crea una categoria.
*/
public function store(StoreInstitutionCategoryRequest $request): JsonResponse
{
$category = InstitutionCategory::create($request->validated());
return $this->createdResponse($category);
}
/**
* Dettaglio di una categoria.
*/
public function show(InstitutionCategory $institutionCategory): JsonResponse
{
return $this->okResponse($institutionCategory);
}
/**
* Aggiorna una categoria.
*/
public function update(UpdateInstitutionCategoryRequest $request, InstitutionCategory $institutionCategory): JsonResponse
{
$institutionCategory->update($request->validated());
return $this->updatedResponse($institutionCategory);
}
/**
* Elimina una categoria (se non assegnata ad alcuna istituzione).
*/
public function destroy(InstitutionCategory $institutionCategory): JsonResponse
{
if ($institutionCategory->isInUse()) {
return $this->conflictResponse('Cannot delete: category assigned to at least one institution.');
}
$institutionCategory->delete();
return $this->deletedResponse();
}
/**
* Indica se la categoria è in uso (bloccata per l'eliminazione).
*/
public function usage(InstitutionCategory $institutionCategory): JsonResponse
{
return response()->json([
'in_use' => $institutionCategory->isInUse(),
]);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\IndexInstitutionRequest;
use App\Http\Requests\StoreInstitutionRequest;
use App\Http\Requests\UpdateInstitutionRequest;
use App\Http\Traits\ApiResponse;
use App\Models\Institution;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/**
* CRUD delle istituzioni (scrittura riservata agli Admin via tier di rotta).
*
* Identificate verso l'esterno dallo `uuid` (route key), non dall'id interno.
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
* definitivamente (e rimuove il file logo + i link via cascade FK).
*/
class InstitutionController extends Controller
{
use ApiResponse;
/** Sottocartella del disco `public` dove vivono i loghi. */
private const LOGO_DIR = 'institution_logo';
/**
* Elenco paginato, con filtri opzionali (ricerca, categoria, cestino).
*/
public function index(IndexInstitutionRequest $request): JsonResponse
{
$query = Institution::query()
->with('category')
->orderBy('name');
$this->applyTrashed($query, $request->input('trashed'));
$this->applySearch($query, $request->input('search'));
if ($request->filled('category_id')) {
$query->where('category_id', $request->integer('category_id'));
}
$institutions = $query->paginate($request->integer('per_page') ?: 20);
return $this->paginatedCollectionResponse($institutions);
}
/**
* Crea un'istituzione (con upload del logo).
*/
public function store(StoreInstitutionRequest $request): JsonResponse
{
$data = $request->validated();
$data['logo'] = $this->storeLogo($request->file('logo'));
$institution = Institution::create($data);
return $this->createdResponse($institution->load('category'));
}
/**
* Dettaglio (con categoria e link).
*/
public function show(Institution $institution): JsonResponse
{
return $this->okResponse($institution->load(['category', 'links']));
}
/**
* Aggiorna un'istituzione. Logo opzionale: se presente sostituisce il vecchio.
*/
public function update(UpdateInstitutionRequest $request, Institution $institution): JsonResponse
{
$data = $request->validated();
if ($request->hasFile('logo')) {
$this->deleteLogo($institution->logo);
$data['logo'] = $this->storeLogo($request->file('logo'));
} else {
unset($data['logo']);
}
$institution->update($data);
return $this->updatedResponse($institution->load('category'));
}
/**
* Cestina (soft delete).
*/
public function destroy(Institution $institution): JsonResponse
{
$institution->delete();
return $this->deletedResponse();
}
/**
* Ripristina un'istituzione cestinata.
*/
public function restore(Institution $institution): JsonResponse
{
$institution->restore();
return $this->restoredResponse($institution->load('category'));
}
/**
* Elimina definitivamente (rimuove il file logo; i link cadono in cascata DB).
*/
public function forceDestroy(Institution $institution): JsonResponse
{
$this->deleteLogo($institution->logo);
$institution->forceDelete();
return $this->deletedResponse();
}
/**
* Salva il file logo sul disco `public` e ne restituisce il path relativo.
*/
private function storeLogo(UploadedFile $file): string
{
return $file->store(self::LOGO_DIR, 'public');
}
/**
* Rimuove un file logo dal disco `public`, se presente.
*/
private function deleteLogo(?string $path): void
{
if (filled($path)) {
Storage::disk('public')->delete($path);
}
}
/**
* Include i soft-deleted: `with` (tutti) o `only` (solo cestinati).
*
* @param Builder<Institution> $query
*/
private function applyTrashed(Builder $query, ?string $trashed): void
{
match ($trashed) {
'with' => $query->withTrashed(),
'only' => $query->onlyTrashed(),
default => null,
};
}
/**
* Ricerca parziale su nome, città e sigla.
*
* @param Builder<Institution> $query
*/
private function applySearch(Builder $query, ?string $search): void
{
if (blank($search)) {
return;
}
$term = '%'.$search.'%';
$query->where(function (Builder $q) use ($term): void {
$q->where('name', 'like', $term)
->orWhere('city', 'like', $term)
->orWhere('abbreviation', 'like', $term);
});
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreInstitutionLinkRequest;
use App\Http\Requests\UpdateInstitutionLinkRequest;
use App\Http\Traits\ApiResponse;
use App\Models\Institution;
use App\Models\InstitutionLink;
use Illuminate\Http\JsonResponse;
/**
* Risorse web di un'istituzione (sito ufficiale, ticketing, social, ...).
*
* Risorsa annidata e scoped sotto `institutions/{institution}`: il link deve
* appartenere all'istituzione del path. Nessun soft delete (solo Institution lo ha).
*/
class InstitutionLinkController extends Controller
{
use ApiResponse;
/**
* Elenco dei link di un'istituzione, ordinati.
*/
public function index(Institution $institution): JsonResponse
{
$links = $institution->links()->orderBy('sort_order')->get();
return $this->collectionResponse($links);
}
/**
* Aggiunge un link all'istituzione.
*/
public function store(StoreInstitutionLinkRequest $request, Institution $institution): JsonResponse
{
$link = $institution->links()->create($request->validated());
return $this->createdResponse($link);
}
/**
* Dettaglio di un link.
*/
public function show(Institution $institution, InstitutionLink $link): JsonResponse
{
return $this->okResponse($link);
}
/**
* Aggiorna un link.
*/
public function update(UpdateInstitutionLinkRequest $request, Institution $institution, InstitutionLink $link): JsonResponse
{
$link->update($request->validated());
return $this->updatedResponse($link);
}
/**
* Elimina un link.
*/
public function destroy(Institution $institution, InstitutionLink $link): JsonResponse
{
$link->delete();
return $this->deletedResponse();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Filtri di query per l'elenco istituzioni. Autorizzazione demandata ai
* middleware di rotta (tier.app).
*/
class IndexInstitutionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'search' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'integer', 'exists:institution_categories,id'],
'trashed' => ['nullable', Rule::in(['with', 'only'])],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstitutionCategoryRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'value' => ['required', 'string', 'max:25', 'unique:institution_categories,value'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use App\Enums\InstitutionLinkType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreInstitutionLinkRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->mergeIfMissing(['sort_order' => 0]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'url' => ['required', 'url', 'max:2000'],
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
'label' => ['nullable', 'string', 'max:100'],
'sort_order' => ['required', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstitutionRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* Default per i campi con valore di colonna (color, is_storage_place):
* così non si inseriscono mai NULL su colonne NOT NULL.
*/
protected function prepareForValidation(): void
{
$this->mergeIfMissing([
'color' => '#c5cae9',
'is_storage_place' => true,
]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
'name' => ['required', 'string', 'max:255', 'unique:institutions,name'],
'abbreviation' => ['required', 'string', 'max:5', 'unique:institutions,abbreviation'],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:100'],
'lat' => ['required', 'numeric', 'between:-90,90'],
'lon' => ['required', 'numeric', 'between:-180,180'],
'logo' => ['required', 'image', 'max:5120'],
'color' => ['required', 'string', 'max:50'],
'is_storage_place' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateInstitutionCategoryRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$id = $this->route('institution_category')->id;
return [
'value' => ['required', 'string', 'max:25', Rule::unique('institution_categories', 'value')->ignore($id)],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use App\Enums\InstitutionLinkType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateInstitutionLinkRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->mergeIfMissing(['sort_order' => 0]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'url' => ['required', 'url', 'max:2000'],
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
'label' => ['nullable', 'string', 'max:100'],
'sort_order' => ['required', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateInstitutionRequest extends FormRequest
{
/**
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$id = $this->route('institution')->id;
return [
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
'name' => ['required', 'string', 'max:255', Rule::unique('institutions', 'name')->ignore($id)],
'abbreviation' => ['required', 'string', 'max:5', Rule::unique('institutions', 'abbreviation')->ignore($id)],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:100'],
'lat' => ['required', 'numeric', 'between:-90,90'],
'lon' => ['required', 'numeric', 'between:-180,180'],
// Opzionale in update: se assente si mantiene il logo esistente.
'logo' => ['nullable', 'image', 'max:5120'],
'color' => ['required', 'string', 'max:50'],
'is_storage_place' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Models;
use App\Models\Lists\InstitutionCategory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
class Institution extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
use SoftDeletes;
protected $fillable = [
'category_id',
'name',
'abbreviation',
'address',
'city',
'lat',
'lon',
'logo',
'color',
'is_storage_place',
'legacy_id',
];
/**
* Identificatore esterno stabile, esposto nelle rotte pubbliche/LOD e usato
* come token nei canonical URI Linked Art. NON fillable: generato qui sotto
* (o impostato esplicitamente dall'ETL), mai via mass assignment.
*/
protected static function booted(): void
{
static::creating(function (Institution $institution): void {
if (empty($institution->uuid)) {
$institution->uuid = (string) Str::orderedUuid();
}
});
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'category_id' => 'integer',
'lat' => 'float',
'lon' => 'float',
'is_storage_place' => 'boolean',
'legacy_id' => 'integer',
];
}
/**
* La rotta pubblica risolve per uuid, non per id auto-increment.
*/
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Categoria dell'istituzione.
*
* @return BelongsTo<InstitutionCategory, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(InstitutionCategory::class, 'category_id');
}
/**
* Risorse web collegate (sito ufficiale, ticketing, social, ...).
*
* @return HasMany<InstitutionLink, $this>
*/
public function links(): HasMany
{
return $this->hasMany(InstitutionLink::class);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\InstitutionLinkType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
class InstitutionLink extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
protected $fillable = [
'institution_id',
'url',
'type',
'label',
'sort_order',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'institution_id' => 'integer',
'type' => InstitutionLinkType::class,
'sort_order' => 'integer',
];
}
/**
* Istituzione a cui appartiene il link.
*
* @return BelongsTo<Institution, $this>
*/
public function institution(): BelongsTo
{
return $this->belongsTo(Institution::class);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models\Lists;
use App\Models\Institution;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;
/**
* Lookup delle categorie di istituzione.
*
* Tabella controllata: gli id sono fissi e coincidono con quelli del DB legacy
* (v1) così l'ETL mappa institutions.category_id 1:1 senza tradurre. La categoria
* legacy 1 ("uncategorized", mai usata) NON viene riportata.
*/
class InstitutionCategory extends Model implements Auditable
{
use AuditableTrait;
use HasFactory;
// Id allineati al legacy (vedi seeder). Nessun id 1 e nessun id 5.
public const LIBRARY = 'library';
public const MUSEUM = 'museum';
public const PUBLIC_ADMINISTRATION = 'public administration';
public const RESEARCH_INSTITUTE = 'research institute';
protected $table = 'institution_categories';
protected $fillable = [
'value',
];
/**
* Istituzioni che appartengono a questa categoria.
*
* @return HasMany<Institution, $this>
*/
public function institutions(): HasMany
{
return $this->hasMany(Institution::class, 'category_id');
}
/**
* La categoria è assegnata ad almeno un'istituzione.
*/
public function isInUse(): bool
{
return $this->institutions()->exists();
}
}