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>
This commit is contained in:
234
app/Services/Piantine/PubblicazionePiantina.php
Normal file
234
app/Services/Piantine/PubblicazionePiantina.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user