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:
79
app/Console/Commands/TerritoriFileOrfani.php
Normal file
79
app/Console/Commands/TerritoriFileOrfani.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Setting;
|
||||
use App\Models\TerritorioMappa;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Elenca (e su richiesta cancella) i file dei territori che nessuna riga usa.
|
||||
*
|
||||
* Fino all'ottobre 2026 l'import XML ricreava i territori con id nuovi senza
|
||||
* toccare i loro file: PDF, miniature e versioni dei territori di prima
|
||||
* restavano sul disco, e passavano da un backup all'altro perché l'archivio
|
||||
* copia tutto il disco pubblico. Oggi l'import li elimina da sé; questo
|
||||
* comando serve a ripulire quelli accumulati prima.
|
||||
*
|
||||
* Di default non cancella niente: elenca. Con --elimina cancella.
|
||||
*/
|
||||
class TerritoriFileOrfani extends Command
|
||||
{
|
||||
protected $signature = 'territori:file-orfani {--elimina : Cancella i file trovati invece di elencarli}';
|
||||
|
||||
protected $description = 'Elenca (o cancella con --elimina) i file dei territori che nessuna riga usa più';
|
||||
|
||||
private const CARTELLE = ['territori-pdf', 'territori-thumbnails', 'territori-pdf-versioni', 'territori-mappe'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$usati = array_flip(array_merge(
|
||||
XmlExchange::fileDeiTerritori(),
|
||||
array_filter([Setting::instance()->logo_path]),
|
||||
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
|
||||
));
|
||||
|
||||
$disco = Storage::disk('public');
|
||||
$orfani = [];
|
||||
$byte = 0;
|
||||
|
||||
foreach (self::CARTELLE as $cartella) {
|
||||
foreach ($disco->files($cartella) as $file) {
|
||||
if (! isset($usati[$file])) {
|
||||
$orfani[] = $file;
|
||||
$byte += $disco->size($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$peso = number_format($byte / 1048576, 1, ',', '.') . ' MB';
|
||||
|
||||
if ($orfani === []) {
|
||||
$this->info('Nessun file orfano.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('elimina')) {
|
||||
foreach ($orfani as $file) {
|
||||
$this->line($file);
|
||||
}
|
||||
$this->info(count($orfani) . " file orfani ({$peso}). Rilancia con --elimina per cancellarli.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$cancellati = 0;
|
||||
foreach ($orfani as $file) {
|
||||
if ($disco->delete($file)) {
|
||||
$cancellati++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("{$cancellati} file orfani cancellati ({$peso}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -43,23 +43,30 @@ class PasswordChangeController extends Controller
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// La password attuale NON viene richiesta, ed e' una scelta.
|
||||
$user = $request->user();
|
||||
|
||||
// La password attuale si chiede solo a chi arriva qui di sua
|
||||
// iniziativa, come fa il profilo.
|
||||
//
|
||||
// Il ripristino sovrascrive gli hash con quelli del backup: subito dopo,
|
||||
// la "password attuale" di un account e' quella che aveva all'epoca del
|
||||
// backup, che l'utente puo' benissimo non ricordare — l'amministratore
|
||||
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori
|
||||
// proprio le persone che questa schermata deve sbloccare. La garanzia
|
||||
// qui e' la sessione: si arriva a questa pagina gia' autenticati.
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
], [
|
||||
// Dopo un ripristino no: gli hash sono quelli del backup, e la
|
||||
// "password attuale" di un account e' quella che aveva all'epoca, che
|
||||
// l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
|
||||
// proprio le persone che questa schermata deve sbloccare; la garanzia li'
|
||||
// e' la sessione. Fuori da quel caso, invece, bastava una sessione
|
||||
// lasciata aperta per cambiare la password a qualcun altro e chiuderlo
|
||||
// fuori dal suo account.
|
||||
$regole = ['password' => ['required', 'confirmed', Password::min(8)]];
|
||||
if (! $user->password_change_required) {
|
||||
$regole['current_password'] = ['required', 'current_password'];
|
||||
}
|
||||
|
||||
$validated = $request->validate($regole, [
|
||||
'password.required' => 'Inserisci la nuova password.',
|
||||
'password.confirmed' => 'Le due password non coincidono.',
|
||||
'current_password.required' => 'Inserisci la password attuale.',
|
||||
'current_password.current_password' => 'La password attuale non è corretta.',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Il cast `hashed` sul modello cifra il valore al salvataggio.
|
||||
$user->update([
|
||||
'password' => $validated['password'],
|
||||
@@ -68,7 +75,10 @@ class PasswordChangeController extends Controller
|
||||
|
||||
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||
// ripristino le vecchie credenziali sono da considerarsi compromesse e
|
||||
// una sessione aperta altrove lo e' altrettanto.
|
||||
// una sessione aperta altrove lo e' altrettanto. Le altre le chiude
|
||||
// AuthenticateSession (bootstrap/app.php) alla loro prossima richiesta,
|
||||
// perche' l'hash della password e' cambiato; qui si cambia l'id di
|
||||
// questa e si invalida il "ricordami".
|
||||
$request->session()->regenerate();
|
||||
$user->setRememberToken(null);
|
||||
$user->save();
|
||||
|
||||
@@ -110,30 +110,45 @@ class BackupController extends Controller
|
||||
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
|
||||
]);
|
||||
|
||||
$operationId = (string) Str::uuid();
|
||||
|
||||
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
|
||||
// una porta d'ingresso separata: un ripristino avviato mentre gira un
|
||||
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
|
||||
if ($this->stato->corrente() !== null) {
|
||||
// Il posto si prende subito e in un passo solo (occupa()): controllarlo
|
||||
// e prenderlo solo dopo aver salvato il file lasciava una finestra in cui
|
||||
// un import o un secondo ripristino passavano entrambi.
|
||||
$inCorso = $this->stato->occupa($operationId, BackupOperationState::TIPO_RESTORE, 'In coda');
|
||||
if ($inCorso !== null) {
|
||||
return response()->json([
|
||||
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.',
|
||||
'message' => $this->stato->messaggioOccupato($inCorso),
|
||||
], 409);
|
||||
}
|
||||
|
||||
$operationId = (string) Str::uuid();
|
||||
try {
|
||||
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||
$percorso = $request->file('backupZip')->storeAs(
|
||||
'backup-restore-in',
|
||||
$operationId . '.zip',
|
||||
'local'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Il posto e' gia' preso: senza chiuderlo resterebbe occupato da un
|
||||
// ripristino che non partira' mai, per l'ora intera del TTL.
|
||||
$this->stato->markFailed($operationId, 'Archivio non salvato sul server: ' . $e->getMessage());
|
||||
|
||||
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||
$percorso = $request->file('backupZip')->storeAs(
|
||||
'backup-restore-in',
|
||||
$operationId . '.zip',
|
||||
'local'
|
||||
);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($percorso === false) {
|
||||
$this->stato->markFailed($operationId, 'Archivio non salvato sul server.');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Il server non è riuscito a salvare il pacchetto ricevuto (spazio o permessi sul disco).',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$this->stato->initialize(
|
||||
$operationId,
|
||||
BackupOperationState::TIPO_RESTORE,
|
||||
'In coda'
|
||||
);
|
||||
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
|
||||
|
||||
RestoreBackupArchive::dispatch(
|
||||
|
||||
@@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupOperationState;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class XmlExchangeUploadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Quanto resta occupato il posto unico se la richiesta di import muore senza
|
||||
* liberarlo (timeout, errore fatale: casi in cui il `finally` non gira).
|
||||
* L'import e' sincrono e PHP lo ferma comunque dopo max_execution_time
|
||||
* (120 s): dieci minuti bastano a coprirlo, invece dell'ora intera pensata
|
||||
* per i job in coda.
|
||||
*/
|
||||
private const TTL_POSTO_IMPORT_XML = 600;
|
||||
|
||||
public function convertSqlToXml(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
|
||||
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
|
||||
}
|
||||
|
||||
$exchange = new XmlExchange();
|
||||
$result = $exchange->importXmlFromContent($content);
|
||||
// L'import svuota e riscrive le tabelle: non puo' girare insieme a un
|
||||
// ripristino (che fa lo stesso), a un azzeramento, a un backup che le sta
|
||||
// fotografando o a un import PDF che sta aggiornando i territori. Il
|
||||
// ripristino chiama importXmlFromContent() per conto suo, gia' dentro il
|
||||
// proprio posto: per questo il blocco sta qui, sulla porta d'ingresso, e
|
||||
// non dentro l'import.
|
||||
$stato = app(BackupOperationState::class);
|
||||
$operationId = (string) Str::uuid();
|
||||
$inCorso = $stato->occupa($operationId, BackupOperationState::TIPO_IMPORT_XML, 'Import XML', self::TTL_POSTO_IMPORT_XML);
|
||||
|
||||
if ($inCorso !== null) {
|
||||
return back()->withErrors(['xmlImport' => $stato->messaggioOccupato($inCorso) . ' Nessun dato è stato modificato.']);
|
||||
}
|
||||
|
||||
$stato->markRunning($operationId);
|
||||
|
||||
try {
|
||||
$exchange = new XmlExchange();
|
||||
$fileDiPrima = XmlExchange::fileDeiTerritori();
|
||||
$result = $exchange->importXmlFromContent($content);
|
||||
|
||||
if (! isset($result['error'])) {
|
||||
$result['stats']['file_rimossi'] = XmlExchange::eliminaFileNonPiuUsati($fileDiPrima);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$stato->markFailed($operationId, $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$stato->markFailed($operationId, $result['error']);
|
||||
|
||||
return back()->withErrors(['xmlImport' => $result['error']]);
|
||||
}
|
||||
|
||||
$stato->markCompleted($operationId);
|
||||
|
||||
$stats = $result['stats'];
|
||||
$issues = $result['issues'];
|
||||
|
||||
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
|
||||
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
|
||||
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
|
||||
}
|
||||
if (($stats['file_rimossi'] ?? 0) > 0) {
|
||||
$message .= ' Eliminati ' . $stats['file_rimossi'] . ' file (PDF e miniature) dei territori sostituiti, che i dati importati non usano più.';
|
||||
}
|
||||
|
||||
return redirect()->route('xml.exchange')
|
||||
->with('success', $message)
|
||||
|
||||
62
app/Http/Controllers/TerritorioFileController.php
Normal file
62
app/Http/Controllers/TerritorioFileController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* PDF, miniature e versioni archiviate dei territori, solo per chi è dentro.
|
||||
*
|
||||
* I file stanno sul disco `public`, ma nginx non serve più le cartelle
|
||||
* `territori-*` (docker/nginx/default.conf): prima chiunque scaricava
|
||||
* /storage/territori-pdf/territorio-12-34.pdf senza login, con nomi facili da
|
||||
* indovinare, e Cloudflare li teneva in cache pubblica per una settimana. I
|
||||
* proclamatori continuano a riceverli dal link con codice e scadenza
|
||||
* (AssignmentPdfController); da qui passano solo gli utenti autenticati, con i
|
||||
* permessi della pagina che li mostra.
|
||||
*/
|
||||
class TerritorioFileController extends Controller
|
||||
{
|
||||
public function pdf(Territorio $territorio): Response
|
||||
{
|
||||
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
|
||||
}
|
||||
|
||||
public function miniatura(Territorio $territorio): Response
|
||||
{
|
||||
return $this->servi($territorio->thumbnail_path, null, null);
|
||||
}
|
||||
|
||||
public function versione(TerritorioPdfVersione $versione): Response
|
||||
{
|
||||
$numero = $versione->territorio?->numero ?? 'senza-territorio';
|
||||
|
||||
return $this->servi($versione->pdf_path, 'application/pdf', "territorio-{$numero}-versione-{$versione->id}.pdf");
|
||||
}
|
||||
|
||||
private function servi(?string $percorso, ?string $tipo, ?string $nome): Response
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
|
||||
abort_unless($percorso && $disco->exists($percorso), 404);
|
||||
|
||||
$intestazioni = [];
|
||||
if ($tipo) {
|
||||
$intestazioni['Content-Type'] = $tipo;
|
||||
}
|
||||
if ($nome) {
|
||||
$intestazioni['Content-Disposition'] = 'inline; filename="' . $nome . '"';
|
||||
}
|
||||
|
||||
// Privata: niente cache intermedie (Cloudflare). Va impostata dopo,
|
||||
// perché BinaryFileResponse si dichiara pubblica da sola e
|
||||
// sovrascriverebbe un Cache-Control passato fra le intestazioni.
|
||||
// L'indirizzo porta l'impronta del percorso (?v=), che cambia col file.
|
||||
return response()->file($disco->path($percorso), $intestazioni)
|
||||
->setPrivate()
|
||||
->setMaxAge(86400);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Jobs;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -44,7 +45,7 @@ class CreateBackupArchive implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
|
||||
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
|
||||
$this->rimuoviArchiviVecchi($archivio);
|
||||
|
||||
$xml = $exchange->currentXmlBackup();
|
||||
$datiPiantine = $piantine->esporta();
|
||||
|
||||
$stato->fase($this->operationId, 'Compressione dei file');
|
||||
|
||||
$archivio->create(
|
||||
$xml,
|
||||
$datiPiantine,
|
||||
$destinazione,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -35,14 +37,21 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
|
||||
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService, BackupOperationState $postoUnico, PiantinaPdfService $pdfService): void
|
||||
{
|
||||
$stateService->markRunning($this->importId);
|
||||
$postoUnico->markRunning($this->importId);
|
||||
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
|
||||
|
||||
// Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si
|
||||
// tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede
|
||||
// e scarta il file come ambiguo invece di sceglierne uno a caso.
|
||||
$territoriMap = [];
|
||||
foreach (Territorio::withTrashed()->get() as $territorio) {
|
||||
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
|
||||
$chiave = $this->chiaveTerritorio((string) $territorio->numero);
|
||||
if ($chiave !== '') {
|
||||
$territoriMap[$chiave][] = $territorio;
|
||||
}
|
||||
}
|
||||
|
||||
$actor = User::find($this->actorId);
|
||||
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedNumber = $territoryMatch['normalized_number'];
|
||||
if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') {
|
||||
$codici = implode(', ', $territoryMatch['codes']);
|
||||
$esistenti = implode(', ', $territoryMatch['matched_numbers']);
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.');
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'unknown-suffix',
|
||||
'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.',
|
||||
'matched_numbers' => $territoryMatch['matched_numbers'],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchedNumber = $territoryMatch['matched_number'];
|
||||
$territorio = $territoryMatch['territorio'];
|
||||
// Per id, non per numero: due file che nominano lo stesso
|
||||
// territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono
|
||||
// comunque un doppione.
|
||||
$normalizedNumber = $territorio->id;
|
||||
|
||||
if (isset($seenNumbers[$normalizedNumber])) {
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
if ($territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($territorio->pdf_path);
|
||||
}
|
||||
|
||||
if ($territorio->thumbnail_path) {
|
||||
$thumbnailService->delete($territorio->thumbnail_path);
|
||||
}
|
||||
|
||||
// The territory id is part of the filename because the slug
|
||||
// alone is not unique: "1 A" and "1-A" are distinct
|
||||
// territory numbers that both slug to "territorio-1-a".
|
||||
// Without it the second import overwrites the first's PDF
|
||||
// and both rows point at one file — then removing the PDF
|
||||
// from either territory breaks the other.
|
||||
//
|
||||
// Il nuovo file si scrive per primo, con un nome che non
|
||||
// coincide con quello in vigore; solo dopo il vecchio va
|
||||
// nello storico, come per le piantine. Prima si cancellava
|
||||
// il vecchio e poi si scriveva: con una scrittura fallita il
|
||||
// territorio restava senza PDF, e un import sbagliato non si
|
||||
// poteva annullare.
|
||||
$storedFilename = Str::slug('territorio-' . $territorio->numero)
|
||||
. '-' . $territorio->id . '.pdf';
|
||||
. '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
|
||||
$publicPath = 'territori-pdf/' . $storedFilename;
|
||||
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
|
||||
if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) {
|
||||
throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.');
|
||||
}
|
||||
|
||||
$pdfService->archiviaCorrente($territorio, 'import');
|
||||
$thumbnailPath = $thumbnailService->generate($publicPath);
|
||||
|
||||
$territorio->update([
|
||||
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
|
||||
$stateService->appendLog($this->importId, 'Import completato.');
|
||||
$stateService->markCompleted($this->importId);
|
||||
$postoUnico->markCompleted($this->importId);
|
||||
} catch (\Throwable $exception) {
|
||||
$stateService->increment($this->importId, 'errors');
|
||||
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
|
||||
$stateService->markFailed($this->importId);
|
||||
$postoUnico->markFailed($this->importId, $exception->getMessage());
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
* Without this the import state is stranded in "running" — the UI polls it
|
||||
* until the 24h cache entry expires — and the uploaded temp directory is
|
||||
* never reclaimed, since handle()'s finally never executes.
|
||||
*
|
||||
* Libera anche il posto unico: senza, un import ucciso dal timeout
|
||||
* lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera
|
||||
* del TTL, con un'operazione che non avanza piu'.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||
);
|
||||
$stateService->markFailed($this->importId);
|
||||
app(BackupOperationState::class)->markFailed(
|
||||
$this->importId,
|
||||
'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||
);
|
||||
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbina il nome di un file al territorio, oppure spiega perche' non lo fa.
|
||||
*
|
||||
* La regola: nel nome si cercano i "numeri di territorio", cioe' cifre
|
||||
* seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un
|
||||
* trattino, un trattino basso o un punto ("12", "012", "12B", "12 B",
|
||||
* "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo
|
||||
* stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero
|
||||
* deve stare da solo — "T12" o "12AB" non contano — e quelli che non
|
||||
* corrispondono a nessun territorio (un anno, un CAP) vengono ignorati.
|
||||
*
|
||||
* Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A"
|
||||
* conteneva "1", e in assenza di un territorio 1A il PDF finiva sul
|
||||
* territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera
|
||||
* dopo il numero fa parte del numero, e se quel territorio non esiste il
|
||||
* file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si
|
||||
* tocca.
|
||||
*
|
||||
* Il file si scarta anche quando l'abbinamento non e' univoco: due numeri
|
||||
* di territori esistenti nello stesso nome, o due territori che si
|
||||
* scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire
|
||||
* la mappa sbagliata e' peggio che non sostituirne nessuna: un file
|
||||
* scartato si ricarica, un PDF sovrascritto non torna.
|
||||
*
|
||||
* I territori con numeri di altra forma (lettere davanti, piu' lettere in
|
||||
* fondo) non si abbinano da nome file: il PDF va caricato dalla loro
|
||||
* scheda.
|
||||
*/
|
||||
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
|
||||
{
|
||||
if ($territoriMap === []) {
|
||||
return null;
|
||||
}
|
||||
$trovati = [];
|
||||
$suffissiSconosciuti = [];
|
||||
|
||||
$basename = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$normalizedBasename = mb_strtoupper($basename);
|
||||
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
|
||||
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
|
||||
foreach ($this->numeriNelNomeFile($filename) as $codice) {
|
||||
if (isset($territoriMap[$codice])) {
|
||||
foreach ($territoriMap[$codice] as $territorio) {
|
||||
$trovati[$territorio->id] = $territorio;
|
||||
}
|
||||
|
||||
$territoryKeys = array_keys($territoriMap);
|
||||
usort($territoryKeys, function (string $left, string $right) {
|
||||
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
|
||||
|
||||
if ($lengthComparison !== 0) {
|
||||
return $lengthComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp($left, $right);
|
||||
});
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($territoryKeys as $normalizedNumber) {
|
||||
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'normalized_number' => $normalizedNumber,
|
||||
'matched_number' => $territoriMap[$normalizedNumber]->numero,
|
||||
'territorio' => $territoriMap[$normalizedNumber],
|
||||
// "1A" che non esiste, mentre 1 si': e' il caso che prima
|
||||
// sovrascriveva il territorio 1. Lo si annota per spiegarlo.
|
||||
$base = preg_replace('/\p{L}$/u', '', $codice);
|
||||
if ($base !== $codice && isset($territoriMap[$base])) {
|
||||
$suffissiSconosciuti[$codice] = $territoriMap[$base];
|
||||
}
|
||||
}
|
||||
|
||||
$numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori)));
|
||||
|
||||
if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) {
|
||||
return [
|
||||
'type' => 'ambiguous',
|
||||
'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))),
|
||||
];
|
||||
}
|
||||
|
||||
if ($matches === []) {
|
||||
if ($trovati === [] && $suffissiSconosciuti !== []) {
|
||||
return [
|
||||
'type' => 'unknown-suffix',
|
||||
'codes' => array_keys($suffissiSconosciuti),
|
||||
'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))),
|
||||
];
|
||||
}
|
||||
|
||||
if ($trovati === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($matches) > 1) {
|
||||
return [
|
||||
'type' => 'ambiguous',
|
||||
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
|
||||
];
|
||||
}
|
||||
$territorio = reset($trovati);
|
||||
|
||||
return $matches[0];
|
||||
return [
|
||||
'type' => 'single',
|
||||
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
|
||||
'matched_number' => $territorio->numero,
|
||||
'territorio' => $territorio,
|
||||
];
|
||||
}
|
||||
|
||||
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
|
||||
/**
|
||||
* I numeri di territorio presenti nel nome del file, gia' nella forma di
|
||||
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function numeriNelNomeFile(string $filename): array
|
||||
{
|
||||
$escapedNumber = preg_quote($normalizedNumber, '/');
|
||||
$nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME));
|
||||
|
||||
if (preg_match('/^\d+$/', $normalizedNumber)) {
|
||||
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
|
||||
// Il lookahead finale e' quello che separa "12 B" (numero 12B) da
|
||||
// "12 Via Roma" (numero 12): la lettera vale come parte del numero solo
|
||||
// se dopo di lei la parola finisce.
|
||||
preg_match_all('/(?<![\p{L}\p{N}])(\d+)(?:[ _.\-]?(\p{L}))?(?![\p{L}\p{N}])/u', $nome, $trovati, PREG_SET_ORDER);
|
||||
|
||||
$codici = [];
|
||||
foreach ($trovati as $trovato) {
|
||||
$codici[] = (ltrim($trovato[1], '0') ?: '0') . ($trovato[2] ?? '');
|
||||
}
|
||||
|
||||
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
|
||||
return array_values(array_unique($codici));
|
||||
}
|
||||
|
||||
protected function normalizeTerritoryNumber(string $number): string
|
||||
/**
|
||||
* Forma di confronto di un numero di territorio: maiuscolo, senza
|
||||
* separatori, senza zeri iniziali. "12 B", "12-b" e "012B" diventano tutti
|
||||
* "12B"; due territori con la stessa chiave rendono ambiguo ogni file che
|
||||
* la nomina.
|
||||
*/
|
||||
protected function chiaveTerritorio(string $numero): string
|
||||
{
|
||||
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
|
||||
$chiave = preg_replace('/[^\p{L}\p{N}]+/u', '', mb_strtoupper($numero));
|
||||
|
||||
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
|
||||
return ltrim($normalized, '0') ?: '0';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ 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;
|
||||
@@ -21,6 +23,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
||||
@@ -72,12 +75,17 @@ class ResetApplication implements ShouldQueue
|
||||
$stato->avanza($this->operationId, 1);
|
||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8);
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
|
||||
|
||||
DB::transaction(function () use ($stato) {
|
||||
$passi = [
|
||||
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||
'Campagne' => fn () => Campagna::query()->delete(),
|
||||
// Prima dei territori, e per esteso: le piantine hanno la
|
||||
// chiave a null on delete, quindi sopravvivevano scollegate
|
||||
// e con l'immagine che puntava a un file appena cancellato.
|
||||
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
||||
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||
@@ -100,6 +108,13 @@ class ResetApplication implements ShouldQueue
|
||||
// ricreare la riga.
|
||||
Setting::flushCache();
|
||||
|
||||
// Area della città e anteprime delle pubblicazioni stanno sul disco
|
||||
// privato, che svuotaDiscoPubblico() non tocca: restavano lì e
|
||||
// l'editor ripartiva con la città di prima.
|
||||
Storage::disk('local')->deleteDirectory('piantine');
|
||||
Storage::disk('local')->deleteDirectory('piantine-anteprime');
|
||||
$stato->log($this->operationId, 'Area della città e anteprime delle piantine rimosse.');
|
||||
|
||||
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
|
||||
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Setting;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
@@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||
/**
|
||||
* Il ripristino e' tutto o niente. Tre tempi:
|
||||
*
|
||||
* 1. estrazione e verifica dell'archivio nello staging, piantine.json
|
||||
* compreso: un archivio rotto si ferma qui, a installazione intatta;
|
||||
* 2. un'unica transazione con l'import XML, le tabelle delle piantine, il
|
||||
* blocco degli account e — per ultimo, ancora dentro — lo scambio dei
|
||||
* file. Se lo scambio fallisce si disfa da solo e la transazione si
|
||||
* annulla; se fallisce il commit, i file si rimettono a posto qui;
|
||||
* 3. a commit avvenuto, si buttano i file precedenti.
|
||||
*
|
||||
* Prima import, blocco account e file erano tre passi separati: un errore
|
||||
* sui file lasciava il database gia' riscritto con il disco a meta', e un
|
||||
* errore sul blocco lasciava attive le password del backup.
|
||||
*
|
||||
* Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit
|
||||
* implicito, chiudendo la transazione a meta'. L'import XML e
|
||||
* PiantineBackup cancellano con DELETE.
|
||||
*/
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
|
||||
$staging = null;
|
||||
$scambio = null;
|
||||
$confermato = false;
|
||||
|
||||
try {
|
||||
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
|
||||
$stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
|
||||
|
||||
$estratto = $archivio->extract(
|
||||
$this->zipPath,
|
||||
@@ -56,33 +80,46 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
);
|
||||
$staging = $estratto['staging'];
|
||||
|
||||
$piantine->valida($estratto['piantine']);
|
||||
|
||||
$stato->fase($this->operationId, 'Riscrittura del database', 1);
|
||||
|
||||
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
|
||||
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
|
||||
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
|
||||
// primo id disponibile invece che su chi ha avviato il ripristino.
|
||||
//
|
||||
// E' anche cio' che tiene dentro chi ripristina: l'import riconosce
|
||||
// l'attore e ne conserva hash della password, remember_token e
|
||||
// ruolo di amministratore. Con AuthenticateSession attivo, un hash
|
||||
// cambiato chiuderebbe la sua sessione e lo costringerebbe a
|
||||
// rientrare con la password dell'epoca del backup, che puo' non
|
||||
// ricordare. Se il suo account nel backup non c'e', l'import (che
|
||||
// fa upsert per email, senza cancellare nessuno) non lo tocca
|
||||
// affatto: resta con password e ruoli attuali.
|
||||
Auth::onceUsingId($this->actorId);
|
||||
|
||||
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||
DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) {
|
||||
// La transazione dell'import diventa un savepoint di questa:
|
||||
// il suo commit non e' piu' definitivo.
|
||||
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$stato->markFailed($this->operationId, $result['error']);
|
||||
if (isset($result['error'])) {
|
||||
throw new RuntimeException($result['error']);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$esitoPiantine = $piantine->ripristina($estratto['piantine']);
|
||||
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
$utenti = $this->richiediCambioPasswordATutti();
|
||||
|
||||
$stato->fase($this->operationId, 'Ripristino dei file');
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
|
||||
|
||||
$fileRipristinati = $archivio->restoreFiles(
|
||||
$staging,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
$scambio = $archivio->scambiaFile($estratto);
|
||||
});
|
||||
|
||||
$stato->fase($this->operationId, 'Blocco degli account', 1);
|
||||
$utenti = $this->richiediCambioPasswordATutti();
|
||||
$confermato = true;
|
||||
$archivio->confermaScambio($scambio);
|
||||
|
||||
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
|
||||
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
|
||||
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
} catch (\Throwable $e) {
|
||||
$messaggio = $e->getMessage();
|
||||
|
||||
// Lo scambio era riuscito ma il commit no: il database e' tornato
|
||||
// indietro, i file devono seguirlo.
|
||||
if ($scambio !== null && ! $confermato) {
|
||||
try {
|
||||
$archivio->annullaScambio($scambio, $e);
|
||||
} catch (\Throwable $annullamento) {
|
||||
$messaggio = $annullamento->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// La transazione annullata non svuota le cache in memoria del
|
||||
// processo: Setting terrebbe l'istanza creata dall'import (una
|
||||
// riga che non esiste piu') e Spatie i ruoli letti durante l'import.
|
||||
Setting::flushCache();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
||||
$stato->markFailed($this->operationId, $e->getMessage());
|
||||
$stato->markFailed($this->operationId, $messaggio);
|
||||
|
||||
return;
|
||||
} finally {
|
||||
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
}
|
||||
|
||||
$stats = $result['stats'];
|
||||
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
|
||||
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
|
||||
$fileRipristinati = $scambio['file'];
|
||||
|
||||
$messaggio = sprintf(
|
||||
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
|
||||
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. '
|
||||
. 'Per sicurezza %s impostare una nuova password.',
|
||||
$stats['territori_importati'] ?? 0,
|
||||
$stats['proclamatori_importati'] ?? 0,
|
||||
$stats['assegnazioni_importate'] ?? 0,
|
||||
$esitoPiantine['mappe'],
|
||||
$fileRipristinati,
|
||||
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
|
||||
);
|
||||
|
||||
// Un archivio di formato 1 non conteneva le piantine: va detto, o
|
||||
// l'amministratore le cercherebbe senza sapere perche' sono sparite.
|
||||
if ($estratto['piantine'] === null) {
|
||||
$stato->log(
|
||||
$this->operationId,
|
||||
'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. '
|
||||
. 'L\'area KML in uso non e\' stata toccata.'
|
||||
);
|
||||
}
|
||||
|
||||
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
|
||||
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
|
||||
// il middleware dirotta l'amministratore sul cambio password. Lo si
|
||||
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
$stato->log($this->operationId, $messaggio);
|
||||
$stato->markCompleted($this->operationId, [
|
||||
'stats' => $stats,
|
||||
'issues' => array_slice($result['issues'], 0, 50),
|
||||
'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
|
||||
'file_ripristinati' => $fileRipristinati,
|
||||
'messaggio' => $messaggio,
|
||||
]);
|
||||
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
* muore per cause che handle() non intercetta, cosi' il posto unico si
|
||||
* libera subito invece di restare occupato fino alla scadenza.
|
||||
*
|
||||
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino
|
||||
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi
|
||||
* con database e file disallineati, e chi legge deve sapere che la strada
|
||||
* e' rilanciare il ripristino, non ignorare l'errore.
|
||||
* Qui il messaggio dice anche in che stato si e' rimasti. Un processo
|
||||
* ucciso a meta' non arriva al commit, e il database annulla da se' la
|
||||
* transazione rimasta aperta: i dati restano quelli di prima. L'unica
|
||||
* finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei
|
||||
* file e il commit — in quel caso i file precedenti sono ancora in
|
||||
* storage/app/backup-precedenti/. La strada resta rilanciare il
|
||||
* ripristino, non ignorare l'errore.
|
||||
*/
|
||||
public function failed(?\Throwable $e): void
|
||||
{
|
||||
app(BackupOperationState::class)->markFailed(
|
||||
$this->operationId,
|
||||
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
|
||||
. ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.'
|
||||
. ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. '
|
||||
. 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.'
|
||||
);
|
||||
|
||||
@unlink($this->zipPath);
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Assegna extends Component
|
||||
{
|
||||
@@ -99,9 +98,12 @@ class Assegna extends Component
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($assegnazione)
|
||||
// L'id, non il nome: nome e cognome sono cifrati nel database, e
|
||||
// copiati qui in chiaro restavano leggibili anche dopo
|
||||
// l'anonimizzazione. Il registro mostra il nome di oggi.
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore' => $proclamatore->nome_completo,
|
||||
'proclamatore_id' => $proclamatore->id,
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
@@ -123,7 +125,7 @@ class Assegna extends Component
|
||||
return null;
|
||||
}
|
||||
$t = Territorio::find($this->territorio_id);
|
||||
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
|
||||
return $t?->miniaturaUrl();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -92,7 +92,7 @@ class Rientra extends Component
|
||||
->performedOn($this->assegnazione)
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore' => $proclamatore->nome_completo,
|
||||
'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
|
||||
'giorni' => $this->assegnazione->giorni,
|
||||
'campagna' => $this->counted_in_campaign,
|
||||
])
|
||||
|
||||
@@ -48,8 +48,22 @@ class AuditLog extends Component
|
||||
$events = Activity::select('description')->distinct()->pluck('description');
|
||||
$users = \App\Models\User::orderBy('name')->get();
|
||||
|
||||
$activities = $query->paginate(30);
|
||||
|
||||
// Il log conserva solo l'id del proclamatore: il nome si legge adesso,
|
||||
// così chi è stato anonimizzato compare come tale anche nello storico.
|
||||
$idProclamatori = $activities->getCollection()
|
||||
->map(fn ($a) => data_get($a->properties, 'proclamatore_id'))
|
||||
->filter()
|
||||
->unique();
|
||||
$nomiProclamatori = \App\Models\Proclamatore::withTrashed()
|
||||
->whereIn('id', $idProclamatori)
|
||||
->get()
|
||||
->mapWithKeys(fn ($p) => [$p->id => $p->nome_completo]);
|
||||
|
||||
return view('livewire.audit-log', [
|
||||
'activities' => $query->paginate(30),
|
||||
'activities' => $activities,
|
||||
'nomiProclamatori' => $nomiProclamatori,
|
||||
'events' => $events,
|
||||
'users' => $users,
|
||||
]);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function deleteCampagna(int $id)
|
||||
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
|
||||
|
||||
$campagna = Campagna::findOrFail($id);
|
||||
$campagna->delete();
|
||||
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
|
||||
$this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -18,12 +18,12 @@ class CampagnaShow extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Assignments counted for this campaign:
|
||||
// - assigned on or after campaign start
|
||||
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
|
||||
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
|
||||
->where('counted_in_campaign', true)
|
||||
->where('assigned_at', '>=', $this->campagna->start_date)
|
||||
// Le assegnazioni contate per la campagna, cioè quelle per cui al
|
||||
// rientro si è risposto sì (campagnaApplicabile propone anche quelle
|
||||
// uscite poco prima dell'inizio e rientrate durante). Le stesse della
|
||||
// percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
|
||||
// e l'elenco era più corto di quanto la percentuale contasse.
|
||||
$conteggiate = $this->campagna->assegnazioniConteggiate()
|
||||
->with(['territorio', 'proclamatore'])
|
||||
->orderBy('returned_at')
|
||||
->get();
|
||||
|
||||
@@ -174,22 +174,46 @@ trait SegueOperazioneBackup
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
||||
// premuto il pulsante vuole comunque vedere a che punto e'.
|
||||
$this->operationId = $inCorso;
|
||||
$this->logSpediti = 0;
|
||||
$this->aggiorna();
|
||||
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
|
||||
$this->rifiutaPerOccupato($inCorso);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function nuovaOperazione(string $tipo): string
|
||||
/**
|
||||
* Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
||||
* premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio
|
||||
* dice quale operazione occupa il posto, perche' puo' essere un import
|
||||
* avviato da un'altra zona della pagina, o da un altro amministratore.
|
||||
*/
|
||||
private function rifiutaPerOccupato(string $inCorso): void
|
||||
{
|
||||
$this->operationId = $inCorso;
|
||||
$this->logSpediti = 0;
|
||||
$this->aggiorna();
|
||||
$this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prende il posto e crea l'operazione; null se il posto e' occupato.
|
||||
*
|
||||
* postoLibero() resta il controllo rapido prima di validare il resto, ma fra
|
||||
* quel controllo e questo passo un'altra richiesta puo' arrivare prima — una
|
||||
* seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex
|
||||
* di BackupOperationState::occupa(), che si decide davvero.
|
||||
*/
|
||||
protected function nuovaOperazione(string $tipo): ?string
|
||||
{
|
||||
$operationId = (string) Str::uuid();
|
||||
|
||||
$stato = app(BackupOperationState::class);
|
||||
$stato->initialize($operationId, $tipo, 'In coda');
|
||||
$inCorso = $stato->occupa($operationId, $tipo, 'In coda');
|
||||
|
||||
if ($inCorso !== null) {
|
||||
$this->rifiutaPerOccupato($inCorso);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
|
||||
|
||||
$this->logSpediti = 0;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreCestino extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function restore(int $id)
|
||||
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($proclamatore)
|
||||
->log('restored');
|
||||
session()->flash('success', "Proclamatore ripristinato.");
|
||||
$this->avvisa("Proclamatore ripristinato.");
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
|
||||
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if ($proclamatore->assegnazioni()->exists()) {
|
||||
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
|
||||
$this->avvisaErrore('Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
|
||||
return;
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->log('force_deleted_proclamatore');
|
||||
$proclamatore->forceDelete();
|
||||
session()->flash('success', 'Proclamatore eliminato definitivamente.');
|
||||
$this->avvisa('Proclamatore eliminato definitivamente.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
@@ -59,12 +61,12 @@ class ProclamatoreIndex extends Component
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
|
||||
if ($proclamatore->assegnazioni()->aperte()->exists()) {
|
||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
||||
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
||||
return;
|
||||
}
|
||||
|
||||
$proclamatore->delete();
|
||||
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||
}
|
||||
|
||||
public function anonimizza(int $id)
|
||||
@@ -73,7 +75,7 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$proclamatore->anonimizza();
|
||||
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class Profile extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
||||
@@ -67,7 +69,7 @@ class Profile extends Component
|
||||
|
||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||
|
||||
session()->flash('password_success', 'Password aggiornata con successo.');
|
||||
$this->avvisa('Password aggiornata con successo.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -48,7 +48,11 @@ class BackupManager extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
||||
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
||||
if ($operationId === null) {
|
||||
return;
|
||||
}
|
||||
$this->operationId = $operationId;
|
||||
|
||||
CreateBackupArchive::dispatch($this->operationId, false);
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SettingsEdit extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $congregazione_nome = '';
|
||||
public int $giorni_giacenza_da_assegnare = 120;
|
||||
public int $giorni_giacenza_prioritari = 180;
|
||||
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
|
||||
'audit_retention_days' => $this->audit_retention_days,
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Impostazioni aggiornate.');
|
||||
$this->avvisa('Impostazioni aggiornate.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Tipologia;
|
||||
|
||||
class TipologieIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $nuovaTipologia = '';
|
||||
public ?int $editingId = null;
|
||||
public string $editingNome = '';
|
||||
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
|
||||
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
|
||||
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
|
||||
$this->nuovaTipologia = '';
|
||||
session()->flash('success', 'Tipologia aggiunta.');
|
||||
$this->avvisa('Tipologia aggiunta.');
|
||||
}
|
||||
|
||||
public function startEdit(int $id)
|
||||
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
|
||||
$tipo->update(['nome' => $this->editingNome]);
|
||||
$this->editingId = null;
|
||||
$this->editingNome = '';
|
||||
session()->flash('success', 'Tipologia aggiornata.');
|
||||
$this->avvisa('Tipologia aggiornata.');
|
||||
}
|
||||
|
||||
public function cancelEdit()
|
||||
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
|
||||
{
|
||||
$tipo = Tipologia::findOrFail($id);
|
||||
if ($tipo->territori()->exists()) {
|
||||
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
|
||||
$this->avvisaErrore("Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
|
||||
return;
|
||||
}
|
||||
$tipo->delete();
|
||||
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
|
||||
$this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -13,6 +14,7 @@ use Spatie\Permission\Models\Role;
|
||||
|
||||
class UsersIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
@@ -83,7 +85,7 @@ class UsersIndex extends Component
|
||||
if (! empty($this->availableRoles)) {
|
||||
$this->selectedRole = $this->availableRoles[0];
|
||||
}
|
||||
session()->flash('success', 'Utente creato con successo.');
|
||||
$this->avvisa('Utente creato con successo.');
|
||||
}
|
||||
|
||||
public function startEdit(int $userId): void
|
||||
@@ -147,7 +149,7 @@ class UsersIndex extends Component
|
||||
$user->syncRoles([$validated['editSelectedRole']]);
|
||||
|
||||
$this->cancelEdit();
|
||||
session()->flash('success', 'Utente aggiornato con successo.');
|
||||
$this->avvisa('Utente aggiornato con successo.');
|
||||
}
|
||||
|
||||
public function deleteUser(int $userId): void
|
||||
@@ -156,12 +158,12 @@ class UsersIndex extends Component
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
|
||||
if (! $currentUser || $currentUser->id === $user->id) {
|
||||
session()->flash('error', 'Non puoi cancellare il tuo utente.');
|
||||
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
|
||||
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
|
||||
$this->avvisaErrore('Non puoi cancellare l\'ultimo amministratore.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,7 +203,7 @@ class UsersIndex extends Component
|
||||
$this->cancelEdit();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
|
||||
$this->avvisa('Utente cancellato. I log sono stati preservati.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -9,20 +10,27 @@ 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 = [];
|
||||
@@ -52,39 +60,149 @@ class XmlExchange extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function importTerritoryPdfFolder(): void
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$this->validate([
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
||||
]);
|
||||
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
|
||||
}
|
||||
|
||||
$importId = (string) Str::uuid();
|
||||
$storedFiles = [];
|
||||
/**
|
||||
* 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();
|
||||
|
||||
foreach ($this->pdfFolder as $index => $file) {
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
|
||||
$storedPath = $file->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
|
||||
'local'
|
||||
);
|
||||
if ($inCorso === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$storedFiles[] = [
|
||||
'original_name' => $originalName,
|
||||
'stored_path' => $storedPath,
|
||||
];
|
||||
$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.'
|
||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
|
||||
$ignorati,
|
||||
$scartati
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function refreshPdfImportStatus(): void
|
||||
@@ -114,8 +232,9 @@ class XmlExchange extends Component
|
||||
* 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) e
|
||||
* non partecipa al posto unico: puo' girare mentre un backup lavora.
|
||||
* 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
|
||||
{
|
||||
@@ -156,10 +275,10 @@ class XmlExchange extends Component
|
||||
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
|
||||
}
|
||||
|
||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
|
||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
|
||||
{
|
||||
$state = app(TerritorioPdfImportDispatcher::class)
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
|
||||
|
||||
$this->currentPdfImportId = $importId;
|
||||
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
||||
@@ -172,13 +291,13 @@ class XmlExchange extends Component
|
||||
$this->logPdfSpediti = 0;
|
||||
$this->refreshPdfImportStatus();
|
||||
|
||||
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
|
||||
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
|
||||
}
|
||||
|
||||
public function downloadImportLogPdf()
|
||||
{
|
||||
if (empty($this->importStats)) {
|
||||
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
|
||||
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,6 +355,67 @@ class XmlExchange extends Component
|
||||
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 = [];
|
||||
@@ -370,14 +550,20 @@ class XmlExchange extends Component
|
||||
// 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) {
|
||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
||||
$meta['remember_token'] = null;
|
||||
}
|
||||
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
||||
|
||||
@@ -620,7 +806,8 @@ class XmlExchange extends Component
|
||||
'email' => (string) $u->email,
|
||||
'password' => (string) $u->getAuthPassword(),
|
||||
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
|
||||
'remember_token' => (string) ($u->getRememberToken() ?? ''),
|
||||
// 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() ?? '',
|
||||
@@ -1030,7 +1217,6 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
|
||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||
$rolesNode = $node->addChild('roles');
|
||||
|
||||
@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||
if ($operationId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->confermaReset = '';
|
||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||
$this->operationId = $operationId;
|
||||
|
||||
CreateBackupArchive::dispatch($this->operationId, true);
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Zona;
|
||||
|
||||
class ZoneIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $nuovaZona = '';
|
||||
public ?int $editingId = null;
|
||||
public string $editingNome = '';
|
||||
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
|
||||
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
|
||||
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
|
||||
$this->nuovaZona = '';
|
||||
session()->flash('success', 'Zona aggiunta.');
|
||||
$this->avvisa('Zona aggiunta.');
|
||||
}
|
||||
|
||||
public function startEdit(int $id)
|
||||
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
|
||||
$zona->update(['nome' => $this->editingNome]);
|
||||
$this->editingId = null;
|
||||
$this->editingNome = '';
|
||||
session()->flash('success', 'Zona aggiornata.');
|
||||
$this->avvisa('Zona aggiornata.');
|
||||
}
|
||||
|
||||
public function cancelEdit()
|
||||
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
|
||||
{
|
||||
$zona = Zona::findOrFail($id);
|
||||
if ($zona->territori()->exists()) {
|
||||
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
|
||||
$this->avvisaErrore("Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
|
||||
return;
|
||||
}
|
||||
$zona->delete();
|
||||
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
|
||||
$this->avvisa("Zona '{$zona->nome}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioCestino extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function restore(int $id)
|
||||
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($territorio)
|
||||
->log('restored');
|
||||
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
|
||||
$this->avvisa("Territorio {$territorio->numero} ripristinato.");
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
|
||||
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if ($territorio->assegnazioni()->exists()) {
|
||||
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
|
||||
$this->avvisaErrore('Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
|
||||
$this->rimuoviFile($territorio);
|
||||
|
||||
$territorio->forceDelete();
|
||||
session()->flash('success', 'Territorio eliminato definitivamente.');
|
||||
$this->avvisa('Territorio eliminato definitivamente.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,14 +64,17 @@ class TerritorioEdit extends Component
|
||||
|
||||
if ($this->pdf) {
|
||||
$thumbService = app(TerritorioThumbnailService::class);
|
||||
// Remove old PDF and thumbnail
|
||||
if ($this->territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
||||
}
|
||||
if ($this->territorio->thumbnail_path) {
|
||||
$thumbService->delete($this->territorio->thumbnail_path);
|
||||
}
|
||||
// Prima il nuovo, poi il vecchio nello storico (ripristinabile
|
||||
// dalla pagina Piantine). Prima il vecchio si cancellava subito:
|
||||
// un caricamento sbagliato non si poteva disfare, e con il
|
||||
// salvataggio fallito il territorio restava senza PDF.
|
||||
$pdfPath = $this->pdf->store('territori-pdf', 'public');
|
||||
if (! $pdfPath) {
|
||||
$this->addError('pdf', 'Non è stato possibile salvare il PDF: quello in uso non è stato toccato.');
|
||||
|
||||
return;
|
||||
}
|
||||
app(\App\Services\Piantine\PiantinaPdfService::class)->archiviaCorrente($this->territorio, 'manuale');
|
||||
$data['pdf_path'] = $pdfPath;
|
||||
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
@@ -11,6 +12,7 @@ use App\Models\Tipologia;
|
||||
|
||||
class TerritorioIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
@@ -127,7 +129,7 @@ class TerritorioIndex extends Component
|
||||
->performedOn($territorio)
|
||||
->log('soft_deleted');
|
||||
$territorio->delete();
|
||||
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
|
||||
$this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -46,8 +46,13 @@ class Campagna extends Model
|
||||
*/
|
||||
public static function attiva(): ?static
|
||||
{
|
||||
return static::where('start_date', '<=', now())
|
||||
->where('end_date', '>=', now())
|
||||
// Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
|
||||
// suo giorno, e contro now() la campagna spariva dalla home proprio
|
||||
// nel suo ultimo giorno.
|
||||
$oggi = now()->toDateString();
|
||||
|
||||
return static::whereDate('start_date', '<=', $oggi)
|
||||
->whereDate('end_date', '>=', $oggi)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -70,9 +75,11 @@ class Campagna extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign coverage percentage.
|
||||
* Numerator: assignments counted for campaign
|
||||
* Denominator: total active territories
|
||||
* Quota dei territori attivi percorsi durante la campagna.
|
||||
*
|
||||
* Si contano i territori, non le assegnazioni: un territorio percorso due
|
||||
* volte valeva doppio, e insieme ai territori poi disattivati (fuori dal
|
||||
* denominatore ma ancora nel numeratore) la percentuale superava il 100%.
|
||||
*/
|
||||
public function getPercentualePercorrenzaAttribute(): float
|
||||
{
|
||||
@@ -82,13 +89,17 @@ class Campagna extends Model
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$conteggiate = $this->assegnazioniConteggiate()->count();
|
||||
$percorsi = $this->assegnazioniConteggiate()
|
||||
->whereHas('territorio', fn ($q) => $q->where('attivo', true))
|
||||
->distinct()
|
||||
->count('territorio_id');
|
||||
|
||||
return round(($conteggiate / $totaleAttivi) * 100, 1);
|
||||
return round(($percorsi / $totaleAttivi) * 100, 1);
|
||||
}
|
||||
|
||||
public function scopeCompletate($query)
|
||||
{
|
||||
return $query->where('end_date', '<', now())->orderByDesc('end_date');
|
||||
// Una campagna che finisce oggi è ancora in corso.
|
||||
return $query->whereDate('end_date', '<', now()->toDateString())->orderByDesc('end_date');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,4 +220,24 @@ class Territorio extends Model
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Indirizzi dei file (serviti da TerritorioFileController) ──────────
|
||||
|
||||
/**
|
||||
* ?v= cambia con il file: l'indirizzo resta per territorio, e senza la
|
||||
* cache del browser mostrerebbe il PDF di prima dopo una sostituzione.
|
||||
*/
|
||||
public function pdfUrl(): ?string
|
||||
{
|
||||
return $this->pdf_path
|
||||
? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)])
|
||||
: null;
|
||||
}
|
||||
|
||||
public function miniaturaUrl(): ?string
|
||||
{
|
||||
return $this->thumbnail_path
|
||||
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
use RuntimeException;
|
||||
@@ -17,6 +19,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Setting tiene le impostazioni in una variabile statica, svuotata
|
||||
// solo quando le salva lo stesso processo. Il worker resta vivo fino a
|
||||
// un'ora: senza questo, ogni job usava le impostazioni lette dal primo,
|
||||
// anche dopo che l'amministratore le aveva cambiate dal browser o un
|
||||
// ripristino le aveva sostituite.
|
||||
Queue::before(fn () => Setting::flushCache());
|
||||
|
||||
// Pin generated URLs to APP_URL.
|
||||
//
|
||||
// Behind a trusted proxy, Symfony derives the port from
|
||||
|
||||
@@ -20,8 +20,15 @@ use ZipArchive;
|
||||
*
|
||||
* manifest.json formato, data, conteggi (letto per validare il file)
|
||||
* data.xml lo stesso XML dell'export "Esporta XML"
|
||||
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
|
||||
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
||||
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
||||
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
|
||||
* che sta accanto al disco `public`, non dentro
|
||||
*
|
||||
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
|
||||
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
|
||||
* KML in uso non si tocca (vedi scambiaFile()).
|
||||
*/
|
||||
class BackupArchive
|
||||
{
|
||||
@@ -29,11 +36,23 @@ class BackupArchive
|
||||
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
||||
* quello che sa leggere invece di ripristinarli a meta'.
|
||||
*/
|
||||
public const FORMAT_VERSION = 1;
|
||||
public const FORMAT_VERSION = 2;
|
||||
|
||||
private const ENTRY_MANIFEST = 'manifest.json';
|
||||
private const ENTRY_DATA = 'data.xml';
|
||||
private const ENTRY_PIANTINE = 'piantine.json';
|
||||
private const PREFIX_STORAGE = 'storage/';
|
||||
private const PREFIX_PIANTINE = 'piantine/';
|
||||
|
||||
/**
|
||||
* Sottocartelle dello staging, una per ogni cartella che il ripristino
|
||||
* sostituisce (vedi radiciDaSostituire()).
|
||||
*/
|
||||
private const STAGING_PUBBLICO = 'pubblico';
|
||||
private const STAGING_PIANTINE = 'piantine';
|
||||
|
||||
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
|
||||
private const CARTELLA_PIANTINE = 'piantine';
|
||||
|
||||
/**
|
||||
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
|
||||
@@ -65,12 +84,32 @@ class BackupArchive
|
||||
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
||||
*
|
||||
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
||||
* @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
|
||||
* Obbligatorio e non facoltativo di proposito: un
|
||||
* chiamante che se ne dimentica produrrebbe archivi
|
||||
* che al ripristino svuotano le piantine.
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
*/
|
||||
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
|
||||
public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
$files = $disk->allFiles();
|
||||
$local = Storage::disk('local');
|
||||
|
||||
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
|
||||
// (per i messaggi), percorso sul disco, nome dell'entry.
|
||||
$files = [];
|
||||
foreach ($disk->allFiles() as $relativePath) {
|
||||
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
|
||||
}
|
||||
$filePubblici = count($files);
|
||||
|
||||
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
|
||||
// percorso relativo al disco coincide con il nome dell'entry.
|
||||
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
|
||||
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
|
||||
}
|
||||
$filePiantine = count($files) - $filePubblici;
|
||||
|
||||
$totale = count($files);
|
||||
|
||||
$cartella = dirname($destinazione);
|
||||
@@ -84,12 +123,14 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||
$zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||
'format_version' => self::FORMAT_VERSION,
|
||||
'application' => 'TerManager2',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'app_url' => (string) config('app.url'),
|
||||
'files_count' => $totale,
|
||||
'files_count' => $filePubblici,
|
||||
'piantine_files_count' => $filePiantine,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
try {
|
||||
@@ -110,12 +151,12 @@ class BackupArchive
|
||||
|
||||
try {
|
||||
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
||||
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
||||
$files, $totale, $perLotto, $onProgress, $destinazione
|
||||
) {
|
||||
$nelLotto = 0;
|
||||
$fatti = 0;
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
foreach ($files as [$relativePath, $sorgente, $entry]) {
|
||||
// addFile() legge dal disco durante close(): i PDF non
|
||||
// passano mai interi per la memoria del processo.
|
||||
//
|
||||
@@ -133,7 +174,7 @@ class BackupArchive
|
||||
// raggiunto mai, e l'amministratore leggerebbe la stringa
|
||||
// grezza di PHP. Stesso problema, e stessa scelta, del
|
||||
// close() avvolto in chiudi().
|
||||
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
|
||||
if (! @$zip->addFile($sorgente, $entry)) {
|
||||
throw new RuntimeException(
|
||||
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
||||
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
||||
@@ -157,7 +198,7 @@ class BackupArchive
|
||||
}
|
||||
|
||||
try {
|
||||
$this->verificaArchivio($destinazione, $totale);
|
||||
$this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($destinazione);
|
||||
|
||||
@@ -178,7 +219,7 @@ class BackupArchive
|
||||
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
||||
* troncamenti, chiusure a meta' e lotti persi.
|
||||
*/
|
||||
private function verificaArchivio(string $path, int $fileAttesi): void
|
||||
private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
@@ -187,27 +228,31 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$trovati = 0;
|
||||
$piantineTrovati = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
||||
$trovati++;
|
||||
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
|
||||
$piantineTrovati++;
|
||||
}
|
||||
}
|
||||
|
||||
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
||||
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
||||
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
|
||||
|
||||
$zip->close();
|
||||
|
||||
if (! $haDati || ! $haManifest) {
|
||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
|
||||
if (! $haDati || ! $haManifest || ! $haPiantine) {
|
||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
|
||||
}
|
||||
|
||||
if ($trovati !== $fileAttesi) {
|
||||
if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
||||
$trovati,
|
||||
$fileAttesi
|
||||
$trovati + $piantineTrovati,
|
||||
$fileAttesi + $piantineAttesi
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -294,8 +339,15 @@ class BackupArchive
|
||||
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||
* dati correnti sono ancora intatti.
|
||||
*
|
||||
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
|
||||
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
|
||||
* file troncato, un'entry corrotta o il disco che si riempie durante
|
||||
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
|
||||
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
|
||||
* anche per lo scambio, che e' solo una serie di rename().
|
||||
*
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
* @return array{xml: string, staging: string, files: int}
|
||||
* @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int}
|
||||
*/
|
||||
public function extract(string $zipPath, ?callable $onProgress = null): array
|
||||
{
|
||||
@@ -334,12 +386,35 @@ class BackupArchive
|
||||
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
|
||||
}
|
||||
|
||||
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
|
||||
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
|
||||
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
|
||||
// Null resta riservato al formato 1, che le piantine non le aveva.
|
||||
$piantine = null;
|
||||
if ($formatVersion >= 2) {
|
||||
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
|
||||
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
|
||||
|
||||
if (! is_array($piantine)) {
|
||||
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
|
||||
}
|
||||
}
|
||||
|
||||
// Le entry che si estraggono, prefisso => sottocartella dello
|
||||
// staging. L'area KML solo dal formato 2: un archivio vecchio non
|
||||
// la porta, e non deve poterla portare per caso.
|
||||
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
|
||||
if ($formatVersion >= 2) {
|
||||
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
|
||||
}
|
||||
|
||||
if ($zip->numFiles > self::MAX_ENTRIES) {
|
||||
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
||||
}
|
||||
|
||||
$totalBytes = 0;
|
||||
$extracted = 0;
|
||||
$estrattiPiantine = 0;
|
||||
|
||||
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
||||
// due entry di servizio": un archivio con una entry in piu' (una
|
||||
@@ -348,7 +423,7 @@ class BackupArchive
|
||||
$daEstrarre = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
|
||||
if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
|
||||
$daEstrarre++;
|
||||
}
|
||||
}
|
||||
@@ -361,11 +436,12 @@ class BackupArchive
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
|
||||
$prefisso = $this->prefissoDi($entryName, $prefissi);
|
||||
if ($prefisso === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
|
||||
$relativePath = substr($entryName, strlen($prefisso));
|
||||
$safePath = $this->safeRelativePath($relativePath);
|
||||
|
||||
if ($safePath === null) {
|
||||
@@ -375,7 +451,8 @@ class BackupArchive
|
||||
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
||||
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
||||
// volta decompressa, riempirebbe il disco.
|
||||
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
|
||||
$stat = $zip->statIndex($index);
|
||||
$size = (int) ($stat['size'] ?? 0);
|
||||
|
||||
if ($size > self::MAX_ENTRY_BYTES) {
|
||||
throw new RuntimeException(sprintf(
|
||||
@@ -390,7 +467,7 @@ class BackupArchive
|
||||
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
||||
}
|
||||
|
||||
$destination = $staging . '/' . $safePath;
|
||||
$destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
|
||||
$directory = dirname($destination);
|
||||
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||
@@ -409,12 +486,40 @@ class BackupArchive
|
||||
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($stream, $target);
|
||||
// Il risultato della copia si controlla, ed e' qui che il
|
||||
// ripristino diventa sicuro: prima un disco pieno o un'entry
|
||||
// corrotta lasciavano nello staging un file troncato senza
|
||||
// nessun errore, e quel file finiva al posto del PDF buono.
|
||||
// La @ per lo stesso motivo di addFile(): il warning di PHP
|
||||
// ("CRC error") arriverebbe all'amministratore al posto del
|
||||
// messaggio che dice quale file.
|
||||
$copiati = @stream_copy_to_stream($stream, $target);
|
||||
fclose($stream);
|
||||
fclose($target);
|
||||
$chiuso = fclose($target);
|
||||
|
||||
$extracted++;
|
||||
$onProgress && $onProgress($extracted, $daEstrarre);
|
||||
if ($copiati === false || $copiati !== $size || ! $chiuso) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
|
||||
$safePath,
|
||||
$copiati === false ? '0' : (string) $copiati,
|
||||
$size
|
||||
));
|
||||
}
|
||||
|
||||
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
|
||||
// lettura arriva in fondo e come warning: ricalcolarlo sul file
|
||||
// scritto costa una rilettura e non dipende da come libzip
|
||||
// segnala l'errore.
|
||||
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
|
||||
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
|
||||
}
|
||||
|
||||
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
|
||||
$estrattiPiantine++;
|
||||
} else {
|
||||
$extracted++;
|
||||
}
|
||||
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$zip->close();
|
||||
@@ -425,63 +530,180 @@ class BackupArchive
|
||||
|
||||
$zip->close();
|
||||
|
||||
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
|
||||
return [
|
||||
'xml' => $xml,
|
||||
'piantine' => $piantine,
|
||||
'formato' => $formatVersion,
|
||||
'staging' => $staging,
|
||||
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
|
||||
'files' => $extracted,
|
||||
'file_piantine' => $estrattiPiantine,
|
||||
];
|
||||
}
|
||||
|
||||
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
|
||||
private function prefissoDi(string $entryName, array $prefissi): ?string
|
||||
{
|
||||
foreach (array_keys($prefissi) as $prefisso) {
|
||||
if (str_starts_with($entryName, $prefisso)) {
|
||||
return $prefisso;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sostituisce il contenuto del disco `public` con quello estratto.
|
||||
* Mette i file estratti al posto di quelli in uso, tenendo da parte i
|
||||
* vecchi finche' il ripristino non e' confermato.
|
||||
*
|
||||
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
|
||||
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
||||
*
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
* Prima si svuotava il disco e poi si ricopiava dallo staging file per
|
||||
* file: un errore a meta' (disco pieno, permessi) lasciava il database
|
||||
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
|
||||
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
|
||||
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
|
||||
* di spazio, e ognuno annotato in un registro che annullaScambio()
|
||||
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
|
||||
* e si solleva: il chiamante annulla la transazione e l'installazione
|
||||
* resta com'era.
|
||||
*
|
||||
* Si spostano le voci e non la cartella radice: a quella del disco
|
||||
* `public` punta il symlink public/storage.
|
||||
*
|
||||
* @param array $estratto Il risultato di extract().
|
||||
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
|
||||
* Da passare a confermaScambio() a database confermato, o ad
|
||||
* annullaScambio() se qualcosa fallisce dopo.
|
||||
*/
|
||||
public function restoreFiles(string $staging, ?callable $onProgress = null): int
|
||||
public function scambiaFile(array $estratto): array
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
// Fuori da backup-restore/ di proposito: quella cartella la ripulisce
|
||||
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
|
||||
// stesso fallisse, resterebbero gli unici file buoni.
|
||||
$scambio = [
|
||||
'file' => $estratto['files'] + $estratto['file_piantine'],
|
||||
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
|
||||
'spostamenti' => [],
|
||||
];
|
||||
|
||||
$this->svuotaDiscoPubblico();
|
||||
try {
|
||||
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
|
||||
$this->preparaCartella($radice);
|
||||
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
|
||||
|
||||
if (! is_dir($staging)) {
|
||||
return 0;
|
||||
foreach ($this->vociDi($radice) as $voce) {
|
||||
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
|
||||
}
|
||||
|
||||
// Una sottocartella assente nello staging vuol dire "nessun
|
||||
// file": la radice resta vuota, come nell'installazione
|
||||
// salvata.
|
||||
$nuovi = $estratto['staging'] . '/' . $nome;
|
||||
foreach ($this->vociDi($nuovi) as $voce) {
|
||||
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->annullaScambio($scambio, $e);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$restored = 0;
|
||||
$totale = iterator_count(new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
));
|
||||
$onProgress && $onProgress(0, $totale);
|
||||
return $scambio;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
|
||||
public function confermaScambio(array $scambio): void
|
||||
{
|
||||
$this->deleteDirectory($scambio['accantonati']);
|
||||
}
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
/**
|
||||
* Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
|
||||
* spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
|
||||
* posto.
|
||||
*
|
||||
* Se anche questo fallisce non si cancella niente: i file precedenti
|
||||
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
|
||||
* si possono rimettere a mano invece di darli per persi.
|
||||
*/
|
||||
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
|
||||
{
|
||||
try {
|
||||
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
|
||||
if (! @rename($a, $da)) {
|
||||
throw new RuntimeException('impossibile rimettere "' . $da . '"');
|
||||
}
|
||||
}
|
||||
|
||||
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
|
||||
|
||||
$handle = fopen($file->getPathname(), 'rb');
|
||||
if ($handle === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->writeStream($relativePath, $handle);
|
||||
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
$restored++;
|
||||
$onProgress && $onProgress($restored, $totale);
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
|
||||
. 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
|
||||
. 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
|
||||
0,
|
||||
$causa ?? $e
|
||||
);
|
||||
}
|
||||
|
||||
return $restored;
|
||||
$this->deleteDirectory($scambio['accantonati']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
|
||||
* => cartella vera.
|
||||
*
|
||||
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
|
||||
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
|
||||
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
|
||||
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
|
||||
* riga lo cita, e' la mappa della citta'.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function radiciDaSostituire(int $formato): array
|
||||
{
|
||||
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
|
||||
|
||||
if ($formato >= 2) {
|
||||
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
|
||||
}
|
||||
|
||||
return $radici;
|
||||
}
|
||||
|
||||
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
|
||||
private function vociDi(string $cartella): array
|
||||
{
|
||||
if (! is_dir($cartella)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
|
||||
}
|
||||
|
||||
private function preparaCartella(string $cartella): void
|
||||
{
|
||||
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
||||
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
|
||||
}
|
||||
}
|
||||
|
||||
private function sposta(string $da, string $a, array &$scambio): void
|
||||
{
|
||||
if (! @rename($da, $a)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
|
||||
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
|
||||
basename($da)
|
||||
));
|
||||
}
|
||||
|
||||
$scambio['spostamenti'][] = [$da, $a];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,18 @@ class BackupOperationState
|
||||
public const TIPO_RESTORE = 'restore';
|
||||
public const TIPO_RESET = 'reset';
|
||||
|
||||
/**
|
||||
* Gli import non sono operazioni di backup, ma occupano lo stesso posto:
|
||||
* l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file
|
||||
* e righe dei territori. Uno dei due in parallelo a un ripristino (o a un
|
||||
* backup che sta copiando quegli stessi file) produce uno stato che nessuno
|
||||
* ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha
|
||||
* TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo
|
||||
* l'occupazione del posto.
|
||||
*/
|
||||
public const TIPO_IMPORT_PDF = 'import-pdf';
|
||||
public const TIPO_IMPORT_XML = 'import-xml';
|
||||
|
||||
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
|
||||
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
||||
|
||||
@@ -48,6 +61,16 @@ class BackupOperationState
|
||||
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
||||
private const TTL_CORRENTE = 3600;
|
||||
|
||||
/**
|
||||
* Mutex attorno a "il posto e' libero? allora lo prendo".
|
||||
*
|
||||
* corrente() seguito da initialize() sono due passi: due richieste che
|
||||
* arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono
|
||||
* — la seconda sovrascrive il puntatore della prima, e i due lavori girano
|
||||
* in parallelo proprio come il posto unico doveva impedire.
|
||||
*/
|
||||
private const CHIAVE_AVVIO = 'backup-op:avvio';
|
||||
|
||||
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
||||
{
|
||||
$state = [
|
||||
@@ -99,6 +122,66 @@ class BackupOperationState
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prende il posto unico se e' libero, in un passo solo.
|
||||
*
|
||||
* Restituisce null se il posto e' stato preso (e l'operazione inizializzata
|
||||
* come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa.
|
||||
* Tutte le porte d'ingresso passano da qui proprio per il mutex: un
|
||||
* controllo con corrente() e un initialize() separati lasciano aperta la
|
||||
* finestra fra i due.
|
||||
*
|
||||
* $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a
|
||||
* meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il
|
||||
* posto resterebbe occupato per l'ora intera del TTL ordinario.
|
||||
*/
|
||||
public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string
|
||||
{
|
||||
try {
|
||||
return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) {
|
||||
$inCorso = $this->corrente();
|
||||
|
||||
if ($inCorso !== null) {
|
||||
return $inCorso;
|
||||
}
|
||||
|
||||
$this->initialize($id, $tipo, $fase);
|
||||
|
||||
if ($ttlPosto !== null) {
|
||||
Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
// Qualcun altro sta prendendo il posto proprio adesso: il mutex
|
||||
// copre pochi millisecondi, quindi cinque secondi d'attesa vogliono
|
||||
// dire che il posto non e' disponibile, non che va forzato.
|
||||
return $this->corrente() ?? 'avvio-in-corso';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo
|
||||
* blocca, perche' l'operazione in corso puo' essere stata avviata da un
|
||||
* altro amministratore in un'altra zona della pagina.
|
||||
*/
|
||||
public function messaggioOccupato(string $idInCorso): string
|
||||
{
|
||||
$tipo = $this->get($idInCorso)['tipo'] ?? null;
|
||||
|
||||
$cosa = match ($tipo) {
|
||||
self::TIPO_BACKUP => ' (un backup)',
|
||||
self::TIPO_RESTORE => ' (un ripristino)',
|
||||
self::TIPO_RESET => ' (un azzeramento)',
|
||||
self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)',
|
||||
self::TIPO_IMPORT_XML => ' (un import XML)',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.';
|
||||
}
|
||||
|
||||
public function get(string $id): ?array
|
||||
{
|
||||
return Cache::get($this->key($id));
|
||||
|
||||
@@ -257,8 +257,11 @@ class PiantinaPdfService
|
||||
|
||||
/**
|
||||
* Sposta il PDF attuale del territorio (se esiste) nello storico.
|
||||
*
|
||||
* Pubblica perché la usa anche l'import dei PDF da cartella: chi
|
||||
* sostituisce un PDF lo archivia sempre allo stesso modo.
|
||||
*/
|
||||
private function archiviaCorrente(Territorio $territorio, string $origine): void
|
||||
public function archiviaCorrente(Territorio $territorio, string $origine): void
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ use RuntimeException;
|
||||
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
||||
* restano al loro posto.
|
||||
*
|
||||
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
|
||||
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
|
||||
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
|
||||
* KML originale — che quindi conviene conservare fuori di qui.
|
||||
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
|
||||
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
|
||||
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
|
||||
* contiene, e ripristinandolo l'area in uso resta com'e'.
|
||||
*/
|
||||
class PiantineArea
|
||||
{
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
|
||||
|
||||
public function __construct(
|
||||
protected TerritorioPdfImportState $stateService,
|
||||
protected BackupOperationState $postoUnico,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
|
||||
/**
|
||||
* Prende il posto unico per questo import, o solleva spiegando chi lo occupa.
|
||||
*
|
||||
* L'import PDF sostituisce file e righe dei territori: in parallelo a un
|
||||
* ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante
|
||||
* un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o
|
||||
* fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto
|
||||
* si libera nel job, a fine lavoro o in failed().
|
||||
*/
|
||||
public function occupaPosto(string $importId): void
|
||||
{
|
||||
$inCorso = $this->postoUnico->occupa($importId, BackupOperationState::TIPO_IMPORT_PDF, 'In coda');
|
||||
|
||||
if ($inCorso !== null) {
|
||||
throw new RuntimeException($this->postoUnico->messaggioOccupato($inCorso));
|
||||
}
|
||||
}
|
||||
|
||||
/** Libera il posto preso da un import che non e' arrivato alla coda. */
|
||||
public function liberaPosto(string $importId, string $motivo): void
|
||||
{
|
||||
$this->postoUnico->markFailed($importId, $motivo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vero per i file che non vanno nemmeno considerati: quelli che non sono
|
||||
* PDF e quelli di sistema o nascosti.
|
||||
*
|
||||
* Una cartella vera non contiene solo PDF: Windows ci lascia Thumbs.db e
|
||||
* desktop.ini, macOS .DS_Store e — dentro gli ZIP — una copia "._nome.pdf"
|
||||
* di ogni file sotto __MACOSX/. Prima uno solo di questi faceva fallire la
|
||||
* validazione dell'intero caricamento; peggio, "._1.pdf" ha l'estensione
|
||||
* giusta e sarebbe finito come mappa del territorio 1.
|
||||
*/
|
||||
public static function daIgnorare(string $percorso): bool
|
||||
{
|
||||
$segmenti = array_values(array_filter(preg_split('#[/\\\\]+#', $percorso) ?: [], fn ($s) => $s !== ''));
|
||||
|
||||
if ($segmenti === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($segmenti as $segmento) {
|
||||
if (str_starts_with($segmento, '.') || strcasecmp($segmento, '__MACOSX') === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// "~$" e' il prefisso dei file temporanei di Office.
|
||||
$nome = end($segmenti);
|
||||
|
||||
return str_starts_with($nome, '~$')
|
||||
|| strtolower(pathinfo($nome, PATHINFO_EXTENSION)) !== 'pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mette in coda l'import. Il posto unico deve essere gia' stato preso con
|
||||
* occupaPosto(): se l'invio alla coda fallisce lo si libera qui.
|
||||
*
|
||||
* @param array<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
|
||||
* @param array<int, array{file: string, message: string}> $scartati PDF rifiutati dalla validazione
|
||||
*/
|
||||
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog, array $ignorati = [], array $scartati = []): array
|
||||
{
|
||||
$state = $this->stateService->initialize($importId, count($storedFiles));
|
||||
$this->stateService->appendLog($importId, $initialLog);
|
||||
|
||||
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
||||
if ($ignorati !== []) {
|
||||
$elenco = array_slice($ignorati, 0, 10);
|
||||
$altri = count($ignorati) - count($elenco);
|
||||
$this->stateService->appendLog(
|
||||
$importId,
|
||||
'Ignorati ' . count($ignorati) . ' file che non sono PDF da importare (file di sistema, nascosti o di altro tipo): '
|
||||
. implode(', ', $elenco) . ($altri > 0 ? ' e altri ' . $altri : '') . '.'
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($scartati as $scartato) {
|
||||
$this->stateService->appendLog($importId, '[SKIP] ' . $scartato['file'] . ' - ' . $scartato['message']);
|
||||
$this->stateService->addIssue($importId, [
|
||||
'file' => $scartato['file'],
|
||||
'type' => 'invalid-file',
|
||||
'message' => $scartato['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
||||
} catch (\Throwable $exception) {
|
||||
// Il job non partira' mai, quindi non sara' lui a liberare il posto.
|
||||
$this->stateService->markFailed($importId);
|
||||
$this->liberaPosto($importId, 'Import PDF non messo in coda: ' . $exception->getMessage());
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $this->stateService->get($importId) ?? $state;
|
||||
}
|
||||
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
|
||||
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
|
||||
{
|
||||
$importId = (string) Str::uuid();
|
||||
|
||||
// Il posto si prende prima di salvare ed estrarre: e' la parte lenta, e
|
||||
// farla per poi sentirsi rispondere "c'e' un ripristino in corso" sarebbe
|
||||
// lavoro buttato.
|
||||
$this->occupaPosto($importId);
|
||||
|
||||
try {
|
||||
return $this->estraiEAccoda($zipFile, $importId, $actorId);
|
||||
} catch (\Throwable $exception) {
|
||||
// Se il job e' partito il posto e' suo e lo libera lui; altrimenti
|
||||
// resterebbe occupato da un import che non esiste.
|
||||
if ($this->stateService->get($importId) === null) {
|
||||
$this->liberaPosto($importId, 'Import PDF non avviato: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function estraiEAccoda(UploadedFile $zipFile, string $importId, ?int $actorId): string
|
||||
{
|
||||
$zipStoredPath = $zipFile->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
'archivio-' . $importId . '.zip',
|
||||
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipAbsolutePath) !== true) {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw new RuntimeException('Impossibile aprire il file ZIP.');
|
||||
}
|
||||
|
||||
$storedFiles = [];
|
||||
$ignorati = [];
|
||||
$entryIndex = 0;
|
||||
|
||||
try {
|
||||
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
if (self::daIgnorare($entryName)) {
|
||||
$ignorati[] = basename($entryName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
|
||||
}
|
||||
|
||||
if ($storedFiles === []) {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
|
||||
}
|
||||
|
||||
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
|
||||
$importId,
|
||||
$storedFiles,
|
||||
$actorId,
|
||||
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
|
||||
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
|
||||
$ignorati
|
||||
);
|
||||
|
||||
return $importId;
|
||||
|
||||
Reference in New Issue
Block a user