Piantine staccate dai territori, editor a schermo intero, restyle
Le piantine non dipendono piu' da un territorio esistente: territorio_id e' nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome` fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese, create sul posto quando mancano. Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import XML svuota `territori` in blocco e finora si portava via tutte le piantine. Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato. /piantine resta la gestione — area, generazione, collegamenti, storico. Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il nome della zona, non un campo duplicato) e i confini scritti nello spazio centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza toccare pdf_path ne' lo storico. Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine, come la ODbL richiede. Rimossa la rigenerazione massiva: job, stato in cache e interfaccia. Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro significati, carattere servito dall'applicazione e non da Google Fonts, via il gradiente della testata. Aggiunto il link "salta al contenuto", che mancava. Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla 1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta, e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire rispondeva 401. 294 test verdi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Anteprima della cartolina, senza sostituire niente.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class PiantinaAnteprimaController extends Controller
|
||||
{
|
||||
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response
|
||||
{
|
||||
$mappa->load('territorio.zona');
|
||||
|
||||
abort_unless($mappa->collegata(), 404);
|
||||
|
||||
$pdf = $pdfService->genera($mappa->territorio, $mappa);
|
||||
|
||||
return response($pdf, 200, [
|
||||
'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.
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Rigenerazione massiva delle piantine: per ogni territorio con un poligono
|
||||
* disegnato rigenera l'immagine dal renderer e, a richiesta, ricompone e
|
||||
* sostituisce anche il PDF (versione precedente archiviata nello storico).
|
||||
*
|
||||
* Un territorio che fallisce non ferma gli altri: viene contato e loggato,
|
||||
* l'esito finale riepiloga quanti sono andati a buon fine.
|
||||
*/
|
||||
class RigeneraPiantine implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
// Rendering + PDF per decine di territori: largo ma non illimitato, per
|
||||
// non lasciare il worker inchiodato se il renderer smette di rispondere.
|
||||
public int $timeout = 5400;
|
||||
|
||||
public function __construct(
|
||||
public string $operationId,
|
||||
public bool $anchePdf,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(
|
||||
PiantineOperationState $state,
|
||||
PiantinaRenderer $renderer,
|
||||
PiantinaPdfService $pdfService,
|
||||
): void {
|
||||
$state->markRunning($this->operationId);
|
||||
$state->log($this->operationId, 'Rigenerazione avviata.');
|
||||
|
||||
$mappe = TerritorioMappa::with('territorio')->orderBy('territorio_id')->get();
|
||||
|
||||
$processed = 0;
|
||||
$falliti = 0;
|
||||
|
||||
foreach ($mappe as $mappa) {
|
||||
$territorio = $mappa->territorio;
|
||||
$etichetta = $territorio ? "territorio {$territorio->numero}" : "mappa #{$mappa->id}";
|
||||
|
||||
try {
|
||||
if (! $territorio) {
|
||||
throw new \RuntimeException('il territorio associato non esiste più');
|
||||
}
|
||||
|
||||
$renderer->render($mappa);
|
||||
$state->log($this->operationId, "Immagine rigenerata per {$etichetta}.");
|
||||
|
||||
if ($this->anchePdf) {
|
||||
$pdfService->sostituisci($territorio, $mappa, 'rigenerazione');
|
||||
$state->log($this->operationId, "PDF sostituito per {$etichetta}.");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$falliti++;
|
||||
$state->log($this->operationId, "[ERRORE] {$etichetta}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$state->avanza($this->operationId, $processed, $falliti);
|
||||
}
|
||||
|
||||
$state->log(
|
||||
$this->operationId,
|
||||
'Rigenerazione conclusa: ' . ($processed - $falliti) . ' riuscite, ' . $falliti . ' fallite.'
|
||||
);
|
||||
$state->markCompleted($this->operationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiude l'operazione quando il job muore fuori da handle() (timeout,
|
||||
* worker terminato): senza, la pagina resterebbe a interrogare per sempre
|
||||
* uno stato "running".
|
||||
*/
|
||||
public function failed(?\Throwable $e): void
|
||||
{
|
||||
app(PiantineOperationState::class)->markFailed(
|
||||
$this->operationId,
|
||||
$e?->getMessage() ?? 'Job interrotto in modo inatteso.'
|
||||
);
|
||||
}
|
||||
}
|
||||
401
app/Livewire/Piantine/PiantineEditor.php
Normal file
401
app/Livewire/Piantine/PiantineEditor.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
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\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
#[Layout('components.layouts.editor')]
|
||||
class PiantineEditor extends Component
|
||||
{
|
||||
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
|
||||
public ?int $mappaId = null;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
/** Geometria appena disegnata, non ancora salvata. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
|
||||
public bool $pannelloAperto = false;
|
||||
|
||||
// ─── Scheda del territorio ──────────────────────────────────────────────
|
||||
|
||||
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
||||
public string $territorioScelto = '';
|
||||
|
||||
public string $numero = '';
|
||||
|
||||
public string $zonaId = '';
|
||||
|
||||
public string $tipologiaId = '';
|
||||
|
||||
public string $confini = '';
|
||||
|
||||
/** Campi di creazione al volo, aperti solo quando servono. */
|
||||
public string $nuovaZona = '';
|
||||
|
||||
public string $nuovaTipologia = '';
|
||||
|
||||
public function mount(?int $mappa = null): void
|
||||
{
|
||||
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
||||
$this->apri($mappa);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Selezione ──────────────────────────────────────────────────────────
|
||||
|
||||
#[On('piantina-selezionata-mappa')]
|
||||
public function apri($mappaId): void
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->caricaScheda($mappa->territorio);
|
||||
$this->aggiornaMappaJs(inquadra: true);
|
||||
}
|
||||
|
||||
public function nuova(): void
|
||||
{
|
||||
$this->reset([
|
||||
'mappaId', 'nome', 'geometriaInAttesa',
|
||||
'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
|
||||
{
|
||||
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
|
||||
$this->numero = (string) ($territorio->numero ?? '');
|
||||
$this->zonaId = (string) ($territorio->zona_id ?? '');
|
||||
$this->tipologiaId = (string) ($territorio->tipologia_id ?? '');
|
||||
$this->confini = (string) ($territorio->confini ?? '');
|
||||
}
|
||||
|
||||
/** Cambiare territorio nella tendina ricarica la sua scheda. */
|
||||
public function updatedTerritorioScelto(string $valore): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
|
||||
if ($valore === 'nuovo') {
|
||||
$this->numero = '';
|
||||
$this->zonaId = '';
|
||||
$this->tipologiaId = '';
|
||||
$this->confini = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
public function scartaDisegno(): void
|
||||
{
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
||||
|
||||
public function creaZona()
|
||||
{
|
||||
$dati = $this->validate(
|
||||
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
||||
[
|
||||
'nuovaZona.required' => 'Scrivi il nome della zona.',
|
||||
'nuovaZona.unique' => 'Questa zona esiste già: sceglila dall\'elenco.',
|
||||
],
|
||||
);
|
||||
|
||||
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]);
|
||||
|
||||
$this->zonaId = (string) $zona->id;
|
||||
$this->nuovaZona = '';
|
||||
|
||||
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
public function creaTipologia()
|
||||
{
|
||||
$dati = $this->validate(
|
||||
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
||||
[
|
||||
'nuovaTipologia.required' => 'Scrivi il nome della tipologia.',
|
||||
'nuovaTipologia.unique' => 'Questa tipologia esiste già: sceglila dall\'elenco.',
|
||||
],
|
||||
);
|
||||
|
||||
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]);
|
||||
|
||||
$this->tipologiaId = (string) $tipologia->id;
|
||||
$this->nuovaTipologia = '';
|
||||
|
||||
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
// ─── Salvataggio ────────────────────────────────────────────────────────
|
||||
|
||||
public function salva()
|
||||
{
|
||||
$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;
|
||||
|
||||
if (! $mappa) {
|
||||
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$renderer->render($mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function elimina()
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$etichetta = $mappa->etichetta();
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
$this->nuova();
|
||||
session()->flash('success', "{$etichetta} eliminata.");
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
||||
|
||||
public function datiMappa(?PiantineArea $area = null): 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,
|
||||
'selezionata' => $this->mappaId,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(bool $inquadra = false): void
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
|
||||
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']),
|
||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||
'title' => 'Editor delle piantine',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,23 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Jobs\RigeneraPiantine;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* Sotto-app "Piantine" (solo amministratore).
|
||||
* Pagina di gestione delle piantine (solo amministratore).
|
||||
*
|
||||
* Dal KML dell'area città si disegnano i poligoni dei territori su una mappa
|
||||
* OpenStreetMap; da ogni poligono si genera l'immagine ad alta definizione
|
||||
* (container map-renderer) e, a scelta, il PDF sul modulo S-12 che sostituisce
|
||||
* quello del territorio, con storico versioni.
|
||||
* 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.
|
||||
*/
|
||||
class PiantineManager extends Component
|
||||
{
|
||||
@@ -30,21 +26,10 @@ class PiantineManager extends Component
|
||||
|
||||
public $kmlFile = null;
|
||||
|
||||
public string $localita = '';
|
||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
||||
public ?int $collegamentoAperto = null;
|
||||
|
||||
public ?int $territorioSelezionato = null;
|
||||
|
||||
/** Geometria (JSON) appena disegnata sulla mappa, in attesa di salvataggio. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
|
||||
/** Id dell'operazione di rigenerazione massiva seguita dalla pagina. */
|
||||
public ?string $operazioneId = null;
|
||||
|
||||
public function mount(PiantineArea $area, PiantineOperationState $state): void
|
||||
{
|
||||
$this->localita = $area->localita();
|
||||
$this->operazioneId = $state->corrente();
|
||||
}
|
||||
public ?int $territorioDaCollegare = null;
|
||||
|
||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||
|
||||
@@ -77,91 +62,7 @@ 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->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function salvaLocalita(PiantineArea $area): void
|
||||
{
|
||||
$area->impostaLocalita($this->localita);
|
||||
session()->flash('success', 'Località aggiornata: comparirà sull\'intestazione dei prossimi PDF.');
|
||||
}
|
||||
|
||||
// ─── Editor dei poligoni ────────────────────────────────────────────────
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[On('territorio-selezionato-mappa')]
|
||||
public function territorioSelezionatoDaMappa($territorioId): void
|
||||
{
|
||||
$this->territorioSelezionato = is_numeric($territorioId) ? (int) $territorioId : null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function updatedTerritorioSelezionato(): void
|
||||
{
|
||||
$this->territorioSelezionato = $this->territorioSelezionato ?: null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function salvaPoligono()
|
||||
{
|
||||
if (! $this->territorioSelezionato) {
|
||||
session()->flash('error', 'Scegli prima a quale territorio associare il poligono.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->geometriaInAttesa) {
|
||||
session()->flash('error', 'Disegna prima il poligono sulla mappa.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = Territorio::findOrFail($this->territorioSelezionato);
|
||||
|
||||
TerritorioMappa::updateOrCreate(
|
||||
['territorio_id' => $territorio->id],
|
||||
['geojson' => $this->geometriaInAttesa],
|
||||
);
|
||||
|
||||
$this->geometriaInAttesa = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_poligono_salvato');
|
||||
session()->flash('success', "Poligono salvato per il territorio {$territorio->numero}. Ora puoi generare l'immagine.");
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function scartaPoligono(): void
|
||||
{
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function eliminaPoligono(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$numero = $mappa->territorio?->numero ?? '?';
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "Poligono e immagine del territorio {$numero} eliminati (il PDF resta com'è).");
|
||||
$this->aggiornaMappaJs();
|
||||
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
}
|
||||
|
||||
// ─── Generazione ────────────────────────────────────────────────────────
|
||||
@@ -178,55 +79,102 @@ class PiantineManager extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per il territorio {$mappa->territorio?->numero}.");
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$territorio = $mappa->territorio;
|
||||
|
||||
if (! $territorio) {
|
||||
session()->flash('error', 'Il territorio associato non esiste più.');
|
||||
if (! $mappa->collegata()) {
|
||||
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdfService->sostituisci($territorio, $mappa);
|
||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "PDF del territorio {$territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
}
|
||||
|
||||
// ─── Rigenerazione massiva ──────────────────────────────────────────────
|
||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||
|
||||
public function rigeneraTutto(bool $anchePdf, PiantineOperationState $state)
|
||||
public function apriCollegamento(int $mappaId): void
|
||||
{
|
||||
if ($state->corrente() !== null) {
|
||||
session()->flash('error', 'C\'è già una rigenerazione in corso: attendi che finisca.');
|
||||
$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;
|
||||
}
|
||||
|
||||
$totale = TerritorioMappa::count();
|
||||
if ($totale === 0) {
|
||||
session()->flash('error', 'Nessun territorio ha ancora un poligono disegnato.');
|
||||
$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;
|
||||
}
|
||||
|
||||
$id = (string) Str::uuid();
|
||||
$state->initialize($id, $totale, $anchePdf);
|
||||
RigeneraPiantine::dispatch($id, $anchePdf);
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
$this->chiudiCollegamento();
|
||||
|
||||
$this->operazioneId = $id;
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->log($anchePdf ? 'piantine_rigenerazione_pdf' : 'piantine_rigenerazione');
|
||||
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) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
|
||||
}
|
||||
|
||||
// ─── Storico versioni ───────────────────────────────────────────────────
|
||||
@@ -259,58 +207,26 @@ class PiantineManager extends Component
|
||||
session()->flash('success', 'Versione eliminata definitivamente.');
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa e rendering ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Il payload che il JavaScript della mappa usa per disegnare: area della
|
||||
* città, poligoni salvati, selezione corrente.
|
||||
*/
|
||||
public function datiMappa(?PiantineArea $area = null): array
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$area ??= app(PiantineArea::class);
|
||||
|
||||
$poligoni = TerritorioMappa::with('territorio')->get()
|
||||
->filter(fn ($mappa) => $mappa->territorio !== null && $mappa->geometria() !== null)
|
||||
->map(fn ($mappa) => [
|
||||
'territorioId' => $mappa->territorio_id,
|
||||
'numero' => $mappa->territorio->numero,
|
||||
'geometry' => $mappa->geometria(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'area' => $area->area(),
|
||||
'poligoni' => $poligoni,
|
||||
'selezionato' => $this->territorioSelezionato,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(): void
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa());
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area, PiantineOperationState $state)
|
||||
{
|
||||
$mappe = TerritorioMappa::with('territorio')->get()->keyBy('territorio_id');
|
||||
|
||||
$territori = Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero', 'pdf_path']);
|
||||
|
||||
$stato = $this->operazioneId ? $state->get($this->operazioneId) : null;
|
||||
|
||||
return view('livewire.piantine.piantine-manager', [
|
||||
'config' => $area->config(),
|
||||
'datiMappaIniziali' => $this->datiMappa($area),
|
||||
'territori' => $territori,
|
||||
'mappe' => $mappe,
|
||||
'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']),
|
||||
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||
->orderByDesc('sostituito_il')
|
||||
->limit(30)
|
||||
->get(),
|
||||
'stato' => $stato,
|
||||
'title' => 'Piantine dei territori',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Il disegno di un'area sulla mappa.
|
||||
*
|
||||
* Puo' essere collegato a un territorio oppure no: si disegna guardando la
|
||||
* 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.
|
||||
*/
|
||||
class TerritorioMappa extends Model
|
||||
{
|
||||
protected $table = 'territorio_mappe';
|
||||
|
||||
protected $fillable = [
|
||||
'territorio_id',
|
||||
'nome',
|
||||
'geojson',
|
||||
'image_path',
|
||||
'rendered_at',
|
||||
@@ -37,4 +46,26 @@ class TerritorioMappa extends Model
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/** Se è collegata a un territorio, il PDF si può generare. */
|
||||
public function collegata(): bool
|
||||
{
|
||||
return $this->territorio_id !== null && $this->territorio !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Come si chiama questa piantina nell'interfaccia: il numero del
|
||||
* territorio se c'è, altrimenti il nome scelto a mano, altrimenti un
|
||||
* ripiego che almeno la distingue dalle altre.
|
||||
*/
|
||||
public function etichetta(): string
|
||||
{
|
||||
if ($this->collegata()) {
|
||||
return 'Terr. ' . $this->territorio->numero;
|
||||
}
|
||||
|
||||
$nome = trim((string) $this->nome);
|
||||
|
||||
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,27 @@ use RuntimeException;
|
||||
use setasign\Fpdi\Fpdi;
|
||||
|
||||
/**
|
||||
* Compone il PDF del territorio sul modulo ufficiale S-12 e lo sostituisce
|
||||
* nel gestionale conservando la versione precedente.
|
||||
* Compone il PDF del territorio e lo sostituisce nel gestionale conservando
|
||||
* la versione precedente.
|
||||
*
|
||||
* Pagina 1: il modulo S-12 originale (resources/pdf/S-12_I.pdf) importato
|
||||
* com'è, con Località, numero e piantina sovrapposti negli spazi previsti.
|
||||
* Pagina 2: i confini scritti (campo `confini` del territorio).
|
||||
* Pagina 1 — la cartolina: il modulo ufficiale S-12 (resources/pdf/S-12_I.pdf,
|
||||
* 146 x 94,4 mm) importato com'è, con Località e numero sulle righe
|
||||
* punteggiate e i confini scritti nello spazio centrale.
|
||||
*
|
||||
* La "Località" del modulo è il nome della zona del territorio: è già lì, dice
|
||||
* la stessa cosa, e si aggiorna in un posto solo quando la congregazione
|
||||
* riorganizza il lavoro.
|
||||
*
|
||||
* Pagina 2 — la piantina, sola, al 98% della pagina. Il riquadro dell'S-12 è
|
||||
* grande sette centimetri: una mappa di quartiere lì dentro non si legge, e
|
||||
* chi percorre il territorio guarda quella, non il modulo.
|
||||
*/
|
||||
class PiantinaPdfService
|
||||
{
|
||||
/** Quanto della pagina occupa la piantina. */
|
||||
private const OCCUPAZIONE = 0.98;
|
||||
|
||||
public function __construct(
|
||||
private PiantineArea $area,
|
||||
private TerritorioThumbnailService $thumbnails,
|
||||
) {
|
||||
}
|
||||
@@ -41,6 +51,7 @@ class PiantinaPdfService
|
||||
[$imgPxW, $imgPxH] = $dimensioni;
|
||||
|
||||
$pdf = new Fpdi();
|
||||
$pdf->SetAutoPageBreak(false);
|
||||
$pdf->setSourceFile(resource_path('pdf/S-12_I.pdf'));
|
||||
$template = $pdf->importPage(1);
|
||||
$misure = $pdf->getTemplateSize($template);
|
||||
@@ -48,60 +59,120 @@ class PiantinaPdfService
|
||||
$w = $misure['width'];
|
||||
$h = $misure['height'];
|
||||
|
||||
// ── Pagina 1: il modulo ────────────────────────────────────────────
|
||||
$pdf->AddPage($misure['orientation'], [$w, $h]);
|
||||
$pdf->useTemplate($template);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
// Intestazione: i valori si appoggiano sulle righe punteggiate del
|
||||
// modulo. Posizioni in proporzione alla pagina, tarate sull'S-12.
|
||||
$pdf->SetFont('Helvetica', 'B', 10);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
// Tarato sull'S-12 reale: i valori si appoggiano alle righe
|
||||
// punteggiate senza coprire le etichette "Località" e "Terr. N.".
|
||||
$headerY = $h * 0.112;
|
||||
$pdf->SetXY($w * 0.155, $headerY);
|
||||
$pdf->Cell($w * 0.48, 5, $this->latin($this->area->localita()), 0, 0, 'L');
|
||||
$headerY = $h * 0.125;
|
||||
$pdf->SetXY($w * 0.185, $headerY);
|
||||
$pdf->Cell($w * 0.45, 5, $this->latin((string) $territorio->zona?->nome), 0, 0, 'L');
|
||||
|
||||
$pdf->SetXY($w * 0.875, $headerY);
|
||||
$pdf->Cell($w * 0.10, 5, $this->latin($territorio->numero), 0, 0, 'C');
|
||||
|
||||
// Piantina nello spazio centrale, adattata mantenendo le proporzioni:
|
||||
// sotto l'intestazione, sopra la dicitura "(Incollate qui sopra...)".
|
||||
$boxX = $w * 0.045;
|
||||
$boxY = $h * 0.175;
|
||||
$boxW = $w * 0.91;
|
||||
$boxH = $h * 0.565;
|
||||
$this->scriviConfini($pdf, $territorio, $w, $h);
|
||||
|
||||
// ── Pagina 2: la piantina ──────────────────────────────────────────
|
||||
$pdf->AddPage($misure['orientation'], [$w, $h]);
|
||||
|
||||
$boxW = $w * self::OCCUPAZIONE;
|
||||
$boxH = $h * self::OCCUPAZIONE;
|
||||
|
||||
$scala = min($boxW / $imgPxW, $boxH / $imgPxH);
|
||||
$imgW = $imgPxW * $scala;
|
||||
$imgH = $imgPxH * $scala;
|
||||
|
||||
$pdf->Image(
|
||||
$immagine,
|
||||
$boxX + ($boxW - $imgW) / 2,
|
||||
$boxY + ($boxH - $imgH) / 2,
|
||||
($w - $imgW) / 2,
|
||||
($h - $imgH) / 2,
|
||||
$imgW,
|
||||
$imgH,
|
||||
'PNG'
|
||||
);
|
||||
|
||||
// Pagina 2: i confini scritti.
|
||||
$pdf->AddPage($misure['orientation'], [$w, $h]);
|
||||
$pdf->SetFont('Helvetica', 'B', 13);
|
||||
$pdf->SetXY($w * 0.045, $h * 0.06);
|
||||
$pdf->Cell($w * 0.91, 7, $this->latin('Confini del territorio N. ' . $territorio->numero), 0, 2, 'C');
|
||||
|
||||
$pdf->SetFont('Helvetica', '', 11);
|
||||
$pdf->SetXY($w * 0.045, $h * 0.16);
|
||||
$confini = trim((string) $territorio->confini);
|
||||
$pdf->MultiCell(
|
||||
$w * 0.91,
|
||||
6,
|
||||
$this->latin($confini !== '' ? $confini : 'Confini non ancora descritti.')
|
||||
$this->tipoImmagine($mappa->image_path)
|
||||
);
|
||||
|
||||
return $pdf->Output('S');
|
||||
}
|
||||
|
||||
/**
|
||||
* I confini nello spazio che sul modulo è riservato alla piantina.
|
||||
*
|
||||
* Il corpo del testo si restringe finché non ci sta: i confini di un
|
||||
* territorio vanno da due righe a venti, e una misura fissa o lascerebbe
|
||||
* mezzo modulo vuoto o sborderebbe sulla dicitura stampata sotto.
|
||||
*/
|
||||
private function scriviConfini(Fpdi $pdf, Territorio $territorio, float $w, float $h): void
|
||||
{
|
||||
$testo = trim((string) $territorio->confini);
|
||||
|
||||
if ($testo === '') {
|
||||
$pdf->SetFont('Helvetica', 'I', 9);
|
||||
$pdf->SetTextColor(120, 120, 120);
|
||||
$pdf->SetXY($w * 0.06, $h * 0.42);
|
||||
$pdf->Cell($w * 0.88, 5, $this->latin('Confini non ancora descritti.'), 0, 0, 'C');
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$boxX = $w * 0.06;
|
||||
$boxY = $h * 0.20;
|
||||
$boxW = $w * 0.88;
|
||||
$boxH = $h * 0.53;
|
||||
|
||||
$righe = preg_split('/\R/', $testo) ?: [$testo];
|
||||
|
||||
foreach ([10, 9, 8, 7, 6, 5.5, 5] as $corpo) {
|
||||
$interlinea = $corpo * 0.44;
|
||||
$pdf->SetFont('Helvetica', '', $corpo);
|
||||
|
||||
if ($this->altezzaTesto($pdf, $righe, $boxW, $interlinea) <= $boxH) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$pdf->SetXY($boxX, $boxY);
|
||||
foreach ($righe as $riga) {
|
||||
$riga = trim($riga);
|
||||
if ($riga === '') {
|
||||
$pdf->Ln($interlinea * 0.6);
|
||||
continue;
|
||||
}
|
||||
$pdf->SetX($boxX);
|
||||
$pdf->MultiCell($boxW, $interlinea, $this->latin($riga), 0, 'L');
|
||||
}
|
||||
}
|
||||
|
||||
/** Quanto verrebbe alto il blocco dei confini a questo corpo. */
|
||||
private function altezzaTesto(Fpdi $pdf, array $righe, float $larghezza, float $interlinea): float
|
||||
{
|
||||
$altezza = 0.0;
|
||||
|
||||
foreach ($righe as $riga) {
|
||||
$riga = trim($riga);
|
||||
if ($riga === '') {
|
||||
$altezza += $interlinea * 0.6;
|
||||
continue;
|
||||
}
|
||||
// GetStringWidth misura una riga sola: quante ne servono lo dice il
|
||||
// rapporto con la larghezza disponibile, arrotondato per eccesso.
|
||||
$larghezzaTesto = $pdf->GetStringWidth($this->latin($riga));
|
||||
$altezza += max(1, ceil($larghezzaTesto / $larghezza)) * $interlinea;
|
||||
}
|
||||
|
||||
return $altezza;
|
||||
}
|
||||
|
||||
/** FPDF vuole sapere il formato quando l'estensione non basta. */
|
||||
private function tipoImmagine(string $path): string
|
||||
{
|
||||
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'png' ? 'PNG' : 'JPEG';
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera il PDF e lo rende quello ufficiale del territorio, archiviando
|
||||
* l'eventuale PDF precedente nello storico versioni.
|
||||
|
||||
@@ -8,9 +8,9 @@ use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Client del container `map-renderer`: gli passa la geometria di un
|
||||
* territorio, riceve il PNG ad alta definizione e lo archivia sul disco
|
||||
* pubblico accanto a PDF e miniature.
|
||||
* Client del container `map-renderer`: gli passa la geometria di una piantina,
|
||||
* riceve l'immagine ad alta definizione e la archivia sul disco pubblico
|
||||
* accanto a PDF e miniature.
|
||||
*/
|
||||
class PiantinaRenderer
|
||||
{
|
||||
@@ -19,14 +19,21 @@ class PiantinaRenderer
|
||||
$geometry = $mappa->geometria();
|
||||
|
||||
if ($geometry === null) {
|
||||
throw new RuntimeException('La geometria salvata per questo territorio non è leggibile: ridisegna il poligono.');
|
||||
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
||||
}
|
||||
|
||||
$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'),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
@@ -34,13 +41,21 @@ class PiantinaRenderer
|
||||
throw new RuntimeException('Il renderer delle mappe ha risposto con un errore: ' . $dettaglio);
|
||||
}
|
||||
|
||||
$png = $response->body();
|
||||
if ($png === '' || ! str_starts_with($png, "\x89PNG")) {
|
||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un PNG valido.');
|
||||
$immagine = $response->body();
|
||||
|
||||
if (! $this->immagineValida($immagine, $formato)) {
|
||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
|
||||
}
|
||||
|
||||
$path = 'territori-mappe/territorio-' . $mappa->territorio_id . '.png';
|
||||
Storage::disk('public')->put($path, $png);
|
||||
// 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);
|
||||
}
|
||||
|
||||
$path = $this->percorso($mappa, $formato);
|
||||
Storage::disk('public')->put($path, $immagine);
|
||||
|
||||
$mappa->update([
|
||||
'image_path' => $path,
|
||||
@@ -49,4 +64,21 @@ 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
|
||||
{
|
||||
if ($dati === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $formato === 'png'
|
||||
? str_starts_with($dati, "\x89PNG")
|
||||
: str_starts_with($dati, "\xFF\xD8\xFF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ use RuntimeException;
|
||||
*
|
||||
* Vive su file (storage/app/piantine/), non su tabelle: c'è una sola area
|
||||
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
||||
* restano al loro posto. Stando dentro storage/, entra nei backup completi
|
||||
* senza bisogno di altro.
|
||||
* restano al loro posto.
|
||||
*
|
||||
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
|
||||
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
|
||||
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
|
||||
* KML originale — che quindi conviene conservare fuori di qui.
|
||||
*/
|
||||
class PiantineArea
|
||||
{
|
||||
@@ -64,33 +68,14 @@ class PiantineArea
|
||||
public function config(): array
|
||||
{
|
||||
if (! Storage::disk('local')->exists(self::FILE_CONFIG)) {
|
||||
return ['localita' => null, 'kml_nome' => null, 'aggiornato_il' => null];
|
||||
return ['kml_nome' => null, 'aggiornato_il' => null];
|
||||
}
|
||||
|
||||
$decoded = json_decode(Storage::disk('local')->get(self::FILE_CONFIG), true);
|
||||
|
||||
return is_array($decoded)
|
||||
? $decoded + ['localita' => null, 'kml_nome' => null, 'aggiornato_il' => null]
|
||||
: ['localita' => null, 'kml_nome' => null, 'aggiornato_il' => null];
|
||||
}
|
||||
|
||||
/** La "Località" stampata sull'intestazione del modulo S-12. */
|
||||
public function localita(): string
|
||||
{
|
||||
$config = $this->config();
|
||||
|
||||
if (! empty($config['localita'])) {
|
||||
return $config['localita'];
|
||||
}
|
||||
|
||||
return (string) \App\Models\Setting::getValue('congregazione_nome', '');
|
||||
}
|
||||
|
||||
public function impostaLocalita(?string $localita): void
|
||||
{
|
||||
$config = $this->config();
|
||||
$config['localita'] = $localita !== null && trim($localita) !== '' ? trim($localita) : null;
|
||||
$this->salvaConfig($config);
|
||||
? $decoded + ['kml_nome' => null, 'aggiornato_il' => null]
|
||||
: ['kml_nome' => null, 'aggiornato_il' => null];
|
||||
}
|
||||
|
||||
private function salvaConfig(array $config): void
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Piantine;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Stato della rigenerazione massiva delle piantine.
|
||||
*
|
||||
* Stessa impostazione di BackupOperationState e TerritorioPdfImportState:
|
||||
* la pagina segue il lavoro in coda interrogando questo stato, la barra si
|
||||
* muove su unità di lavoro completate davvero (territori renderizzati).
|
||||
* Un'operazione alla volta: due rigenerazioni parallele martellerebbero il
|
||||
* renderer e il tile server senza alcun beneficio.
|
||||
*/
|
||||
class PiantineOperationState
|
||||
{
|
||||
protected int $ttlSeconds = 86400;
|
||||
|
||||
private const MAX_LOG = 60;
|
||||
private const CHIAVE_CORRENTE = 'piantine-op:corrente';
|
||||
private const TTL_CORRENTE = 3600;
|
||||
|
||||
public function initialize(string $id, int $total, bool $anchePdf): array
|
||||
{
|
||||
$state = [
|
||||
'id' => $id,
|
||||
'status' => 'queued',
|
||||
'anche_pdf' => $anchePdf,
|
||||
'total' => $total,
|
||||
'processed' => 0,
|
||||
'falliti' => 0,
|
||||
'logs' => [],
|
||||
'error' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
];
|
||||
|
||||
$this->put($id, $state);
|
||||
Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function corrente(): ?string
|
||||
{
|
||||
$id = Cache::get(self::CHIAVE_CORRENTE);
|
||||
|
||||
if (! is_string($id) || $id === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stato = $this->get($id);
|
||||
|
||||
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function get(string $id): ?array
|
||||
{
|
||||
return Cache::get($this->key($id));
|
||||
}
|
||||
|
||||
public function put(string $id, array $state): void
|
||||
{
|
||||
Cache::put($this->key($id), $state, $this->ttlSeconds);
|
||||
}
|
||||
|
||||
public function update(string $id, callable $callback): void
|
||||
{
|
||||
$state = $this->get($id);
|
||||
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->put($id, $callback($state) ?? $state);
|
||||
}
|
||||
|
||||
public function log(string $id, string $message): void
|
||||
{
|
||||
$this->update($id, function (array $state) use ($message) {
|
||||
$state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message;
|
||||
$state['logs'] = array_slice($state['logs'], -self::MAX_LOG);
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function avanza(string $id, int $processed, int $falliti): void
|
||||
{
|
||||
$this->update($id, function (array $state) use ($processed, $falliti) {
|
||||
$state['processed'] = $processed;
|
||||
$state['falliti'] = $falliti;
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markRunning(string $id): void
|
||||
{
|
||||
$this->update($id, function (array $state) {
|
||||
$state['status'] = 'running';
|
||||
$state['started_at'] ??= now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markCompleted(string $id): void
|
||||
{
|
||||
$this->update($id, function (array $state) {
|
||||
$state['status'] = 'completed';
|
||||
$state['finished_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
|
||||
$this->liberaCorrente($id);
|
||||
}
|
||||
|
||||
public function markFailed(string $id, string $error): void
|
||||
{
|
||||
$this->update($id, function (array $state) use ($error) {
|
||||
$state['status'] = 'failed';
|
||||
$state['error'] = $error;
|
||||
$state['finished_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
|
||||
$this->log($id, 'ERRORE: ' . $error);
|
||||
$this->liberaCorrente($id);
|
||||
}
|
||||
|
||||
private function liberaCorrente(string $id): void
|
||||
{
|
||||
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
}
|
||||
}
|
||||
|
||||
protected function key(string $id): string
|
||||
{
|
||||
return 'piantine-op:' . $id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user