- Lo stato "Pubblicata" non dipende più dall'id del territorio, che il ripristino ricrea: l'impronta usa il numero, e la piantina ricorda il percorso del PDF pubblicato (pdf_pubblicato, con migrazione che riconosce le piantine già pubblicate). - BackupOperationState: corrente() in sola lettura e liberaCorrente() sotto lo stesso mutex di occupa(), così una lettura non cancella il puntatore di un'operazione appena avviata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
8.1 KiB
PHP
238 lines
8.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Piantine;
|
|
|
|
use App\Jobs\PreparaAnteprimaPiantina;
|
|
use App\Models\TerritorioMappa;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Pubblicazione di una piantina: dalla bozza al PDF ufficiale del territorio.
|
|
*
|
|
* Tre passi, perché il PDF pubblicato viene stampato e consegnato e va visto
|
|
* prima di sostituire quello in vigore:
|
|
*
|
|
* 1. avvia() — mette in coda la preparazione dell'anteprima. In coda e
|
|
* non nella richiesta: il primo rendering di un territorio
|
|
* scarica centinaia di tessere e può superare i 100 secondi
|
|
* dopo i quali Cloudflare chiude la connessione.
|
|
* 2. prepara() — (dal job) genera immagine e PDF in una cartella privata,
|
|
* senza toccare né la piantina né il territorio.
|
|
* 3. conferma() — rende ufficiali i file dell'anteprima. Se nel frattempo la
|
|
* piantina o i dati stampati sono cambiati, rifiuta: si
|
|
* pubblicherebbe qualcosa di diverso da quello che si è visto.
|
|
*
|
|
* Lo stato di ogni anteprima vive in cache sotto un token casuale; i file
|
|
* stanno sul disco locale, mai su quello pubblico servito da nginx.
|
|
*/
|
|
class PubblicazionePiantina
|
|
{
|
|
public const IN_CORSO = 'in_corso';
|
|
|
|
public const PRONTA = 'pronta';
|
|
|
|
public const ERRORE = 'errore';
|
|
|
|
/** Quanto resta valida un'anteprima non confermata. */
|
|
private const DURATA_SECONDI = 7200;
|
|
|
|
private const CARTELLA = 'piantine-anteprime';
|
|
|
|
public function __construct(
|
|
private PiantinaRenderer $renderer,
|
|
private PiantinaPdfService $pdf,
|
|
) {
|
|
}
|
|
|
|
/** Mette in coda l'anteprima e ne restituisce il token. */
|
|
public function avvia(TerritorioMappa $mappa): string
|
|
{
|
|
$mappa->loadMissing('territorio.zona');
|
|
|
|
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
|
throw new RuntimeException($motivo);
|
|
}
|
|
|
|
$this->pulisciVecchie();
|
|
|
|
$token = Str::random(40);
|
|
$this->scrivi($token, ['stato' => self::IN_CORSO, 'mappa_id' => $mappa->id]);
|
|
|
|
PreparaAnteprimaPiantina::dispatch($token);
|
|
|
|
return $token;
|
|
}
|
|
|
|
/** Lo stato dell'anteprima, o null se il token non esiste o è scaduto. */
|
|
public function stato(string $token): ?array
|
|
{
|
|
return Cache::get($this->chiave($token));
|
|
}
|
|
|
|
/** Il lavoro vero, eseguito dal job. */
|
|
public function prepara(string $token): void
|
|
{
|
|
$stato = $this->stato($token);
|
|
if (! $stato || $stato['stato'] !== self::IN_CORSO) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$mappa = TerritorioMappa::with('territorio.zona')->find($stato['mappa_id']);
|
|
if (! $mappa) {
|
|
throw new RuntimeException('La piantina non esiste più.');
|
|
}
|
|
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
|
throw new RuntimeException($motivo);
|
|
}
|
|
|
|
// L'impronta si prende adesso, sullo stesso stato da cui escono
|
|
// immagine e PDF: è quello che la conferma dovrà ritrovare.
|
|
$impronta = $mappa->impronta();
|
|
|
|
[$immagine, $estensione] = $this->renderer->immagine($mappa->geometria());
|
|
|
|
$disco = Storage::disk('local');
|
|
$percorsoImmagine = self::CARTELLA . "/{$token}.{$estensione}";
|
|
$percorsoPdf = self::CARTELLA . "/{$token}.pdf";
|
|
|
|
if (! $disco->put($percorsoImmagine, $immagine)) {
|
|
throw new RuntimeException('Non è stato possibile salvare l\'immagine dell\'anteprima.');
|
|
}
|
|
|
|
$binario = $this->pdf->genera($mappa->territorio, $mappa, $disco->path($percorsoImmagine));
|
|
|
|
if (! $disco->put($percorsoPdf, $binario)) {
|
|
throw new RuntimeException('Non è stato possibile salvare il PDF dell\'anteprima.');
|
|
}
|
|
|
|
$this->scrivi($token, $stato + [
|
|
'impronta' => $impronta,
|
|
'immagine' => $percorsoImmagine,
|
|
'estensione' => $estensione,
|
|
'pdf' => $percorsoPdf,
|
|
], self::PRONTA);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
$this->segnaErrore($token, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function segnaErrore(string $token, string $messaggio): void
|
|
{
|
|
$stato = $this->stato($token);
|
|
if (! $stato) {
|
|
return;
|
|
}
|
|
|
|
$this->scrivi($token, $stato + ['messaggio' => $messaggio], self::ERRORE);
|
|
}
|
|
|
|
/** Percorso assoluto del PDF di un'anteprima pronta, o null. */
|
|
public function pdfAnteprima(string $token): ?string
|
|
{
|
|
$stato = $this->stato($token);
|
|
|
|
if (($stato['stato'] ?? null) !== self::PRONTA || ! Storage::disk('local')->exists($stato['pdf'])) {
|
|
return null;
|
|
}
|
|
|
|
return Storage::disk('local')->path($stato['pdf']);
|
|
}
|
|
|
|
/**
|
|
* Rende ufficiale l'anteprima: PDF del territorio sostituito (il
|
|
* precedente va nello storico), immagine della piantina aggiornata, bozza
|
|
* segnata come pubblicata.
|
|
*/
|
|
public function conferma(string $token): TerritorioMappa
|
|
{
|
|
$stato = $this->stato($token);
|
|
|
|
if (($stato['stato'] ?? null) !== self::PRONTA) {
|
|
throw new RuntimeException('L\'anteprima non è più disponibile: generala di nuovo.');
|
|
}
|
|
|
|
$mappa = TerritorioMappa::with('territorio.zona')->find($stato['mappa_id']);
|
|
if (! $mappa || ! $mappa->collegata()) {
|
|
throw new RuntimeException('La piantina non è più collegata a un territorio: non si può pubblicare.');
|
|
}
|
|
|
|
if ($mappa->impronta() !== $stato['impronta']) {
|
|
throw new RuntimeException('La piantina o i dati del territorio sono cambiati dopo l\'anteprima: rigenerala per pubblicare la versione attuale.');
|
|
}
|
|
|
|
$disco = Storage::disk('local');
|
|
if (! $disco->exists($stato['pdf']) || ! $disco->exists($stato['immagine'])) {
|
|
throw new RuntimeException('I file dell\'anteprima non ci sono più: generala di nuovo.');
|
|
}
|
|
|
|
$this->pdf->sostituisciConPdf($mappa->territorio, $disco->get($stato['pdf']), 'piantine');
|
|
$this->renderer->archivia($mappa, $disco->get($stato['immagine']), $stato['estensione']);
|
|
|
|
$mappa->update([
|
|
'geojson_pubblicato' => $mappa->geojson,
|
|
'impronta_pubblicata' => $stato['impronta'],
|
|
// Il PDF appena messo in vigore: finché il territorio usa questo
|
|
// file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()).
|
|
'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path,
|
|
'pubblicata_at' => now(),
|
|
]);
|
|
|
|
activity()
|
|
->causedBy(auth()->user())
|
|
->performedOn($mappa->territorio)
|
|
->log('piantina_pubblicata');
|
|
|
|
$this->annulla($token);
|
|
|
|
return $mappa->fresh('territorio');
|
|
}
|
|
|
|
/** Butta l'anteprima: file e stato. */
|
|
public function annulla(string $token): void
|
|
{
|
|
$stato = $this->stato($token);
|
|
|
|
if ($stato) {
|
|
Storage::disk('local')->delete(array_filter([$stato['immagine'] ?? null, $stato['pdf'] ?? null]));
|
|
}
|
|
|
|
Cache::forget($this->chiave($token));
|
|
}
|
|
|
|
private function scrivi(string $token, array $dati, ?string $nuovoStato = null): void
|
|
{
|
|
if ($nuovoStato !== null) {
|
|
$dati['stato'] = $nuovoStato;
|
|
}
|
|
|
|
Cache::put($this->chiave($token), $dati, self::DURATA_SECONDI);
|
|
}
|
|
|
|
private function chiave(string $token): string
|
|
{
|
|
return 'piantine:anteprima:' . $token;
|
|
}
|
|
|
|
/**
|
|
* Le anteprime aperte e mai confermate né annullate (finestra chiusa)
|
|
* lascerebbero file per sempre: chi ne avvia una nuova pulisce quelle
|
|
* scadute.
|
|
*/
|
|
private function pulisciVecchie(): void
|
|
{
|
|
$disco = Storage::disk('local');
|
|
$limite = now()->subSeconds(self::DURATA_SECONDI)->getTimestamp();
|
|
|
|
foreach ($disco->files(self::CARTELLA) as $file) {
|
|
if ($disco->lastModified($file) < $limite) {
|
|
$disco->delete($file);
|
|
}
|
|
}
|
|
}
|
|
}
|