Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
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>
This commit is contained in:
183
app/Services/Piantine/PiantineBackup.php
Normal file
183
app/Services/Piantine/PiantineBackup.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Piantine;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Le tabelle della sotto-app delle piantine dentro il backup completo:
|
||||
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
|
||||
* (`territorio_pdf_versioni`).
|
||||
*
|
||||
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
|
||||
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
|
||||
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
|
||||
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
|
||||
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
|
||||
*
|
||||
* Il collegamento al territorio si salva come *numero*, non solo come id:
|
||||
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
|
||||
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
|
||||
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
|
||||
* unico.
|
||||
*/
|
||||
class PiantineBackup
|
||||
{
|
||||
public const TABELLA_MAPPE = 'territorio_mappe';
|
||||
|
||||
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
|
||||
|
||||
/**
|
||||
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
|
||||
* del territorio collegato.
|
||||
*
|
||||
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
|
||||
* compresi, da reinserire identica — non i valori passati per i cast.
|
||||
*
|
||||
* @return array<string, list<array<string, mixed>>>
|
||||
*/
|
||||
public function esporta(): array
|
||||
{
|
||||
return [
|
||||
self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE),
|
||||
self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla la struttura di piantine.json prima che il ripristino tocchi
|
||||
* qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le
|
||||
* piantine non le conteneva.
|
||||
*/
|
||||
public function valida(?array $dati): void
|
||||
{
|
||||
if ($dati === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) {
|
||||
if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) {
|
||||
throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".');
|
||||
}
|
||||
|
||||
foreach ($dati[$tabella] as $posizione => $riga) {
|
||||
if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.',
|
||||
$posizione + 1,
|
||||
$tabella
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sostituisce il contenuto delle due tabelle con quello del backup.
|
||||
*
|
||||
* Va chiamato dentro la transazione del ripristino e *dopo* l'import XML:
|
||||
* servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e
|
||||
* non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe'
|
||||
* chiuderebbe a meta' la transazione che deve poter annullare tutto.
|
||||
*
|
||||
* Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto.
|
||||
* Tenere le righe dell'installazione corrente vorrebbe dire piantine che
|
||||
* citano immagini del disco appena sostituito — file che non ci sono piu'
|
||||
* — accanto ai dati di un'altra epoca: proprio lo stato misto che il
|
||||
* ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la
|
||||
* cascata sulla cancellazione dei territori.
|
||||
*
|
||||
* @return array{mappe: int, versioni: int, issues: list<array{entity: string, legacy_id: string, reason: string}>}
|
||||
*/
|
||||
public function ripristina(?array $dati): array
|
||||
{
|
||||
DB::table(self::TABELLA_VERSIONI)->delete();
|
||||
DB::table(self::TABELLA_MAPPE)->delete();
|
||||
|
||||
$esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []];
|
||||
|
||||
if ($dati === null) {
|
||||
return $esito;
|
||||
}
|
||||
|
||||
// withTrashed implicito: il query builder non conosce i soft delete, e
|
||||
// una piantina collegata a un territorio nel cestino deve restarlo.
|
||||
$territori = DB::table('territori')->pluck('id', 'numero')
|
||||
->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id])
|
||||
->all();
|
||||
|
||||
$colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE));
|
||||
$collegati = [];
|
||||
|
||||
foreach ($dati[self::TABELLA_MAPPE] as $riga) {
|
||||
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
|
||||
$territorioId = null;
|
||||
|
||||
if (($riga['territorio_id'] ?? null) !== null) {
|
||||
$territorioId = $territori[$numero] ?? null;
|
||||
|
||||
// territorio_id e' unico: due piantine sullo stesso numero
|
||||
// (possibile solo se il backup aveva territori con numero
|
||||
// ripetuto, che l'import scarta) non possono restare collegate
|
||||
// entrambe.
|
||||
if ($territorioId !== null && isset($collegati[$territorioId])) {
|
||||
$territorioId = null;
|
||||
}
|
||||
|
||||
if ($territorioId === null) {
|
||||
// Il disegno non si butta: resta scollegato, con il numero
|
||||
// come etichetta per riconoscerlo.
|
||||
if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') {
|
||||
$riga['nome'] = 'Terr. ' . $numero;
|
||||
}
|
||||
$esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata');
|
||||
} else {
|
||||
$collegati[$territorioId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$riga['territorio_id'] = $territorioId;
|
||||
DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe));
|
||||
$esito['mappe']++;
|
||||
}
|
||||
|
||||
$colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI));
|
||||
|
||||
foreach ($dati[self::TABELLA_VERSIONI] as $riga) {
|
||||
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
|
||||
$territorioId = $territori[$numero] ?? null;
|
||||
|
||||
// Una versione senza territorio non e' ripristinabile da nessuna
|
||||
// schermata (e la colonna non ammette NULL): si scarta, e lo si dice.
|
||||
if ($territorioId === null) {
|
||||
$esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$riga['territorio_id'] = $territorioId;
|
||||
DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni));
|
||||
$esito['versioni']++;
|
||||
}
|
||||
|
||||
return $esito;
|
||||
}
|
||||
|
||||
private function righe(string $tabella): array
|
||||
{
|
||||
return DB::table($tabella)
|
||||
->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id')
|
||||
->orderBy($tabella . '.id')
|
||||
->get([$tabella . '.*', 'territori.numero as territorio_numero'])
|
||||
->map(fn (object $riga) => (array) $riga)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */
|
||||
private function problema(string $entita, array $riga, string $motivo): array
|
||||
{
|
||||
return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user