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:
@@ -36,15 +36,24 @@ class PiantinaPdfService
|
||||
) {
|
||||
}
|
||||
|
||||
/** Il PDF completo, come stringa binaria. */
|
||||
public function genera(Territorio $territorio, TerritorioMappa $mappa): string
|
||||
/**
|
||||
* Il PDF completo, come stringa binaria.
|
||||
*
|
||||
* $immagine è il percorso assoluto di un'immagine diversa da quella
|
||||
* salvata nella piantina: l'anteprima della pubblicazione compone il PDF
|
||||
* con l'immagine appena generata, senza averla ancora resa ufficiale.
|
||||
*/
|
||||
public function genera(Territorio $territorio, TerritorioMappa $mappa, ?string $immagine = null): string
|
||||
{
|
||||
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
|
||||
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
|
||||
if ($immagine === null) {
|
||||
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
|
||||
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
|
||||
}
|
||||
|
||||
$immagine = Storage::disk('public')->path($mappa->image_path);
|
||||
}
|
||||
|
||||
$immagine = Storage::disk('public')->path($mappa->image_path);
|
||||
$dimensioni = getimagesize($immagine);
|
||||
$dimensioni = @getimagesize($immagine);
|
||||
if ($dimensioni === false) {
|
||||
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
|
||||
}
|
||||
@@ -92,7 +101,7 @@ class PiantinaPdfService
|
||||
($h - $imgH) / 2,
|
||||
$imgW,
|
||||
$imgH,
|
||||
$this->tipoImmagine($mappa->image_path)
|
||||
$this->tipoImmagine($immagine)
|
||||
);
|
||||
|
||||
return $pdf->Output('S');
|
||||
@@ -179,13 +188,26 @@ class PiantinaPdfService
|
||||
*/
|
||||
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
|
||||
{
|
||||
$binario = $this->genera($territorio, $mappa);
|
||||
$this->sostituisciConPdf($territorio, $this->genera($territorio, $mappa), $origine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rende ufficiale un PDF già composto, archiviando il precedente.
|
||||
*
|
||||
* Prima si scrive il nuovo file, poi si archivia il vecchio: nell'ordine
|
||||
* inverso un errore di scrittura (disco pieno) lasciava pdf_path puntato
|
||||
* a un file già spostato nello storico, e i link delle assegnazioni in 404.
|
||||
*/
|
||||
public function sostituisciConPdf(Territorio $territorio, string $binario, string $origine = 'piantine'): void
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
$this->archiviaCorrente($territorio, $origine);
|
||||
|
||||
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
|
||||
$disk->put($nuovoPath, $binario);
|
||||
if (! $disk->put($nuovoPath, $binario)) {
|
||||
throw new RuntimeException('Non è stato possibile scrivere il nuovo PDF sul disco: il PDF in vigore non è stato toccato.');
|
||||
}
|
||||
|
||||
$this->archiviaCorrente($territorio, $origine);
|
||||
|
||||
$territorio->update([
|
||||
'pdf_path' => $nuovoPath,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Piantine;
|
||||
|
||||
use App\Models\TerritorioMappa;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
@@ -14,6 +15,11 @@ use RuntimeException;
|
||||
*/
|
||||
class PiantinaRenderer
|
||||
{
|
||||
/**
|
||||
* Rendering e archiviazione come immagine della piantina.
|
||||
*
|
||||
* @return string il percorso sul disco pubblico
|
||||
*/
|
||||
public function render(TerritorioMappa $mappa): string
|
||||
{
|
||||
$geometry = $mappa->geometria();
|
||||
@@ -22,19 +28,38 @@ class PiantinaRenderer
|
||||
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
||||
}
|
||||
|
||||
[$immagine, $estensione] = $this->immagine($geometry);
|
||||
|
||||
return $this->archivia($mappa, $immagine, $estensione);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo il rendering, senza toccare niente: serve all'anteprima della
|
||||
* pubblicazione, che non deve cambiare la piantina finché non si conferma.
|
||||
*
|
||||
* @return array{0: string, 1: string} contenuto e estensione ('jpg'|'png')
|
||||
*/
|
||||
public function immagine(array $geometry): array
|
||||
{
|
||||
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
|
||||
|
||||
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
||||
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
||||
'geometry' => $geometry,
|
||||
'marginRatio' => (float) config('piantine.margin_ratio'),
|
||||
'maxSide' => (int) config('piantine.max_side'),
|
||||
'aspect' => (float) config('piantine.aspetto'),
|
||||
'strokeWidth' => (float) config('piantine.tratto'),
|
||||
'strokeOpacity' => (float) config('piantine.tratto_opacita'),
|
||||
'format' => $formato,
|
||||
'quality' => (int) config('piantine.qualita'),
|
||||
]);
|
||||
try {
|
||||
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
||||
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
||||
'geometry' => $geometry,
|
||||
'marginRatio' => (float) config('piantine.margin_ratio'),
|
||||
'maxSide' => (int) config('piantine.max_side'),
|
||||
'aspect' => (float) config('piantine.aspetto'),
|
||||
'strokeWidth' => (float) config('piantine.tratto'),
|
||||
'strokeOpacity' => (float) config('piantine.tratto_opacita'),
|
||||
'format' => $formato,
|
||||
'quality' => (int) config('piantine.qualita'),
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
// Container fermo, nome non risolto o tempo scaduto: il messaggio
|
||||
// di cURL in inglese non dice niente a chi sta pubblicando.
|
||||
throw new RuntimeException('Il servizio che disegna le mappe non risponde (container map-renderer). Riprova fra qualche minuto; se continua, va controllato il server.', 0, $e);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
|
||||
@@ -47,15 +72,28 @@ class PiantinaRenderer
|
||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
|
||||
}
|
||||
|
||||
// Il vecchio file puo' avere un'estensione diversa da quella nuova (si
|
||||
// e' passati da PNG a JPEG): senza questa rimozione resterebbe sul
|
||||
// volume per sempre, senza che niente lo referenzi piu'.
|
||||
if ($mappa->image_path && $mappa->image_path !== $this->percorso($mappa, $formato)) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva l'immagine come quella della piantina, rimuovendo la precedente.
|
||||
*
|
||||
* Il nome cambia a ogni rendering: nginx e Cloudflare tengono in cache i
|
||||
* file di /storage per giorni, e con un nome fisso il link mostrava
|
||||
* l'immagine vecchia anche dopo averla rigenerata.
|
||||
*/
|
||||
public function archivia(TerritorioMappa $mappa, string $immagine, string $estensione): string
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
$path = 'territori-mappe/piantina-' . $mappa->id . '-' . now()->format('YmdHis') . '.' . $estensione;
|
||||
|
||||
if (! $disco->put($path, $immagine)) {
|
||||
throw new RuntimeException('Non è stato possibile salvare l\'immagine della piantina sul disco.');
|
||||
}
|
||||
|
||||
$path = $this->percorso($mappa, $formato);
|
||||
Storage::disk('public')->put($path, $immagine);
|
||||
if ($mappa->image_path && $mappa->image_path !== $path) {
|
||||
$disco->delete($mappa->image_path);
|
||||
}
|
||||
|
||||
$mappa->update([
|
||||
'image_path' => $path,
|
||||
@@ -65,11 +103,6 @@ class PiantinaRenderer
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function percorso(TerritorioMappa $mappa, string $formato): string
|
||||
{
|
||||
return 'territori-mappe/piantina-' . $mappa->id . '.' . ($formato === 'png' ? 'png' : 'jpg');
|
||||
}
|
||||
|
||||
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
|
||||
private function immagineValida(string $dati, string $formato): bool
|
||||
{
|
||||
|
||||
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