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 */ 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 $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 . 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(); GruppoServizio::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 = []; $utentiDaCollegare = []; foreach (($xml->users->user ?? []) as $userNode) { $email = trim((string) ($userNode->email ?? '')); $username = trim((string) ($userNode->username ?? '')); $legacyId = (string) ($userNode['legacy_id'] ?? ''); // Il sorvegliante di gruppo non ha email: si riconosce dal nome utente. if ($email === '' && $username === '') { $this->pushImportIssue('utente', $legacyId, 'Email e nome utente mancanti'); continue; } $user = User::query()->firstOrNew($email !== '' ? ['email' => $email] : ['username' => $username]); $user->name = (string) ($userNode->name ?? ($email !== '' ? $email : $username)); $user->username = $username !== '' ? $username : null; 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); // Utenza di un sorvegliante: si collega al proclamatore dopo che // i proclamatori sono stati importati (vedi i gruppi di servizio). $utentiDaCollegare[$user->id] = [ 'proclamatore' => (string) ($userNode->legacy_proclamatore_id ?? ''), 'disattivato_at' => $this->normalizeDateTimeForDb((string) ($userNode->disattivato_at ?? '')), ]; $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 = []; $gruppiDeiProclamatori = []; foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) { $p = Proclamatore::create([ 'nome' => (string) $proclamatoreNode->nome, 'cognome' => (string) $proclamatoreNode->cognome, 'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, // e , presenti nei file del 13-19 ottobre // 2026, si ignorano: i proclamatori non li hanno più. ]); $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true); $stats['proclamatori_importati']++; $gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? ''); $legacyId = (string) ($proclamatoreNode['legacy_id'] ?? ''); if ($legacyId !== '') { $proclamatoreMap[$legacyId] = $p->id; } } // Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di // loro. Poi i collegamenti che li aspettavano (membri, utenze). $gruppoMap = []; foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) { $legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? ''); // Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora // (): ora un gruppo sciolto non esiste più, quindi // non si importa. if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') { continue; } $g = GruppoServizio::create([ 'nome' => (string) $gruppoNode->nome, 'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null, ]); $this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode); $legacyId = (string) ($gruppoNode['legacy_id'] ?? ''); if ($legacyId !== '') { $gruppoMap[$legacyId] = $g->id; } } foreach ($gruppiDeiProclamatori as $idProclamatore => $legacyGruppo) { if (isset($gruppoMap[$legacyGruppo])) { DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]); } } foreach ($utentiDaCollegare as $idUtente => $collegamento) { DB::table('users')->where('id', $idUtente)->update([ 'proclamatore_id' => $proclamatoreMap[$collegamento['proclamatore']] ?? null, 'disattivato_at' => $collegamento['disattivato_at'], ]); } $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, 'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null, '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, 'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1, 'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null, 'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null, 'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null, '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, 'username' => (string) $u->username, '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(), // Sorvegliante di gruppo: il proclamatore collegato e se l'utenza è disattivata. 'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '', 'disattivato_at' => optional($u->disattivato_at)->toIso8601String() ?? '', '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', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']), 'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']), 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', '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', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'gruppo_nome', '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 $colonne * @return array> */ 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->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, 'username', (string) ($user['username'] ?? '')); $this->addXmlText($node, 'password', (string) ($user['password'] ?? '')); $node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? '')); $node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? '')); $node->addChild('disattivato_at', (string) ($user['disattivato_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'] ?? '')); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); $node->addChild('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? '')); $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))); $node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? '')); $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); } // Gruppi di servizio: il sorvegliante è un proclamatore (legacy id). $gruppiNode = $xml->addChild('gruppi_servizio'); foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) { $node = $gruppiNode->addChild('gruppo'); $this->addLegacyIdAttribute($node, $gruppo); $this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? '')); $node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? '')); $this->addTimestampNodes($node, $gruppo); } $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))); $node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0))); $node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? '')); $this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? '')); $this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? '')); $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); } } }