Files
termanager2/app/Services/Piantine/PubblicazionePiantina.php
Francesco Picone 056d95cfcb Editor piantine: bozza automatica e pubblicazione con anteprima
- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:08:34 +02:00

235 lines
7.9 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'],
'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);
}
}
}
}