Files
termanager2/app/Livewire/Settings/XmlExchange.php
Francesco Picone fc8623f7db Posta configurabile dall'app, contatti dei proclamatori, conferma email
- Server di posta in Impostazioni (password cifrata, email di prova): se
  compilato prende il posto del .env; non si esporta e l'import XML non lo
  cancella. MAIL_SCHEME=tls nel .env.example non era valido per Laravel 11.
- Proclamatori con email e telefono facoltativi, cifrati, nell'XML e
  cancellati dall'anonimizzazione; il numero apre WhatsApp.
- Dopo l'assegnazione un riquadro in evidenza con il link del PDF,
  "Invia su WhatsApp" con il messaggio pronto e la scelta "inviato /
  non ancora", che resta finché non si sceglie.
- Conferma dell'email obbligatoria per tutti gli utenti, anche esistenti:
  email in italiano, link valido senza login, nuova conferma a ogni cambio
  di indirizzo, "Reinvia conferma" in Utenti. Senza conferma restano
  raggiungibili profilo, cambio password e, per l'admin, il server di
  posta; comando utenti:conferma-email per l'emergenza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:12:31 +02:00

1419 lines
60 KiB
PHP

<?php
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class XmlExchange extends Component
{
use Avvisa;
use WithFileUploads;
public array $importStats = [];
public array $importIssues = [];
public array $pdfFolder = [];
public array $pdfImportLogs = [];
public array $pdfImportStats = [];
public array $pdfImportIssues = [];
public ?string $currentPdfImportId = null;
public string $pdfImportStatus = 'idle';
/**
* Quante righe di log dell'import sono gia' state spedite alla console.
*
* La coda dell'import non ha tetto (un import di duemila PDF fa duemila
* righe): rispedirla intera a ogni poll significherebbe trascinarsi dietro
* l'intera cronologia dentro ogni risposta, una volta al secondo.
*/
public int $logPdfSpediti = 0;
public function mount(): void
{
$this->currentPdfImportId = request()->query('pdf-import');
if ($this->currentPdfImportId) {
$this->refreshPdfImportStatus();
}
}
/**
* Quanti file viaggiano in una singola richiesta di caricamento della
* cartella.
*
* Il browser non manda la cartella tutta insieme: la spezza in lotti e li
* aggiunge uno dopo l'altro a $pdfFolder. Un'unica richiesta con tutti i file
* si scontrava con `max_file_uploads` di PHP (20 per default), che non
* rifiuta: scarta in silenzio i file in piu', e l'import partiva con i primi
* venti di una cartella da trecento. Alzare il limite non basterebbe: la
* richiesta unica resterebbe soggetta al tetto di dimensione e al timeout di
* 100 secondi di Cloudflare, e un errore a meta' farebbe ricominciare da capo.
* A lotti, ogni richiesta e' piccola e un errore ne ferma una sola.
*
* Si legge il valore vero di PHP, perche' un'installazione con un limite piu'
* basso non perda file in silenzio come prima.
*/
public function getFilePerLottoProperty(): int
{
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
}
/**
* Controllo rapido prima di caricare la cartella: senza, si caricherebbero
* centinaia di file per poi sentirsi dire che c'e' un ripristino in corso.
* Non prende il posto — lo prende importTerritoryPdfFolder(), a caricamento
* finito — perche' una scheda chiusa a meta' caricamento lo terrebbe
* occupato per un'ora.
*/
public function cartellaPuoPartire(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
if ($inCorso === null) {
return true;
}
$this->avvisaErrore(app(BackupOperationState::class)->messaggioOccupato($inCorso));
return false;
}
/** Il browser azzera i lotti di un caricamento precedente prima di iniziarne uno nuovo. */
public function svuotaCartella(): void
{
foreach ($this->pdfFolder as $file) {
if ($file instanceof TemporaryUploadedFile) {
$file->delete();
}
}
$this->pdfFolder = [];
}
/**
* Mette in coda l'import dei lotti gia' caricati. Restituisce true se
* l'import e' partito: il browser tiene la cartella scelta finche' non lo e',
* cosi' dopo un rifiuto basta ripremere.
*/
public function importTerritoryPdfFolder(): bool
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
]);
$importId = (string) Str::uuid();
$dispatcher = app(TerritorioPdfImportDispatcher::class);
try {
$dispatcher->occupaPosto($importId);
} catch (\RuntimeException $e) {
// Il posto l'ha preso qualcun altro fra il controllo iniziale e la
// fine del caricamento: si avvisa, e non si tocca niente.
$this->avvisaErrore($e->getMessage());
return false;
}
$storedFiles = [];
$ignorati = [];
$scartati = [];
try {
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
// Thumbs.db, .DS_Store, desktop.ini e simili: prima uno solo di
// questi faceva fallire la validazione e con lei l'intero import.
if (TerritorioPdfImportDispatcher::daIgnorare($originalName)) {
$ignorati[] = $originalName;
continue;
}
// Un PDF rotto o troppo grande si scarta da solo, con il suo
// motivo nell'esito: non e' una ragione per buttare gli altri.
$verifica = Validator::make(['file' => $file], ['file' => ['file', 'mimes:pdf', 'max:10240']]);
if ($verifica->fails()) {
$scartati[] = [
'file' => $originalName,
'message' => 'File non valido: ' . $verifica->errors()->first('file'),
];
continue;
}
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
} catch (\Throwable $e) {
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $e;
}
$this->svuotaCartella();
if ($storedFiles === []) {
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
. '.');
return false;
}
$this->dispatchPdfImport(
$importId,
$storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
$ignorati,
$scartati
);
return true;
}
public function refreshPdfImportStatus(): void
{
if (! $this->currentPdfImportId) {
return;
}
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
if (! $state) {
return;
}
$this->pdfImportStatus = $state['status'] ?? 'idle';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->spedisciImportAllaConsole();
}
/**
* Manda alla console unica le righe nuove dell'import PDF.
*
* La console e' un componente Alpine fuori dal ciclo di Livewire: i suoi
* contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
* Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
* non gira mai insieme a un backup, un ripristino o un azzeramento.
*/
protected function spedisciImportAllaConsole(): void
{
// Coda accorciata: e' un import nuovo, si riparte da capo.
if (count($this->pdfImportLogs) < $this->logPdfSpediti) {
$this->logPdfSpediti = 0;
}
$nuove = array_slice($this->pdfImportLogs, $this->logPdfSpediti);
$this->logPdfSpediti = count($this->pdfImportLogs);
$totale = (int) ($this->pdfImportStats['total'] ?? 0);
$fatti = (int) ($this->pdfImportStats['processed'] ?? 0);
$this->dispatch(
'console-operazione',
sorgente: 'import-pdf',
righe: array_values($nuove),
etichetta: 'Import PDF',
fase: $totale > 0 ? $fatti . ' / ' . $totale . ' file' : '',
percento: $this->percentualeImportPdf($fatti, $totale),
stato: $this->pdfImportStatus,
);
}
private function percentualeImportPdf(int $fatti, int $totale): int
{
if ($this->pdfImportStatus === 'completed') {
return 100;
}
return $totale > 0 ? (int) min(100, round($fatti / $totale * 100)) : 0;
}
/** Vero mentre l'import PDF e' in coda o in esecuzione. */
public function getImportPdfInCorsoProperty(): bool
{
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
// Import nuovo: il conto delle righe gia' raccontate riparte, altrimenti
// le prime righe di questo import verrebbero scambiate per quelle del
// precedente e non comparirebbero.
$this->logPdfSpediti = 0;
$this->refreshPdfImportStatus();
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
return;
}
$stats = $this->importStats;
$issues = $this->importIssues;
$generatedAt = now()->format('d/m/Y H:i:s');
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
return response()->streamDownload(function () use ($html) {
echo Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->output();
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function exportCurrentAsXml()
{
$xml = $this->currentXmlBackup();
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
/**
* L'export XML completo dei dati correnti. E' lo stesso contenuto che
* finisce in data.xml dentro il pacchetto di backup .zip, dove viaggia
* insieme ai file caricati.
*/
public function currentXmlBackup(): string
{
return $this->datasetToXml($this->currentDataset(), 'current-app-export');
}
public function render()
{
if (session()->has('importStats')) {
$this->importStats = session('importStats');
}
if (session()->has('importIssues')) {
$this->importIssues = session('importIssues');
}
return view('livewire.settings.xml-exchange');
}
public function legacySqlToDatasetPublic(string $sql): array
{
return $this->legacySqlToDataset($sql);
}
public function datasetToXmlPublic(array $dataset, string $source): string
{
return $this->datasetToXml($dataset, $source);
}
/**
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
*
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
* file dei territori cancellati sul disco: nessuna schermata li mostra,
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
* PDF per territorio.
*
* @return array<int, string>
*/
public static function fileDeiTerritori(): array
{
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
->filter()
->unique()
->values()
->all();
}
/**
* Elimina i file che prima dell'import appartenevano a un territorio e che
* adesso nessuna riga richiama piu'.
*
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
* XML che riporta gli stessi percorsi (un export di questa stessa
* installazione) non perde niente.
*
* @param array<int, string> $fileDiPrima
*/
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
{
$usati = array_flip(array_merge(
self::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$eliminati = 0;
foreach ($fileDiPrima as $percorso) {
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
continue;
}
if ($disco->delete($percorso)) {
$eliminati++;
}
}
return $eliminati;
}
public function importXmlFromContent(string $content): array
{
$this->importStats = [];
$this->importIssues = [];
$xml = $this->leggiXml($content);
if (is_string($xml)) {
return ['error' => $xml];
}
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
// (un sitemap, l'export di un altro programma) lascerebbe
// l'installazione vuota con un "completato con successo". Si accetta
// solo un file prodotto da questa applicazione, con le sue sezioni.
if ($xml->getName() !== 'termanager2') {
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <termanager2>. Nessun dato è stato modificato.'];
}
$mancanti = array_values(array_filter(
['territori', 'proclamatori', 'assegnazioni'],
fn (string $sezione) => ! isset($xml->{$sezione})
));
if ($mancanti !== []) {
return ['error' => 'Il file è incompleto: mancano le sezioni ' . implode(', ', $mancanti) . '. Nessun dato è stato modificato.'];
}
// Chi esegue l'import (o il ripristino, che imposta l'utente a mano)
// non deve uscirne estromesso: il file può contenere il suo account
// con una password vecchia o senza il ruolo di amministratore.
$attore = auth()->user();
$attoreEraAmministratore = $attore?->hasRole('amministratore') ?? false;
$actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) {
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
}
$stats = [
'utenti_importati' => 0,
'zone_importate' => 0,
'tipologie_importate' => 0,
'proclamatori_importati' => 0,
'anni_importati' => 0,
'campagne_importate' => 0,
'territori_importati' => 0,
'assegnazioni_importate' => 0,
'duplicate_territori' => 0,
'assegnazioni_saltate' => 0,
];
try {
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
Assegnazione::query()->delete();
Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete();
Proclamatore::query()->withTrashed()->forceDelete();
Tipologia::query()->delete();
Zona::query()->delete();
AnnoTeocratico::query()->delete();
// Il server di posta è configurazione di questa installazione, non
// un dato da trasferire: non si esporta (la password è cifrata con
// la sua APP_KEY) e un import non deve cancellarlo — senza posta
// nessuno riceverebbe più le email di conferma.
$posta = Setting::query()->first()?->only(Setting::CAMPI_POSTA) ?? [];
Setting::query()->delete();
// The mass delete above fires no model events, so Setting's
// memoized instance still points at the deleted row. Without this
// the update() below would target a nonexistent id, match zero
// rows, and silently leave the app with no settings at all.
Setting::flushCache();
$settingsNode = $xml->settings;
if ($settingsNode) {
Setting::instance()->update([
'congregazione_nome' => (string) ($settingsNode->congregazione_nome ?? ''),
'public_base_url' => (string) ($settingsNode->public_base_url ?? ''),
'logo_path' => (string) ($settingsNode->logo_path ?? ''),
'giorni_giacenza_da_assegnare' => (int) ($settingsNode->giorni_giacenza_da_assegnare ?? 120),
'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10),
'assignment_link_ttl_hours' => (int) ($settingsNode->assignment_link_ttl_months ?? $settingsNode->assignment_link_ttl_hours ?? 1),
'pdf_viewer_show_download' => ((int) ($settingsNode->pdf_viewer_show_download ?? 0)) === 1,
'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730),
// The first-run wizard was removed; an imported dataset always
// represents a configured install, so setup is complete.
'setup_completed' => true,
]);
}
if ($posta !== []) {
Setting::instance()->update($posta);
}
// Users are UPSERTED by email, never wiped. Deleting the users
// table during a restore risks locking out the administrator
// performing the import (e.g. a backup that predates their account,
// or a partial file). Existing accounts are updated in place;
// accounts in the file are (re)created. $userMap remaps the
// created_by/returned_by FKs on the assignments below.
$userMap = [];
foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? '');
if ($email === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
continue;
}
$user = User::query()->firstOrNew(['email' => $email]);
$user->name = (string) ($userNode->name ?? $email);
if (! $user->exists) {
// password is NOT NULL; the real hash is written raw below.
$user->password = Str::password(24);
}
$user->save();
// Ensure every referenced role exists before syncing — the
// standard three come from the seeder, but a custom role in the
// backup would otherwise make syncRoles() throw.
$roleNames = [];
foreach (($userNode->roles->role ?? []) as $roleNode) {
$roleName = trim((string) $roleNode);
if ($roleName !== '') {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);
$roleNames[] = $roleName;
}
}
$eAttore = $attore !== null && $user->id === $attore->id;
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
$roleNames[] = 'amministratore';
}
$user->syncRoles($roleNames);
// Password hash, remember_token, verification flag and
// timestamps are written with the query builder: the raw stored
// hash is preserved exactly (the 'hashed' cast would re-validate
// the bcrypt cost and could reject a hash made with a different
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
// Per chi sta importando password e token restano i suoi:
// quelli del file possono essere di mesi fa.
//
// Chi riceve l'hash dal file deve scegliere una password nuova,
// come dopo un ripristino: chi ha avuto il file fra le mani ha
// potuto tentarne il crack con calma. Il "ricordami" si azzera
// e basta: non si esporta più, e quello di un file vecchio
// sarebbe una credenziale persistente regalata a chi lo tiene.
$meta = [];
$passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
}
if (! $eAttore) {
$meta['remember_token'] = null;
}
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
if ($createdAt !== null) {
$meta['created_at'] = $createdAt;
}
$updatedAt = $this->normalizeDateTimeForDb((string) ($userNode->updated_at ?? ''));
if ($updatedAt !== null) {
$meta['updated_at'] = $updatedAt;
}
DB::table('users')->where('id', $user->id)->update($meta);
$stats['utenti_importati']++;
if ($legacyId !== '') {
$userMap[$legacyId] = $user->id;
}
}
$zoneMap = [];
foreach (($xml->zones->zone ?? []) as $zoneNode) {
$z = Zona::create([
'nome' => (string) $zoneNode->nome,
'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1,
]);
$this->applyRowMeta('zone', $z->id, $zoneNode);
$stats['zone_importate']++;
$legacyId = (string) ($zoneNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$zoneMap[$legacyId] = $z->id;
}
}
$tipologiaMap = [];
foreach (($xml->tipologie->tipologia ?? []) as $tipologiaNode) {
$t = Tipologia::create([
'nome' => (string) $tipologiaNode->nome,
'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1,
]);
$this->applyRowMeta('tipologie', $t->id, $tipologiaNode);
$stats['tipologie_importate']++;
$legacyId = (string) ($tipologiaNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$tipologiaMap[$legacyId] = $t->id;
}
}
$proclamatoreMap = [];
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
$p = Proclamatore::create([
'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
// Facoltativi, e assenti nei file di prima dell'ottobre 2026.
'email' => trim((string) ($proclamatoreNode->email ?? '')) ?: null,
'telefono' => trim((string) ($proclamatoreNode->telefono ?? '')) ?: null,
]);
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++;
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$proclamatoreMap[$legacyId] = $p->id;
}
}
$annoMap = [];
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
$label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([
'label' => $label,
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
]);
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
$stats['anni_importati']++;
$legacyId = (string) ($annoNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$annoMap[$legacyId] = $anno->id;
}
}
$campagnaMap = [];
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
$campagna = Campagna::create([
'descrizione' => (string) $campagnaNode->descrizione,
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
]);
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
$stats['campagne_importate']++;
$legacyId = (string) ($campagnaNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$campagnaMap[$legacyId] = $campagna->id;
}
}
$territorioMap = [];
$territoriNumeroVisti = [];
foreach (($xml->territori->territorio ?? []) as $territorioNode) {
$legacyZonaId = (string) ($territorioNode->legacy_zona_id ?? '');
$legacyTipologiaId = (string) ($territorioNode->legacy_tipologia_id ?? '');
$numero = trim((string) $territorioNode->numero);
$legacyId = (string) ($territorioNode['legacy_id'] ?? '');
if ($numero === '' || isset($territoriNumeroVisti[$numero])) {
$stats['duplicate_territori']++;
$this->pushImportIssue('territorio', $legacyId, 'Territorio duplicato o numero vuoto (numero=' . ($numero !== '' ? $numero : 'vuoto') . ')');
continue;
}
// Zona e tipologia sono facoltative (le colonne sono nullable e
// un territorio resta senza quando la sua zona viene
// eliminata): un riferimento vuoto o sconosciuto non è motivo
// per scartare il territorio, che si porterebbe dietro tutto
// il suo storico di assegnazioni.
if (($legacyZonaId !== '' && ! isset($zoneMap[$legacyZonaId]))
|| ($legacyTipologiaId !== '' && ! isset($tipologiaMap[$legacyTipologiaId]))) {
$this->pushImportIssue('territorio', $legacyId, 'Zona o tipologia non trovata (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . '): territorio importato senza');
}
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
$thumbnailPath = (string) ($territorioNode->thumbnail_path ?? '');
$territorio = Territorio::create([
'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
'confini' => (string) ($territorioNode->confini ?? ''),
'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
]);
$this->applyRowMeta('territori', $territorio->id, $territorioNode, true);
$territoriNumeroVisti[$numero] = true;
$stats['territori_importati']++;
if ($legacyId !== '') {
$territorioMap[$legacyId] = $territorio->id;
}
}
foreach (($xml->assegnazioni->assegnazione ?? []) as $assegnazioneNode) {
$legacyTerritorio = (string) ($assegnazioneNode->legacy_territorio_id ?? '');
$legacyProclamatore = (string) ($assegnazioneNode->legacy_proclamatore_id ?? '');
$legacyAnno = (string) ($assegnazioneNode->legacy_anno_id ?? '');
$legacyId = (string) ($assegnazioneNode['legacy_id'] ?? '');
$assignedAt = $this->normalizeDateForDb((string) ($assegnazioneNode->assigned_at ?? ''), false);
$returnedAt = $this->normalizeDateForDb((string) ($assegnazioneNode->returned_at ?? ''), true);
if (!isset($territorioMap[$legacyTerritorio], $proclamatoreMap[$legacyProclamatore], $annoMap[$legacyAnno])) {
$stats['assegnazioni_saltate']++;
$this->pushImportIssue('assegnazione', $legacyId, 'Riferimenti mancanti (territorio=' . $legacyTerritorio . ', proclamatore=' . $legacyProclamatore . ', anno=' . $legacyAnno . ')');
continue;
}
if ($assignedAt === null) {
$stats['assegnazioni_saltate']++;
$this->pushImportIssue('assegnazione', $legacyId, 'Data assigned_at non valida o vuota');
continue;
}
$legacyCampagna = (string) ($assegnazioneNode->legacy_campagna_id ?? '');
// Remap the audit FKs onto the imported users; fall back to the
// acting user when the referenced account isn't in the file.
$legacyCreatedBy = (string) ($assegnazioneNode->legacy_created_by_id ?? '');
$createdBy = ($legacyCreatedBy !== '' && isset($userMap[$legacyCreatedBy]))
? $userMap[$legacyCreatedBy]
: $actorId;
$returnedBy = null;
if ($returnedAt !== null) {
$legacyReturnedBy = (string) ($assegnazioneNode->legacy_returned_by_id ?? '');
$returnedBy = ($legacyReturnedBy !== '' && isset($userMap[$legacyReturnedBy]))
? $userMap[$legacyReturnedBy]
: $actorId;
}
$pdfAccessCode = (string) ($assegnazioneNode->pdf_access_code ?? '');
$assegnazione = Assegnazione::create([
'territorio_id' => $territorioMap[$legacyTerritorio],
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore],
'anno_teocratico_id' => $annoMap[$legacyAnno],
'assigned_at' => $assignedAt,
'returned_at' => $returnedAt,
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $createdBy,
'returned_by' => $returnedBy,
]);
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
$stats['assegnazioni_importate']++;
}
});
} catch (\Illuminate\Database\QueryException $e) {
// La transazione è già stata annullata: il database è com'era.
// Senza questo l'amministratore vedrebbe un "Server Error" senza
// sapere quale dato del file ha violato un vincolo.
report($e);
return ['error' => 'Import annullato, nessun dato è stato modificato: il file contiene un dato che il database rifiuta (' . Str::limit($e->getPrevious()?->getMessage() ?? $e->getMessage(), 240) . ').'];
}
return [
'stats' => $stats,
'issues' => $this->importIssues,
];
}
private function currentDataset(): array
{
$settings = Setting::instance();
return [
'settings' => [
'congregazione_nome' => (string) ($settings->congregazione_nome ?? ''),
'public_base_url' => (string) ($settings->public_base_url ?? ''),
'logo_path' => (string) ($settings->logo_path ?? ''),
'giorni_giacenza_da_assegnare' => (int) ($settings->giorni_giacenza_da_assegnare ?? 120),
'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settings->home_limit_list ?? 10),
'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1),
'pdf_viewer_show_download' => (int) ((bool) ($settings->pdf_viewer_show_download ?? false)),
'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730),
'setup_completed' => (int) ((bool) ($settings->setup_completed ?? true)),
],
// Full user rows, credentials included. getAuthPassword() bypasses
// the $hidden guard to read the raw bcrypt hash; roles come from the
// Spatie relation. This is sensitive data — see the note in the
// export UI / HANDOFF.
'users' => User::query()->with('roles')->orderBy('id')->get()->map(fn ($u) => [
'legacy_id' => (string) $u->id,
'name' => (string) $u->name,
'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
// remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(),
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'email', 'telefono', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at']),
];
}
/**
* Righe da esportare, con le date scritte in modo che il reimport le
* ritrovi identiche.
*
* toArray() serializza ogni data in UTC: con l'applicazione su
* Europe/Rome il 15/03 a mezzanotte diventava "2024-03-14T23:00:00Z" e
* al reimport tornava 14/03, un giorno perso a ogni backup e ripristino.
* Le colonne con cast date escono come Y-m-d, che non ha fuso; i
* timestamp come ISO 8601 con lo scostamento esplicito.
*
* @param array<int, string> $colonne
* @return array<int, array<string, mixed>>
*/
private function righeEsportate(\Illuminate\Database\Eloquent\Builder $query, array $colonne): array
{
return $query->get($colonne)->map(function (\Illuminate\Database\Eloquent\Model $riga) use ($colonne) {
$dati = [];
foreach ($colonne as $colonna) {
$valore = $riga->getAttribute($colonna);
if ($valore instanceof \Carbon\CarbonInterface) {
$valore = $riga->hasCast($colonna, ['date', 'immutable_date'])
? $valore->format('Y-m-d')
: $valore->toIso8601String();
}
$dati[$colonna] = $valore;
}
return $dati;
})->all();
}
/**
* Il file come SimpleXMLElement, oppure il messaggio d'errore con riga e
* motivo: "impossibile leggere il file" da solo non aiuta a correggerlo.
*/
private function leggiXml(string $content): \SimpleXMLElement|string
{
$precedente = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if ($xml !== false) {
return $xml;
}
$errore = libxml_get_errors()[0] ?? null;
return $errore
? sprintf('Impossibile leggere il file XML (riga %d): %s.', $errore->line, trim($errore->message))
: 'Impossibile leggere il file XML.';
} finally {
libxml_clear_errors();
libxml_use_internal_errors($precedente);
}
}
private function legacySqlToDataset(string $sql): array
{
$rows = $this->extractInsertRows($sql);
$congregazione = $rows['congregazione'][0][1] ?? 'Congregazione';
$impostazioni = $rows['impostazioni'][0] ?? [1, 120, 10, 120];
$settings = [
'congregazione_nome' => (string) $congregazione,
'giorni_giacenza_da_assegnare' => (int) ($impostazioni[3] ?? 120),
'giorni_giacenza_prioritari' => (int) ($impostazioni[1] ?? 180),
'giorni_per_smarrito' => (int) ($impostazioni[3] ?? 120),
'home_limit_list' => (int) ($impostazioni[2] ?? 10),
'assignment_link_ttl_months' => 1,
'audit_retention_days' => 730,
];
$zones = [];
foreach (($rows['zona'] ?? []) as $r) {
$zones[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'nome' => (string) ($r[1] ?? ''),
'attivo' => 1,
];
}
$tipologie = [];
foreach (($rows['tipologia'] ?? []) as $r) {
$tipologie[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'nome' => (string) ($r[1] ?? ''),
'attivo' => 1,
];
}
$proclamatori = [];
foreach (($rows['proclamatore'] ?? []) as $r) {
$proclamatori[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'cognome' => (string) ($r[1] ?? ''),
'nome' => (string) ($r[2] ?? ''),
'attivo' => ((int) ($r[3] ?? 1)) === 1 ? 1 : 0,
];
}
$territori = [];
foreach (($rows['territorio'] ?? []) as $r) {
$territori[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'numero' => (string) ($r[1] ?? ''),
'legacy_tipologia_id' => (string) ($r[2] ?? ''),
'legacy_zona_id' => (string) ($r[3] ?? ''),
'confini' => (string) ($r[4] ?? ''),
'note' => (string) ($r[5] ?? ''),
'attivo' => ((int) ($r[8] ?? 1)) === 1 ? 1 : 0,
'prioritario' => ((int) ($r[11] ?? 0)) > 0 ? 1 : 0,
];
}
$anni = [];
foreach (($rows['annoServizio'] ?? []) as $r) {
$label = (string) ($r[1] ?? '');
[$startDate, $endDate] = $this->datesFromLegacyAnnoLabel($label);
$anni[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'label' => str_replace(' - ', '-', $label),
'start_date' => $startDate,
'end_date' => $endDate,
];
}
$campagne = [];
foreach (($rows['campagna'] ?? []) as $r) {
$campagne[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'descrizione' => (string) ($r[1] ?? ''),
'start_date' => $this->toDateOnly((string) ($r[2] ?? '')),
'end_date' => $this->toDateOnly((string) ($r[3] ?? '')),
];
}
$assegnazioni = [];
foreach (($rows['assegnazione'] ?? []) as $r) {
$assegnazioni[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'legacy_territorio_id' => (string) ($r[1] ?? ''),
'legacy_proclamatore_id' => (string) ($r[2] ?? ''),
'legacy_anno_id' => (string) ($r[9] ?? ''),
'legacy_campagna_id' => $r[8] === null ? '' : (string) $r[8],
'assigned_at' => $this->toDateOnly((string) ($r[4] ?? '')),
'returned_at' => $this->toDateOnly((string) ($r[5] ?? '')),
'counted_in_campaign' => ((int) ($r[7] ?? 0)) === 1 ? 1 : 0,
'note' => '',
];
}
return [
'settings' => $settings,
'zones' => $zones,
'tipologie' => $tipologie,
'proclamatori' => $proclamatori,
'territori' => $territori,
'anni_teocratici' => $anni,
'campagne' => $campagne,
'assegnazioni' => $assegnazioni,
];
}
private function extractInsertRows(string $sql): array
{
$result = [];
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+);/m', $sql, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$table = $match[1];
$valuesPayload = $match[2];
$tuples = $this->splitSqlTuples($valuesPayload);
foreach ($tuples as $tuple) {
$result[$table][] = $this->parseSqlTuple($tuple);
}
}
return $result;
}
private function splitSqlTuples(string $payload): array
{
$tuples = [];
$inString = false;
$escape = false;
$depth = 0;
$current = '';
$len = strlen($payload);
for ($i = 0; $i < $len; $i++) {
$ch = $payload[$i];
if ($inString) {
$current .= $ch;
if ($escape) {
$escape = false;
continue;
}
if ($ch === '\\') {
$escape = true;
continue;
}
if ($ch === "'") {
$inString = false;
}
continue;
}
if ($ch === "'") {
$inString = true;
$current .= $ch;
continue;
}
if ($ch === '(') {
if ($depth === 0) {
$current = '';
}
$depth++;
continue;
}
if ($ch === ')') {
$depth--;
if ($depth === 0) {
$tuples[] = $current;
$current = '';
continue;
}
}
if ($depth > 0) {
$current .= $ch;
}
}
return $tuples;
}
private function parseSqlTuple(string $tuple): array
{
$values = [];
$inString = false;
$escape = false;
$current = '';
$len = strlen($tuple);
for ($i = 0; $i < $len; $i++) {
$ch = $tuple[$i];
if ($inString) {
$current .= $ch;
if ($escape) {
$escape = false;
continue;
}
if ($ch === '\\') {
$escape = true;
continue;
}
if ($ch === "'") {
$inString = false;
}
continue;
}
if ($ch === "'") {
$inString = true;
$current .= $ch;
continue;
}
if ($ch === ',') {
$values[] = $this->normalizeSqlValue($current);
$current = '';
continue;
}
$current .= $ch;
}
if ($current !== '') {
$values[] = $this->normalizeSqlValue($current);
}
return $values;
}
private function normalizeSqlValue(string $raw)
{
$raw = trim($raw);
if (strcasecmp($raw, 'NULL') === 0) {
return null;
}
if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
$v = substr($raw, 1, -1);
$v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v);
$v = html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $this->normalizeUnicodeQuotes($v);
}
if (is_numeric($raw)) {
return str_contains($raw, '.') ? (float) $raw : (int) $raw;
}
return $raw;
}
private function normalizeUnicodeQuotes(string $value): string
{
return str_replace(
["\u{2018}", "\u{2019}", "\u{2032}", "\u{2035}", "\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}", "\u{2036}"],
["'", "'", "'", "'", '"', '"', '"', '"', '"'],
$value
);
}
private function datesFromLegacyAnnoLabel(string $label): array
{
if (preg_match('/(\d{4})\s*-\s*(\d{4})/', $label, $m)) {
return [$m[1] . '-09-01', $m[2] . '-08-31'];
}
$now = Carbon::now();
return [$now->copy()->startOfYear()->toDateString(), $now->copy()->endOfYear()->toDateString()];
}
private function toDateOnly(string $dateTime): string
{
if ($dateTime === '') {
return '';
}
return substr($dateTime, 0, 10);
}
private function normalizeDateForDb(string $raw, bool $nullable): ?string
{
$raw = trim($raw);
if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') {
return $nullable ? null : null;
}
// Riportata nel fuso dell'applicazione prima di tenere solo il giorno:
// i backup fatti prima della correzione dell'export hanno le date in
// UTC ("2024-03-14T23:00:00Z" per il 15/03), e così tornano giuste.
// Una data senza orario e senza fuso resta com'è.
try {
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
} catch (\Throwable $e) {
return $nullable ? null : null;
}
}
private function pushImportIssue(string $entity, string $legacyId, string $reason): void
{
if (count($this->importIssues) >= 300) {
return;
}
$this->importIssues[] = [
'entity' => $entity,
'legacy_id' => $legacyId !== '' ? $legacyId : '-',
'reason' => $reason,
];
}
private function datasetToXml(array $dataset, string $source): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><termanager2/>');
$xml->addAttribute('version', '1.0');
$xml->addAttribute('source', $source);
$xml->addAttribute('generated_at', now()->toIso8601String());
$settings = $xml->addChild('settings');
foreach ($dataset['settings'] as $key => $value) {
$this->addXmlText($settings, $key, (string) $value);
}
// Users are absent from the legacy-SQL dataset, so guard with ?? [].
$usersNode = $xml->addChild('users');
foreach (($dataset['users'] ?? []) as $user) {
$node = $usersNode->addChild('user');
if (isset($user['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $user['legacy_id']);
}
$this->addXmlText($node, 'name', (string) ($user['name'] ?? ''));
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');
foreach (($user['roles'] ?? []) as $roleName) {
$this->addXmlText($rolesNode, 'role', (string) $roleName);
}
}
$zonesNode = $xml->addChild('zones');
foreach ($dataset['zones'] as $zone) {
$node = $zonesNode->addChild('zone');
$this->addLegacyIdAttribute($node, $zone);
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
$this->addTimestampNodes($node, $zone);
}
$tipologieNode = $xml->addChild('tipologie');
foreach ($dataset['tipologie'] as $tipologia) {
$node = $tipologieNode->addChild('tipologia');
$this->addLegacyIdAttribute($node, $tipologia);
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
$this->addTimestampNodes($node, $tipologia);
}
$proclamatoriNode = $xml->addChild('proclamatori');
foreach ($dataset['proclamatori'] as $proclamatore) {
$node = $proclamatoriNode->addChild('proclamatore');
$this->addLegacyIdAttribute($node, $proclamatore);
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$this->addXmlText($node, 'email', (string) ($proclamatore['email'] ?? ''));
$this->addXmlText($node, 'telefono', (string) ($proclamatore['telefono'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore);
}
$territoriNode = $xml->addChild('territori');
foreach ($dataset['territori'] as $territorio) {
$node = $territoriNode->addChild('territorio');
$this->addLegacyIdAttribute($node, $territorio);
$this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? '')));
$node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
$this->addXmlText($node, 'confini', (string) ($territorio['confini'] ?? ''));
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $territorio);
}
$anniNode = $xml->addChild('anni_teocratici');
foreach ($dataset['anni_teocratici'] as $anno) {
$node = $anniNode->addChild('anno');
$this->addLegacyIdAttribute($node, $anno);
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
$node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
$node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
$this->addTimestampNodes($node, $anno);
}
$campagneNode = $xml->addChild('campagne');
foreach ($dataset['campagne'] as $campagna) {
$node = $campagneNode->addChild('campagna');
$this->addLegacyIdAttribute($node, $campagna);
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
$node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
$node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
$this->addTimestampNodes($node, $campagna);
}
$assegnazioniNode = $xml->addChild('assegnazioni');
foreach ($dataset['assegnazioni'] as $assegnazione) {
$node = $assegnazioniNode->addChild('assegnazione');
$this->addLegacyIdAttribute($node, $assegnazione);
$node->addChild('legacy_territorio_id', (string) ($assegnazione['legacy_territorio_id'] ?? ($assegnazione['territorio_id'] ?? '')));
$node->addChild('legacy_proclamatore_id', (string) ($assegnazione['legacy_proclamatore_id'] ?? ($assegnazione['proclamatore_id'] ?? '')));
$node->addChild('legacy_anno_id', (string) ($assegnazione['legacy_anno_id'] ?? ($assegnazione['anno_teocratico_id'] ?? '')));
$node->addChild('legacy_campagna_id', (string) ($assegnazione['legacy_campagna_id'] ?? ($assegnazione['campaign_id'] ?? '')));
$node->addChild('legacy_created_by_id', (string) ($assegnazione['legacy_created_by_id'] ?? ($assegnazione['created_by'] ?? '')));
$node->addChild('legacy_returned_by_id', (string) ($assegnazione['legacy_returned_by_id'] ?? ($assegnazione['returned_by'] ?? '')));
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
$this->addTimestampNodes($node, $assegnazione);
}
return $xml->asXML() ?: '';
}
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{
// XML 1.0 non ammette i caratteri di controllo (tranne tab e a capo)
// né l'UTF-8 non valido: uno solo, magari incollato da Word in una
// nota, rende l'intero file illeggibile al reimport — e quel file è
// anche il data.xml dei backup.
$value = mb_scrub($value, 'UTF-8');
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
$child = $parent->addChild($name);
$dom = dom_import_simplexml($child);
$dom->textContent = $value;
return $child;
}
private function addTimestampNodes(\SimpleXMLElement $node, array $row): void
{
$node->addChild('created_at', (string) ($row['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($row['updated_at'] ?? ''));
}
/**
* Write the legacy_id attribute used to remap foreign keys on import.
* The legacy-SQL conversion supplies an explicit 'legacy_id'; the
* current-app export supplies the real 'id' — either works as the stable
* key the referencing rows (territori, assegnazioni, …) point back to.
*/
private function addLegacyIdAttribute(\SimpleXMLElement $node, array $row): void
{
$legacyId = $row['legacy_id'] ?? $row['id'] ?? null;
if ($legacyId !== null && (string) $legacyId !== '') {
$node->addAttribute('legacy_id', (string) $legacyId);
}
}
/**
* Parse a full datetime (created_at/updated_at/deleted_at/email_verified_at)
* for storage, returning a `Y-m-d H:i:s` string or null. Unlike
* normalizeDateForDb this keeps the time component.
*/
private function normalizeDateTimeForDb(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
} catch (\Throwable $e) {
return null;
}
}
/**
* Restore created_at/updated_at (and deleted_at, when the model soft
* deletes) from the XML node after the row has been created. Written with
* the query builder so Eloquent does not stamp its own timestamps over the
* imported values. Only columns present in the XML are touched.
*/
private function applyRowMeta(string $table, int $id, \SimpleXMLElement $node, bool $withSoftDelete = false): void
{
$updates = [];
$createdAt = $this->normalizeDateTimeForDb((string) ($node->created_at ?? ''));
if ($createdAt !== null) {
$updates['created_at'] = $createdAt;
}
$updatedAt = $this->normalizeDateTimeForDb((string) ($node->updated_at ?? ''));
if ($updatedAt !== null) {
$updates['updated_at'] = $updatedAt;
}
if ($withSoftDelete) {
$deletedAt = $this->normalizeDateTimeForDb((string) ($node->deleted_at ?? ''));
if ($deletedAt !== null) {
$updates['deleted_at'] = $deletedAt;
}
}
if ($updates !== []) {
DB::table($table)->where('id', $id)->update($updates);
}
}
}