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:
@@ -3,37 +3,30 @@
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PubblicazionePiantina;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Anteprima della cartolina, senza sostituire niente.
|
||||
* Il PDF dell'anteprima di una pubblicazione, da guardare prima di confermare.
|
||||
*
|
||||
* Il PDF viene composto al volo e servito da leggere: non tocca `pdf_path` del
|
||||
* territorio, non archivia versioni, non lascia file sul disco. Serve a
|
||||
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione
|
||||
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un
|
||||
* confine sbagliato quando è già nello storico.
|
||||
* Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
|
||||
* soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
|
||||
* e scade con l'anteprima; la rotta resta comunque riservata
|
||||
* all'amministratore come il resto delle piantine.
|
||||
*/
|
||||
class PiantinaAnteprimaController extends Controller
|
||||
{
|
||||
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response
|
||||
public function __invoke(string $token, PubblicazionePiantina $pubblicazione): Response
|
||||
{
|
||||
$mappa->load('territorio.zona');
|
||||
$percorso = $pubblicazione->pdfAnteprima($token);
|
||||
|
||||
abort_unless($mappa->collegata(), 404);
|
||||
abort_if($percorso === null, 404);
|
||||
|
||||
$pdf = $pdfService->genera($mappa->territorio, $mappa);
|
||||
|
||||
return response($pdf, 200, [
|
||||
return response()->file($percorso, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
// `inline`: si apre nel visualizzatore del browser invece di
|
||||
// finire fra i download, che di un'anteprima non se ne fa niente.
|
||||
'Content-Disposition' => 'inline; filename="anteprima-territorio-'
|
||||
. $mappa->territorio->numero . '.pdf"',
|
||||
// Un'anteprima rispecchia lo stato di adesso: se la cache la
|
||||
// congela, si continua a guardare il confine di prima.
|
||||
'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
44
app/Jobs/PreparaAnteprimaPiantina.php
Normal file
44
app/Jobs/PreparaAnteprimaPiantina.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Piantine\PubblicazionePiantina;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Prepara l'anteprima di una pubblicazione (immagine HD + PDF) fuori dalla
|
||||
* richiesta web. Non modifica niente: lo fa solo la conferma.
|
||||
*/
|
||||
class PreparaAnteprimaPiantina implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/** Ripetere non serve: un errore va mostrato, e si riprova dal pulsante. */
|
||||
public int $tries = 1;
|
||||
|
||||
/** Il rendering più il margine per comporre il PDF. */
|
||||
public int $timeout;
|
||||
|
||||
public function __construct(public string $token)
|
||||
{
|
||||
$this->timeout = (int) config('piantine.renderer_timeout') + 60;
|
||||
}
|
||||
|
||||
public function handle(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
$pubblicazione->prepara($this->token);
|
||||
}
|
||||
|
||||
/** Job ucciso (timeout, worker fermato): l'anteprima non resta "in corso" per sempre. */
|
||||
public function failed(?\Throwable $e): void
|
||||
{
|
||||
app(PubblicazionePiantina::class)->segnaErrore(
|
||||
$this->token,
|
||||
'La preparazione dell\'anteprima si è interrotta. Riprova.'
|
||||
);
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Concerns/Avvisa.php
Normal file
25
app/Livewire/Concerns/Avvisa.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
/**
|
||||
* Avvisi a comparsa che funzionano dopo un'azione Livewire.
|
||||
*
|
||||
* session()->flash() non basta: il messaggio sta nel telaio della pagina, e
|
||||
* dopo un'azione Livewire ridisegna solo il componente. L'avviso compariva
|
||||
* solo al caricamento completo successivo, fuori contesto, e un salvataggio
|
||||
* rifiutato sembrava riuscito. Qui l'avviso parte come evento del browser e
|
||||
* lo raccoglie <x-avvisi> nel telaio, che resta sempre in pagina.
|
||||
*/
|
||||
trait Avvisa
|
||||
{
|
||||
protected function avvisa(string $testo, string $tipo = 'ok'): void
|
||||
{
|
||||
$this->dispatch('avviso', tipo: $tipo === 'errore' ? 'errore' : 'ok', testo: $testo);
|
||||
}
|
||||
|
||||
protected function avvisaErrore(string $testo): void
|
||||
{
|
||||
$this->avvisa($testo, 'errore');
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,54 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\Zona;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Editor delle piantine a schermo intero (solo amministratore).
|
||||
*
|
||||
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
|
||||
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
|
||||
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
|
||||
* modulo che poi gira di mano in mano.
|
||||
* Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
|
||||
* disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
|
||||
* La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
|
||||
* (anteprima e conferma, nel componente Pubblicazione). I dati del territorio
|
||||
* — numero, zona, tipologia, confini — sono invece dati del gestionale e si
|
||||
* salvano subito, come dalla scheda territorio.
|
||||
*
|
||||
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e
|
||||
* solo dopo si compila la scheda del territorio — che può anche non esistere
|
||||
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
|
||||
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
|
||||
* Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
|
||||
* aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
|
||||
* disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
|
||||
*
|
||||
* Con la mappa si parla per eventi, e solo con il necessario: i dati di tutte
|
||||
* le piantine li chiede la mappa una volta (datiMappa), poi riceve solo la
|
||||
* piantina cambiata. Prima ogni richiesta rispediva tutte le geometrie e
|
||||
* l'area KML, fino a una decina di MB per aprire un pannello.
|
||||
*/
|
||||
#[Layout('components.layouts.editor')]
|
||||
class PiantineEditor extends Component
|
||||
{
|
||||
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
|
||||
use Avvisa;
|
||||
|
||||
/** Piantina aperta, o null quando non ce n'è nessuna selezionata. */
|
||||
#[Locked]
|
||||
public ?int $mappaId = null;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
/** Geometria appena disegnata, non ancora salvata. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
/** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
|
||||
public string $salvatoAlle = '';
|
||||
|
||||
public bool $pannelloAperto = false;
|
||||
|
||||
// ─── Scheda del territorio ──────────────────────────────────────────────
|
||||
// ─── Territorio ─────────────────────────────────────────────────────────
|
||||
|
||||
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
||||
public string $territorioScelto = '';
|
||||
@@ -53,7 +62,6 @@ class PiantineEditor extends Component
|
||||
|
||||
public string $confini = '';
|
||||
|
||||
/** Campi di creazione al volo, aperti solo quando servono. */
|
||||
public string $nuovaZona = '';
|
||||
|
||||
public string $nuovaTipologia = '';
|
||||
@@ -61,7 +69,7 @@ class PiantineEditor extends Component
|
||||
public function mount(?int $mappa = null): void
|
||||
{
|
||||
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
||||
$this->apri($mappa);
|
||||
$this->carica(TerritorioMappa::with('territorio')->find($mappa));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,38 +81,46 @@ class PiantineEditor extends Component
|
||||
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
||||
|
||||
if (! $mappa) {
|
||||
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
|
||||
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->caricaScheda($mappa->territorio);
|
||||
$this->aggiornaMappaJs(inquadra: true);
|
||||
$this->carica($mappa);
|
||||
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
|
||||
}
|
||||
|
||||
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
|
||||
public function nuova(): void
|
||||
{
|
||||
$this->svuota();
|
||||
$this->dispatch('piantina-selezionata', mappaId: null, inquadra: false);
|
||||
$this->dispatch('editor-disegna');
|
||||
}
|
||||
|
||||
private function carica(TerritorioMappa $mappa): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->salvatoAlle = '';
|
||||
$this->nuovaZona = '';
|
||||
$this->nuovaTipologia = '';
|
||||
$this->caricaTerritorio($mappa->territorio);
|
||||
}
|
||||
|
||||
private function svuota(): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
$this->reset([
|
||||
'mappaId', 'nome', 'geometriaInAttesa',
|
||||
'mappaId', 'nome', 'salvatoAlle',
|
||||
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
||||
'nuovaZona', 'nuovaTipologia',
|
||||
]);
|
||||
$this->resetValidation();
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function chiudiPannello(): void
|
||||
{
|
||||
$this->pannelloAperto = false;
|
||||
}
|
||||
|
||||
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
|
||||
private function caricaScheda(?Territorio $territorio): void
|
||||
private function caricaTerritorio(?Territorio $territorio): void
|
||||
{
|
||||
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
|
||||
$this->numero = (string) ($territorio->numero ?? '');
|
||||
@@ -113,12 +129,118 @@ class PiantineEditor extends Component
|
||||
$this->confini = (string) ($territorio->confini ?? '');
|
||||
}
|
||||
|
||||
/** Cambiare territorio nella tendina ricarica la sua scheda. */
|
||||
// ─── Confine (bozza, salvataggio automatico) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Arriva dalla mappa dopo un disegno o lo spostamento di un vertice.
|
||||
* $nuova: il disegno è una piantina nuova (strumento "Disegna"), non il
|
||||
* confine ridisegnato di quella aperta.
|
||||
*/
|
||||
#[On('geometria-modificata')]
|
||||
public function salvaGeometria($geometry, bool $nuova = false): void
|
||||
{
|
||||
if (! $this->geometriaValida($geometry)) {
|
||||
$this->avvisaErrore('Il confine disegnato non è valido: ridisegnalo.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$geojson = json_encode($geometry);
|
||||
$mappa = (! $nuova && $this->mappaId) ? TerritorioMappa::find($this->mappaId) : null;
|
||||
|
||||
if ($mappa) {
|
||||
$mappa->update(['geojson' => $geojson]);
|
||||
} else {
|
||||
$mappa = TerritorioMappa::create(['geojson' => $geojson]);
|
||||
$this->carica($mappa);
|
||||
activity()->causedBy(auth()->user())->log('piantina_creata');
|
||||
}
|
||||
|
||||
$this->salvato($mappa, seleziona: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anelli chiusi di almeno quattro punti con coordinate nei limiti: un
|
||||
* record malformato faceva fallire il disegno di tutte le piantine.
|
||||
*/
|
||||
private function geometriaValida($geometry): bool
|
||||
{
|
||||
if (! is_array($geometry) || ! is_array($geometry['coordinates'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$poligoni = match ($geometry['type'] ?? null) {
|
||||
'Polygon' => [$geometry['coordinates']],
|
||||
'MultiPolygon' => $geometry['coordinates'],
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $poligoni) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($poligoni as $anelli) {
|
||||
if (! is_array($anelli) || $anelli === []) {
|
||||
return false;
|
||||
}
|
||||
foreach ($anelli as $anello) {
|
||||
if (! is_array($anello) || count($anello) < 4) {
|
||||
return false;
|
||||
}
|
||||
foreach ($anello as $punto) {
|
||||
if (! is_array($punto) || count($punto) < 2
|
||||
|| ! is_numeric($punto[0]) || ! is_numeric($punto[1])
|
||||
|| abs($punto[0]) > 180 || abs($punto[1]) > 90) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Riporta il confine a quello pubblicato. */
|
||||
public function scartaModifiche(): void
|
||||
{
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa || ! $mappa->bozzaScartabile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa->update(['geojson' => $mappa->geojson_pubblicato]);
|
||||
$this->salvato($mappa, seleziona: true);
|
||||
$this->avvisa('Confine riportato a quello pubblicato.');
|
||||
}
|
||||
|
||||
public function updatedNome(): void
|
||||
{
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nome = trim($this->nome);
|
||||
$mappa->update(['nome' => $nome !== '' ? mb_substr($nome, 0, 120) : null]);
|
||||
$this->salvato($mappa);
|
||||
}
|
||||
|
||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||
|
||||
public function updatedTerritorioScelto(string $valore): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($valore === 'nuovo') {
|
||||
// Si compilano i campi e si crea con il pulsante: un territorio
|
||||
// non nasce da un salvataggio automatico a metà digitazione.
|
||||
$this->numero = '';
|
||||
$this->zonaId = '';
|
||||
$this->tipologiaId = '';
|
||||
@@ -127,34 +249,158 @@ class PiantineEditor extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
|
||||
}
|
||||
if ($valore === '') {
|
||||
$numero = $mappa->territorio?->numero;
|
||||
$mappa->update([
|
||||
'territorio_id' => null,
|
||||
// Senza territorio serve un'etichetta, altrimenti la piantina
|
||||
// finisce in elenco come un anonimo "Piantina #12".
|
||||
'nome' => $mappa->nome ?: ($numero ? "Ex territorio {$numero}" : null),
|
||||
]);
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->caricaTerritorio(null);
|
||||
$this->salvato($mappa);
|
||||
|
||||
// ─── Disegno ────────────────────────────────────────────────────────────
|
||||
|
||||
#[On('poligono-disegnato')]
|
||||
public function poligonoDisegnato($geometry): void
|
||||
{
|
||||
if (! is_array($geometry)
|
||||
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|
||||
|| ! is_array($geometry['coordinates'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = json_encode($geometry);
|
||||
$this->pannelloAperto = true;
|
||||
$territorio = Territorio::find($valore);
|
||||
if (! $territorio) {
|
||||
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
|
||||
$this->caricaTerritorio($mappa->territorio);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$occupata = TerritorioMappa::where('territorio_id', $territorio->id)->whereKeyNot($mappa->id)->exists();
|
||||
if ($occupata) {
|
||||
$this->addError('territorioScelto', "Il territorio {$territorio->numero} ha già una piantina.");
|
||||
$this->caricaTerritorio($mappa->territorio);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
$this->caricaTerritorio($territorio);
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||
$this->salvato($mappa);
|
||||
}
|
||||
|
||||
public function scartaDisegno(): void
|
||||
public function creaTerritorio(): void
|
||||
{
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->aggiornaMappaJs();
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa || $this->territorioScelto !== 'nuovo') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->numero = trim($this->numero);
|
||||
$this->confini = trim($this->confini);
|
||||
|
||||
$this->validate(
|
||||
[
|
||||
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
],
|
||||
$this->messaggiTerritorio(),
|
||||
);
|
||||
|
||||
try {
|
||||
$territorio = DB::transaction(function () use ($mappa) {
|
||||
$territorio = Territorio::create($this->valoriTerritorio() + ['attivo' => true]);
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
|
||||
return $territorio;
|
||||
});
|
||||
} catch (\Illuminate\Database\UniqueConstraintViolationException) {
|
||||
$this->addError('numero', 'Esiste già un territorio con questo numero.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
||||
|
||||
$this->caricaTerritorio($territorio);
|
||||
$this->salvato($mappa);
|
||||
$this->avvisa("Territorio {$territorio->numero} creato e collegato.");
|
||||
}
|
||||
|
||||
// ─── Dati del territorio (salvati subito nel gestionale) ────────────────
|
||||
|
||||
public function updatedNumero(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('numero');
|
||||
}
|
||||
|
||||
public function updatedZonaId(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('zonaId');
|
||||
}
|
||||
|
||||
public function updatedTipologiaId(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('tipologiaId');
|
||||
}
|
||||
|
||||
public function updatedConfini(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('confini');
|
||||
}
|
||||
|
||||
private function salvaCampoTerritorio(string $campo): void
|
||||
{
|
||||
// In creazione i campi si raccolgono e basta: li salva creaTerritorio().
|
||||
$territorio = $this->territorioCollegato();
|
||||
if (! $territorio) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->numero = trim($this->numero);
|
||||
|
||||
$regole = [
|
||||
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')->ignore($territorio->id)],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
|
||||
$this->validateOnly($campo, $regole, $this->messaggiTerritorio());
|
||||
|
||||
// Solo la colonna del campo cambiato: con un altro campo ancora non
|
||||
// valido a schermo, riscrivere tutto salverebbe anche quel valore.
|
||||
$colonna = ['numero' => 'numero', 'zonaId' => 'zona_id', 'tipologiaId' => 'tipologia_id', 'confini' => 'confini'][$campo];
|
||||
$territorio->update([$colonna => $this->valoriTerritorio()[$colonna]]);
|
||||
|
||||
$this->salvato($this->mappaAperta());
|
||||
}
|
||||
|
||||
private function valoriTerritorio(): array
|
||||
{
|
||||
return [
|
||||
'numero' => trim($this->numero),
|
||||
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
||||
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
||||
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function messaggiTerritorio(): array
|
||||
{
|
||||
return [
|
||||
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
||||
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
||||
'numero.max' => 'Il numero può avere al massimo 20 caratteri.',
|
||||
'confini.max' => 'I confini possono avere al massimo 5000 caratteri.',
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
||||
|
||||
public function creaZona()
|
||||
public function creaZona(): void
|
||||
{
|
||||
$this->nuovaZona = trim($this->nuovaZona);
|
||||
$dati = $this->validate(
|
||||
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
||||
[
|
||||
@@ -163,16 +409,17 @@ class PiantineEditor extends Component
|
||||
],
|
||||
);
|
||||
|
||||
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]);
|
||||
$zona = Zona::create(['nome' => $dati['nuovaZona'], 'attivo' => true]);
|
||||
|
||||
$this->zonaId = (string) $zona->id;
|
||||
$this->nuovaZona = '';
|
||||
|
||||
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
|
||||
$this->updatedZonaId();
|
||||
$this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
public function creaTipologia()
|
||||
public function creaTipologia(): void
|
||||
{
|
||||
$this->nuovaTipologia = trim($this->nuovaTipologia);
|
||||
$dati = $this->validate(
|
||||
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
||||
[
|
||||
@@ -181,221 +428,155 @@ class PiantineEditor extends Component
|
||||
],
|
||||
);
|
||||
|
||||
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]);
|
||||
$tipologia = Tipologia::create(['nome' => $dati['nuovaTipologia'], 'attivo' => true]);
|
||||
|
||||
$this->tipologiaId = (string) $tipologia->id;
|
||||
$this->nuovaTipologia = '';
|
||||
|
||||
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||
$this->updatedTipologiaId();
|
||||
$this->avvisa("Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
// ─── Salvataggio ────────────────────────────────────────────────────────
|
||||
// ─── Pubblicazione ed eliminazione ──────────────────────────────────────
|
||||
|
||||
public function salva()
|
||||
public function pubblica(): void
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null;
|
||||
|
||||
if (! $mappa && ! $this->geometriaInAttesa) {
|
||||
session()->flash('error', 'Disegna il poligono prima di salvare.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = $this->salvaScheda($mappa);
|
||||
|
||||
// salvaScheda() torna false quando la validazione della scheda non
|
||||
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
|
||||
if ($territorio === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valori = [
|
||||
'territorio_id' => $territorio?->id,
|
||||
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
|
||||
];
|
||||
|
||||
if ($this->geometriaInAttesa) {
|
||||
$valori['geojson'] = $this->geometriaInAttesa;
|
||||
}
|
||||
|
||||
if ($mappa) {
|
||||
$mappa->update($valori);
|
||||
} else {
|
||||
$mappa = TerritorioMappa::create($valori);
|
||||
$this->mappaId = $mappa->id;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->log('piantina_salvata');
|
||||
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea o aggiorna il territorio della scheda.
|
||||
*
|
||||
* @return Territorio|null|false Il territorio collegato, null se la
|
||||
* piantina resta scollegata, false se la
|
||||
* scheda non è valida.
|
||||
*/
|
||||
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
|
||||
{
|
||||
if ($this->territorioScelto === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$esistente = $this->territorioScelto === 'nuovo'
|
||||
? null
|
||||
: Territorio::find($this->territorioScelto);
|
||||
|
||||
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
|
||||
session()->flash('error', 'Il territorio scelto non esiste più.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate(
|
||||
[
|
||||
'numero' => [
|
||||
'required', 'string', 'max:20',
|
||||
Rule::unique('territori', 'numero')->ignore($esistente?->id),
|
||||
],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
],
|
||||
[
|
||||
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
||||
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
||||
],
|
||||
['numero' => 'numero'],
|
||||
);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->pannelloAperto = true;
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Un territorio ha una piantina sola.
|
||||
if ($esistente) {
|
||||
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
|
||||
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
|
||||
->exists();
|
||||
|
||||
if ($occupato) {
|
||||
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$valori = [
|
||||
'numero' => trim($this->numero),
|
||||
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
||||
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
||||
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
||||
];
|
||||
|
||||
if ($esistente) {
|
||||
$esistente->update($valori);
|
||||
return $esistente;
|
||||
}
|
||||
|
||||
$territorio = Territorio::create($valori + ['attivo' => true]);
|
||||
$this->territorioScelto = (string) $territorio->id;
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
||||
|
||||
return $territorio;
|
||||
}
|
||||
|
||||
// ─── Immagine ed eliminazione ───────────────────────────────────────────
|
||||
|
||||
public function generaImmagine(PiantinaRenderer $renderer)
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||
$this->avvisaErrore($motivo);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$renderer->render($mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||
}
|
||||
|
||||
public function elimina()
|
||||
#[On('piantina-pubblicata')]
|
||||
public function pubblicata(int $mappaId): void
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||
|
||||
if ($mappa) {
|
||||
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
|
||||
}
|
||||
}
|
||||
|
||||
public function elimina(): void
|
||||
{
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$etichetta = $mappa->etichetta();
|
||||
$id = $mappa->id;
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
$this->nuova();
|
||||
session()->flash('success', "{$etichetta} eliminata.");
|
||||
$this->svuota();
|
||||
$this->dispatch('piantina-rimossa', mappaId: $id);
|
||||
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
||||
|
||||
public function datiMappa(?PiantineArea $area = null): array
|
||||
/** Chiamata una volta dalla mappa all'avvio ($wire.datiMappa()). */
|
||||
public function datiMappa(PiantineArea $area): array
|
||||
{
|
||||
$area ??= app(PiantineArea::class);
|
||||
|
||||
$piantine = TerritorioMappa::with('territorio')->get()
|
||||
->filter(fn ($m) => $m->geometria() !== null)
|
||||
->map(fn ($m) => [
|
||||
'mappaId' => $m->id,
|
||||
'etichetta' => $m->etichetta(),
|
||||
'collegata' => $m->collegata(),
|
||||
'geometry' => $m->geometria(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'area' => $area->area(),
|
||||
'piantine' => $piantine,
|
||||
'piantine' => TerritorioMappa::with('territorio.zona')->get()
|
||||
->filter(fn (TerritorioMappa $m) => $m->geometria() !== null)
|
||||
->map(fn (TerritorioMappa $m) => $this->datiPiantina($m))
|
||||
->values()
|
||||
->all(),
|
||||
'selezionata' => $this->mappaId,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(bool $inquadra = false): void
|
||||
private function datiPiantina(TerritorioMappa $mappa): array
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
|
||||
return [
|
||||
'mappaId' => $mappa->id,
|
||||
'etichetta' => $mappa->etichetta(),
|
||||
'collegata' => $mappa->collegata(),
|
||||
'stato' => $mappa->stato(),
|
||||
'statoEtichetta' => $mappa->statoEtichetta(),
|
||||
'geometry' => $mappa->geometria(),
|
||||
];
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area)
|
||||
/** Dopo ogni salvataggio automatico: ora in testata, mappa e indirizzo allineati. */
|
||||
private function salvato(?TerritorioMappa $mappa, bool $seleziona = false): void
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa = $mappa->fresh(['territorio.zona']);
|
||||
$this->salvatoAlle = now()->format('H:i');
|
||||
|
||||
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
|
||||
|
||||
if ($seleziona) {
|
||||
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: false);
|
||||
}
|
||||
|
||||
// La piantina aperta resta nell'indirizzo: ricaricando la pagina si
|
||||
// torna su questa e non su un editor vuoto.
|
||||
$this->dispatch('editor-indirizzo', url: route('piantine.editor', $mappa->id));
|
||||
}
|
||||
|
||||
private function mappaAperta(): ?TerritorioMappa
|
||||
{
|
||||
return $this->mappaId ? TerritorioMappa::with('territorio.zona')->find($this->mappaId) : null;
|
||||
}
|
||||
|
||||
private function territorioCollegato(): ?Territorio
|
||||
{
|
||||
if ($this->territorioScelto === '' || $this->territorioScelto === 'nuovo') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
return $mappa?->territorio;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
// Nella tendina: i territori attivi senza piantina, più quello già
|
||||
// collegato anche se nel frattempo è stato disattivato o cestinato.
|
||||
$occupati = TerritorioMappa::whereNotNull('territorio_id')
|
||||
->when($mappa, fn ($q) => $q->whereKeyNot($mappa->id))
|
||||
->pluck('territorio_id');
|
||||
|
||||
$territori = Territorio::attivi()
|
||||
->whereNotIn('id', $occupati)
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero', 'attivo', 'deleted_at']);
|
||||
|
||||
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
|
||||
$territori->prepend($mappa->territorio);
|
||||
}
|
||||
|
||||
return view('livewire.piantine.piantine-editor', [
|
||||
'mappa' => $mappa,
|
||||
'areaCaricata' => $area->area() !== null,
|
||||
'datiMappaIniziali' => $this->datiMappa($area),
|
||||
'territori' => Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero']),
|
||||
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
||||
'territori' => $territori,
|
||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||
'title' => 'Editor delle piantine',
|
||||
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,60 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* Pagina di gestione delle piantine (solo amministratore).
|
||||
*
|
||||
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano
|
||||
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno
|
||||
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su
|
||||
* una mappa larga un terzo di schermo non si taglia un confine.
|
||||
* È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
|
||||
* con modifiche da pubblicare), l'area della città e lo storico dei PDF
|
||||
* sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
|
||||
* da qui si apre, si pubblica (con la stessa finestra di anteprima
|
||||
* dell'editor) e si elimina.
|
||||
*/
|
||||
class PiantineManager extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithFileUploads;
|
||||
use WithPagination;
|
||||
|
||||
public const FILTRI = [
|
||||
'tutte' => 'Tutte',
|
||||
'bozze' => 'Bozze',
|
||||
'da-pubblicare' => 'Da pubblicare',
|
||||
'pubblicate' => 'Pubblicate',
|
||||
'scollegate' => 'Scollegate',
|
||||
];
|
||||
|
||||
#[Url(as: 'q', except: '')]
|
||||
public string $cerca = '';
|
||||
|
||||
#[Url(except: 'tutte')]
|
||||
public string $filtro = 'tutte';
|
||||
|
||||
public $kmlFile = null;
|
||||
|
||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
||||
public ?int $collegamentoAperto = null;
|
||||
|
||||
public ?int $territorioDaCollegare = null;
|
||||
public function updatedFiltro(): void
|
||||
{
|
||||
if (! array_key_exists($this->filtro, self::FILTRI)) {
|
||||
$this->filtro = 'tutte';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||
|
||||
public function caricaKml(PiantineArea $area)
|
||||
public function caricaKml(PiantineArea $area): void
|
||||
{
|
||||
$this->validate(
|
||||
['kmlFile' => 'required|file|max:10240'],
|
||||
@@ -62,111 +84,42 @@ class PiantineManager extends Component
|
||||
$this->kmlFile = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
|
||||
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
$this->avvisa("Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
}
|
||||
|
||||
// ─── Generazione ────────────────────────────────────────────────────────
|
||||
// ─── Piantine ───────────────────────────────────────────────────────────
|
||||
|
||||
public function generaImmagine(int $mappaId, PiantinaRenderer $renderer)
|
||||
public function pubblica(int $mappaId): void
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||
|
||||
try {
|
||||
$renderer->render($mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
if (! $mappa) {
|
||||
$this->avvisaErrore('La piantina non esiste più.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
|
||||
if (! $mappa->collegata()) {
|
||||
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
|
||||
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||
$this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||
}
|
||||
|
||||
/** La finestra di pubblicazione ha finito: basta ridisegnare l'elenco. */
|
||||
#[On('piantina-pubblicata')]
|
||||
public function pubblicata(): void {}
|
||||
|
||||
public function elimina(int $mappaId): void
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->find($mappaId);
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
}
|
||||
|
||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||
|
||||
public function apriCollegamento(int $mappaId): void
|
||||
{
|
||||
$this->collegamentoAperto = $mappaId;
|
||||
$this->territorioDaCollegare = TerritorioMappa::find($mappaId)?->territorio_id;
|
||||
}
|
||||
|
||||
public function chiudiCollegamento(): void
|
||||
{
|
||||
$this->collegamentoAperto = null;
|
||||
$this->territorioDaCollegare = null;
|
||||
}
|
||||
|
||||
public function collega(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::findOrFail($mappaId);
|
||||
|
||||
if (! $this->territorioDaCollegare) {
|
||||
session()->flash('error', 'Scegli il territorio a cui collegare la piantina.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = Territorio::findOrFail($this->territorioDaCollegare);
|
||||
|
||||
// Un territorio ha una piantina sola: se ne aveva già un'altra il
|
||||
// vincolo di unicità la respingerebbe con un errore di database, che
|
||||
// non spiega niente a chi sta guardando la pagina.
|
||||
$occupato = TerritorioMappa::where('territorio_id', $territorio->id)
|
||||
->where('id', '!=', $mappa->id)
|
||||
->exists();
|
||||
|
||||
if ($occupato) {
|
||||
session()->flash('error', "Il territorio {$territorio->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
$this->chiudiCollegamento();
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
|
||||
}
|
||||
|
||||
public function scollega(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$numero = $mappa->territorio?->numero ?? '?';
|
||||
|
||||
// Senza territorio serve un'etichetta, altrimenti la piantina sparisce
|
||||
// dall'elenco sotto un anonimo "Piantina #12".
|
||||
$mappa->update([
|
||||
'territorio_id' => null,
|
||||
'nome' => $mappa->nome ?: "Ex territorio {$numero}",
|
||||
]);
|
||||
|
||||
session()->flash('success', "Piantina scollegata dal territorio {$numero}. Il PDF del territorio resta com'è.");
|
||||
}
|
||||
|
||||
public function eliminaPiantina(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$etichetta = $mappa->etichetta();
|
||||
|
||||
if ($mappa->image_path) {
|
||||
@@ -174,59 +127,106 @@ class PiantineManager extends Component
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
|
||||
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||
}
|
||||
|
||||
// ─── Storico versioni ───────────────────────────────────────────────────
|
||||
|
||||
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService)
|
||||
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService): void
|
||||
{
|
||||
$versione = TerritorioPdfVersione::with('territorio')->findOrFail($versioneId);
|
||||
$versione = TerritorioPdfVersione::with('territorio')->find($versioneId);
|
||||
|
||||
if (! $versione) {
|
||||
return;
|
||||
}
|
||||
|
||||
$numero = $versione->territorio?->numero ?? '?';
|
||||
|
||||
try {
|
||||
$pdfService->ripristina($versione);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Versione precedente ripristinata per il territorio {$numero}.");
|
||||
$this->avvisa("Versione precedente ripristinata per il territorio {$numero}. Il PDF che c'era è nello storico.");
|
||||
}
|
||||
|
||||
public function eliminaVersione(int $versioneId)
|
||||
public function eliminaVersione(int $versioneId): void
|
||||
{
|
||||
$versione = TerritorioPdfVersione::findOrFail($versioneId);
|
||||
$versione = TerritorioPdfVersione::find($versioneId);
|
||||
|
||||
if (Storage::disk('public')->exists($versione->pdf_path)) {
|
||||
Storage::disk('public')->delete($versione->pdf_path);
|
||||
if (! $versione) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($versione->pdf_path);
|
||||
$versione->delete();
|
||||
|
||||
session()->flash('success', 'Versione eliminata definitivamente.');
|
||||
$this->avvisa('Versione eliminata definitivamente.');
|
||||
}
|
||||
|
||||
// ─── Dati per la vista ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tutte le piantine con il loro stato, in memoria: sono qualche centinaio
|
||||
* al massimo, e lo stato dipende dall'impronta, che si calcola in PHP.
|
||||
*
|
||||
* @return Collection<int, TerritorioMappa>
|
||||
*/
|
||||
private function piantine(): Collection
|
||||
{
|
||||
return TerritorioMappa::with('territorio.zona')
|
||||
->get()
|
||||
->sortBy(fn (TerritorioMappa $m) => [
|
||||
$m->territorio_id === null ? 1 : 0,
|
||||
strlen((string) $m->territorio?->numero),
|
||||
(string) $m->territorio?->numero,
|
||||
mb_strtolower((string) $m->nome),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
private function corrisponde(TerritorioMappa $mappa, string $filtro): bool
|
||||
{
|
||||
return match ($filtro) {
|
||||
'bozze' => $mappa->stato() === TerritorioMappa::STATO_BOZZA,
|
||||
'da-pubblicare' => $mappa->stato() === TerritorioMappa::STATO_MODIFICATA,
|
||||
'pubblicate' => $mappa->stato() === TerritorioMappa::STATO_PUBBLICATA,
|
||||
'scollegate' => ! $mappa->collegata(),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$piantine = $this->piantine();
|
||||
|
||||
$conteggi = collect(self::FILTRI)
|
||||
->map(fn ($etichetta, $filtro) => $piantine->filter(fn ($m) => $this->corrisponde($m, $filtro))->count());
|
||||
|
||||
$cerca = mb_strtolower(trim($this->cerca));
|
||||
|
||||
$elenco = $piantine
|
||||
->filter(fn ($m) => $this->corrisponde($m, $this->filtro))
|
||||
->filter(fn (TerritorioMappa $m) => $cerca === '' || str_contains(mb_strtolower(implode(' ', [
|
||||
$m->etichetta(),
|
||||
$m->territorio?->numero,
|
||||
$m->nome,
|
||||
$m->territorio?->zona?->nome,
|
||||
])), $cerca))
|
||||
->values();
|
||||
|
||||
return view('livewire.piantine.piantine-manager', [
|
||||
'config' => $area->config(),
|
||||
'piantine' => TerritorioMappa::with('territorio')
|
||||
->get()
|
||||
->sortBy(fn ($m) => [
|
||||
$m->collegata() ? 0 : 1,
|
||||
strlen((string) $m->territorio?->numero),
|
||||
(string) $m->territorio?->numero,
|
||||
(string) $m->nome,
|
||||
])
|
||||
->values(),
|
||||
'territori' => Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero']),
|
||||
'elenco' => $elenco,
|
||||
'totale' => $piantine->count(),
|
||||
'conteggi' => $conteggi,
|
||||
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||
->orderByDesc('sostituito_il')
|
||||
->limit(30)
|
||||
->get(),
|
||||
->orderByDesc('id')
|
||||
->paginate(15, pageName: 'versioni'),
|
||||
'title' => 'Piantine dei territori',
|
||||
]);
|
||||
}
|
||||
|
||||
124
app/Livewire/Piantine/Pubblicazione.php
Normal file
124
app/Livewire/Piantine/Pubblicazione.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PubblicazionePiantina;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* La finestra di pubblicazione, uguale nell'editor e nella gestione.
|
||||
*
|
||||
* Si apre con l'evento "pubblica-piantina", prepara l'anteprima in coda
|
||||
* (controllando a intervalli quando è pronta), la mostra, e solo su
|
||||
* "Conferma" sostituisce il PDF del territorio. Chiuderla butta l'anteprima.
|
||||
*/
|
||||
class Pubblicazione extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
#[Locked]
|
||||
public ?int $mappaId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $token = null;
|
||||
|
||||
public string $fase = '';
|
||||
|
||||
public string $messaggio = '';
|
||||
|
||||
public string $etichetta = '';
|
||||
|
||||
#[On('pubblica-piantina')]
|
||||
public function apri(int $mappaId, PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
$this->chiudi($pubblicazione);
|
||||
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||
if (! $mappa) {
|
||||
$this->avvisaErrore('La piantina non esiste più.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->etichetta = $mappa->etichetta();
|
||||
|
||||
try {
|
||||
$this->token = $pubblicazione->avvia($mappa);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->fase = PubblicazionePiantina::ERRORE;
|
||||
$this->messaggio = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->aggiorna($pubblicazione);
|
||||
}
|
||||
|
||||
/** Chiamata a intervalli finché l'anteprima è in preparazione. */
|
||||
public function aggiorna(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if (! $this->token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stato = $pubblicazione->stato($this->token);
|
||||
|
||||
if (! $stato) {
|
||||
$this->fase = PubblicazionePiantina::ERRORE;
|
||||
$this->messaggio = 'L\'anteprima è scaduta: generala di nuovo.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fase = $stato['stato'];
|
||||
$this->messaggio = $stato['messaggio'] ?? '';
|
||||
}
|
||||
|
||||
public function conferma(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if (! $this->token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$mappa = $pubblicazione->conferma($this->token);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->fase = PubblicazionePiantina::ERRORE;
|
||||
$this->messaggio = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
|
||||
|
||||
$this->avvisa("{$mappa->etichetta()} pubblicata: il PDF del territorio è stato sostituito, il precedente è nello storico.");
|
||||
$this->dispatch('piantina-pubblicata', mappaId: $mappa->id);
|
||||
}
|
||||
|
||||
/** Riprova dopo un errore, con lo stato attuale della piantina. */
|
||||
public function riprova(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if ($this->mappaId) {
|
||||
$this->apri($this->mappaId, $pubblicazione);
|
||||
}
|
||||
}
|
||||
|
||||
public function chiudi(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if ($this->token) {
|
||||
$pubblicazione->annulla($this->token);
|
||||
}
|
||||
|
||||
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.piantine.pubblicazione');
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,29 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
|
||||
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
||||
* territorio, che e' il riferimento con cui lo chiamano tutti.
|
||||
*
|
||||
* `geojson` e' la bozza: l'editor la salva da solo a ogni modifica, e non
|
||||
* tocca il PDF del territorio. Il PDF cambia solo con la pubblicazione, che
|
||||
* ricorda cosa e' stato pubblicato (`geojson_pubblicato`,
|
||||
* `impronta_pubblicata`, `pubblicata_at`).
|
||||
*/
|
||||
class TerritorioMappa extends Model
|
||||
{
|
||||
public const STATO_BOZZA = 'bozza';
|
||||
|
||||
public const STATO_PUBBLICATA = 'pubblicata';
|
||||
|
||||
public const STATO_MODIFICATA = 'modificata';
|
||||
|
||||
protected $table = 'territorio_mappe';
|
||||
|
||||
protected $fillable = [
|
||||
'territorio_id',
|
||||
'nome',
|
||||
'geojson',
|
||||
'geojson_pubblicato',
|
||||
'impronta_pubblicata',
|
||||
'pubblicata_at',
|
||||
'image_path',
|
||||
'rendered_at',
|
||||
];
|
||||
@@ -28,12 +42,19 @@ class TerritorioMappa extends Model
|
||||
{
|
||||
return [
|
||||
'rendered_at' => 'datetime',
|
||||
'pubblicata_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* withTrashed: un territorio nel cestino resta collegato. Senza, la
|
||||
* relazione tornava null, l'editor mostrava la piantina come scollegata e
|
||||
* il primo salvataggio cancellava il collegamento — che al ripristino del
|
||||
* territorio non c'era piu'.
|
||||
*/
|
||||
public function territorio()
|
||||
{
|
||||
return $this->belongsTo(Territorio::class, 'territorio_id');
|
||||
return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,15 +63,22 @@ class TerritorioMappa extends Model
|
||||
*/
|
||||
public function geometria(): ?array
|
||||
{
|
||||
$decoded = json_decode($this->geojson, true);
|
||||
$decoded = json_decode((string) $this->geojson, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/** Se è collegata a un territorio, il PDF si può generare. */
|
||||
/** Collegata a un territorio vivo: è la condizione per pubblicare. */
|
||||
public function collegata(): bool
|
||||
{
|
||||
return $this->territorio_id !== null && $this->territorio !== null;
|
||||
return $this->territorio_id !== null
|
||||
&& $this->territorio !== null
|
||||
&& ! $this->territorio->trashed();
|
||||
}
|
||||
|
||||
public function territorioNelCestino(): bool
|
||||
{
|
||||
return $this->territorio !== null && $this->territorio->trashed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +88,7 @@ class TerritorioMappa extends Model
|
||||
*/
|
||||
public function etichetta(): string
|
||||
{
|
||||
if ($this->collegata()) {
|
||||
if ($this->territorio !== null) {
|
||||
return 'Terr. ' . $this->territorio->numero;
|
||||
}
|
||||
|
||||
@@ -68,4 +96,87 @@ class TerritorioMappa extends Model
|
||||
|
||||
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
||||
}
|
||||
|
||||
// ─── Bozza e pubblicazione ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Impronta di tutto cio' che finisce nel PDF: confine, territorio e i
|
||||
* suoi dati stampati sulla cartolina (numero, zona come «Località»,
|
||||
* confini). Se cambia uno qualunque di questi, il PDF in vigore non
|
||||
* corrisponde piu' e la piantina ha modifiche da pubblicare — anche quando
|
||||
* il disegno e' rimasto lo stesso.
|
||||
*/
|
||||
public function impronta(): string
|
||||
{
|
||||
$territorio = $this->territorio;
|
||||
|
||||
return hash('sha256', json_encode([
|
||||
$this->geometria(),
|
||||
$this->territorio_id,
|
||||
$territorio ? trim((string) $territorio->numero) : null,
|
||||
$territorio?->zona?->nome,
|
||||
$territorio ? trim((string) $territorio->confini) : null,
|
||||
]));
|
||||
}
|
||||
|
||||
public function stato(): string
|
||||
{
|
||||
if ($this->pubblicata_at === null) {
|
||||
return self::STATO_BOZZA;
|
||||
}
|
||||
|
||||
return $this->impronta() === $this->impronta_pubblicata && $this->pdfInUsoDallaPiantina()
|
||||
? self::STATO_PUBBLICATA
|
||||
: self::STATO_MODIFICATA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Il PDF in vigore del territorio è uno composto da una piantina. Se nel
|
||||
* frattempo è stato sostituito per altre vie (ripristino di una versione,
|
||||
* caricamento a mano, import), quello che arriva ai proclamatori non è
|
||||
* più questo disegno, anche se l'impronta torna.
|
||||
*/
|
||||
public function pdfInUsoDallaPiantina(): bool
|
||||
{
|
||||
return $this->territorio_id !== null
|
||||
&& str_starts_with((string) $this->territorio?->pdf_path, 'territori-pdf/piantina-' . $this->territorio_id . '-');
|
||||
}
|
||||
|
||||
/** Il testo dell'etichetta di stato, uguale in ogni pagina. */
|
||||
public function statoEtichetta(): string
|
||||
{
|
||||
return match ($this->stato()) {
|
||||
self::STATO_BOZZA => 'Bozza',
|
||||
self::STATO_PUBBLICATA => 'Pubblicata',
|
||||
self::STATO_MODIFICATA => 'Modifiche da pubblicare',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perché non si può pubblicare, o null se si può. Il testo va a schermo
|
||||
* accanto al pulsante: dice cosa fare, non solo che manca qualcosa.
|
||||
*/
|
||||
public function motivoNonPubblicabile(): ?string
|
||||
{
|
||||
if ($this->geometria() === null) {
|
||||
return 'Disegna il confine della piantina.';
|
||||
}
|
||||
|
||||
if ($this->territorioNelCestino()) {
|
||||
return 'Il territorio collegato è nel cestino: ripristinalo o collega un altro territorio.';
|
||||
}
|
||||
|
||||
if (! $this->collegata()) {
|
||||
return 'Collega la piantina a un territorio: il PDF è quello del territorio.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Il confine pubblicato è diverso da quello in bozza: si può scartare. */
|
||||
public function bozzaScartabile(): bool
|
||||
{
|
||||
return $this->geojson_pubblicato !== null
|
||||
&& json_decode($this->geojson_pubblicato, true) != $this->geometria();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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