Blocco 2 - Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash che comparivano alla pagina dopo. - PDF, miniature e versioni dei territori non più serviti da nginx su /storage: TerritorioFileController con login, permessi e cache privata. - Ripristino tutto o niente: verifica dell'archivio, una transazione con import, piantine, blocco account e scambio dei file annullabile. - Backup formato 2 con piantine, storico PDF e area KML. - Posto unico preso in un passo (Cache::lock) e rispettato anche da import XML e PDF. - Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui), caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico. - L'import XML elimina i file dei territori che non usa più; comando territori:file-orfani per quelli accumulati prima. Blocco 3 - Password attuale richiesta al cambio volontario; AuthenticateSession chiude le sessioni aperte altrove. - Niente nomi dei proclamatori in chiaro nel registro attività. - Export XML senza remember_token; chi riceve l'hash dal file deve cambiare password. - Campagne: ultimo giorno incluso, percentuale sui territori distinti. - Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru, impostazioni rilette a ogni job. - Azzeramento che cancella anche piantine, area KML e anteprime. - Pulsanti profilo e menu che non si nascondevano; icona del sito. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1403 lines
59 KiB
PHP
1403 lines
59 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();
|
|
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,
|
|
]);
|
|
}
|
|
|
|
// 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,
|
|
]);
|
|
$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', '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'] ?? ''));
|
|
$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);
|
|
}
|
|
}
|
|
}
|