Compare commits
38 Commits
9c37c1ad7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f262d89d | ||
|
|
1ddb97371f | ||
| eb756ad3b1 | |||
|
|
d3314a9d17 | ||
|
|
7384a4b39d | ||
|
|
3000ca9939 | ||
|
|
3451663cfb | ||
|
|
e4549f8ff8 | ||
|
|
d8c519542b | ||
|
|
034329471b | ||
|
|
840fbe4c16 | ||
|
|
a10d06aa6c | ||
|
|
81a8f2ba33 | ||
|
|
fda428f242 | ||
|
|
2873424c3c | ||
|
|
bc465f53f5 | ||
|
|
0c530b65b3 | ||
|
|
f8539b58bd | ||
|
|
bde7a4a8a8 | ||
|
|
f8911afe64 | ||
|
|
ac787039c3 | ||
|
|
2f42cf937c | ||
|
|
1708890d48 | ||
|
|
097aa4ff30 | ||
|
|
6ef301cb14 | ||
|
|
358cbfc81c | ||
|
|
40b4ca0b0b | ||
|
|
2f0a24bca4 | ||
|
|
223d44300c | ||
| fc8623f7db | |||
| e7148c7b32 | |||
| 426a95211d | |||
| 056d95cfcb | |||
| d433c9392d | |||
| 754adf9878 | |||
|
|
76ea127d5b | ||
|
|
3ec2422fbc | ||
|
|
6b11ab7028 |
15
.env.example
15
.env.example
@@ -34,6 +34,10 @@ APP_URL=http://192.0.2.10:8080
|
|||||||
# I dati personali dei proclamatori sono cifrati con questa chiave: se la
|
# I dati personali dei proclamatori sono cifrati con questa chiave: se la
|
||||||
# perdi non sono piu' leggibili nemmeno avendo il backup del database.
|
# perdi non sono piu' leggibili nemmeno avendo il backup del database.
|
||||||
# Conservala in un password manager.
|
# Conservala in un password manager.
|
||||||
|
# Una copia sta nel volume dello storage: se questo valore cambia e non
|
||||||
|
# coincide con la copia, il container si rifiuta di partire invece di rendere
|
||||||
|
# illeggibili i dati. Per un cambio voluto aggiungi APP_KEY_ROTATE=1 (la
|
||||||
|
# chiave vecchia viene conservata accanto alla copia), poi toglila.
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
|
|
||||||
LOG_CHANNEL=stderr
|
LOG_CHANNEL=stderr
|
||||||
@@ -122,15 +126,24 @@ SESSION_SECURE_COOKIE=false
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Posta
|
# Posta
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
# Il server di posta si configura di solito dall'applicazione (Impostazioni →
|
||||||
|
# Server di posta): se lì è compilato, questi valori vengono ignorati.
|
||||||
|
# Servono le email di conferma: senza posta gli utenti non possono confermare
|
||||||
|
# il loro indirizzo e il loro account non funziona.
|
||||||
|
#
|
||||||
# In produzione: SMTP esterno.
|
# In produzione: SMTP esterno.
|
||||||
# In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025
|
# In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025
|
||||||
# Per disattivare del tutto l'invio: MAIL_MAILER=log
|
# Per disattivare del tutto l'invio: MAIL_MAILER=log
|
||||||
|
#
|
||||||
|
# MAIL_SCHEME: vuoto (sceglie da solo: STARTTLS sulla 587, SSL sulla 465),
|
||||||
|
# oppure smtp / smtps. "tls" non è un valore valido con Laravel 11 e fa
|
||||||
|
# fallire ogni invio.
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_HOST=
|
MAIL_HOST=
|
||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_USERNAME=
|
MAIL_USERNAME=
|
||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
MAIL_SCHEME=tls
|
MAIL_SCHEME=
|
||||||
MAIL_FROM_ADDRESS=noreply@example.org
|
MAIL_FROM_ADDRESS=noreply@example.org
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
|||||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Gli script finiscono in container Linux: con CRLF (core.autocrlf su
|
||||||
|
# Windows) il kernel non trova l'interprete e il container non parte.
|
||||||
|
*.sh text eol=lf
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,6 +18,7 @@
|
|||||||
/.phpunit.cache/
|
/.phpunit.cache/
|
||||||
/.idea/
|
/.idea/
|
||||||
/.vscode/
|
/.vscode/
|
||||||
|
.claude
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.sql
|
*.sql
|
||||||
@@ -29,3 +30,4 @@
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
db_data/
|
db_data/
|
||||||
redis_data/
|
redis_data/
|
||||||
|
.claude
|
||||||
502
HANDOFF.md
502
HANDOFF.md
@@ -4,7 +4,52 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
|||||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||||
|
|
||||||
Ultimo aggiornamento: **28 agosto 2026**.
|
Ultimo aggiornamento: **21 ottobre 2026** (rilascio di sicurezza di PHP) — vedi [Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
|
||||||
|
|
||||||
|
> **Questo è l'HANDOFF di `main` (produzione).** Il 21/10/2026 in produzione
|
||||||
|
> è andata **solo la correzione di PHP**, scelta dell'utente: le funzioni nuove
|
||||||
|
> di `dev` (gruppi di servizio e lavori del 20 ottobre) restano sulla demo.
|
||||||
|
> Il quadro completo e aggiornato del progetto è nell'HANDOFF di `dev`, che
|
||||||
|
> sostituirà questo quando `dev` verrà portato in produzione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)
|
||||||
|
|
||||||
|
Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
|
||||||
|
14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
|
||||||
|
La produzione girava su 8.3.35. Rilasciati su `main` solo i due commit di
|
||||||
|
sicurezza di `dev` (cherry-pick):
|
||||||
|
|
||||||
|
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
|
||||||
|
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
|
||||||
|
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
|
||||||
|
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
|
||||||
|
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
|
||||||
|
un tag mobile o a una versione inferiore.
|
||||||
|
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
|
||||||
|
quel giorno pecl.php.net rispondeva 504 e la build falliva.
|
||||||
|
- `docker/php/php.ini`: `session.upload_progress.enabled = 0` e
|
||||||
|
`session.use_strict_mode = 1`, la mitigazione indicata da Sansec in attesa
|
||||||
|
della patch, da tenere anche dopo (l'applicazione usa le sessioni di
|
||||||
|
Laravel, in Redis).
|
||||||
|
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
|
||||||
|
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
|
||||||
|
|
||||||
|
**In produzione dal 21 ottobre 2026, ore 18:48** (16:48 UTC; `main` =
|
||||||
|
`1ddb973`). Su Docker Hub il tag `php:8.3.36-fpm` è comparso alle 15:38 UTC
|
||||||
|
ma vuoto: l'immagine per amd64 (il server) è arrivata alle 16:41 UTC, e prima
|
||||||
|
la build falliva con "no match for platform in manifest". Prima del deploy:
|
||||||
|
test di questa versione su PHP 8.3.36 (524 PHP + 13 JS verdi) e backup
|
||||||
|
`/etc/dokploy/termanager2-backups/prod-20261021-164333` (database, 78 MB di
|
||||||
|
`storage/app`, conteggi). Deploy lanciato a mano (l'autodeploy non è
|
||||||
|
partito), uno solo. Verifica: PHP 8.3.36, `session.upload_progress.enabled`
|
||||||
|
Off e `session.use_strict_mode` On dal `php.ini` dell'immagine, nessuna
|
||||||
|
migrazione in sospeso, conteggi identici al backup (274 territori, 1130
|
||||||
|
assegnazioni di cui 77 aperte, 111 proclamatori, 4 utenti, 1251 righe di log,
|
||||||
|
35 migrazioni), login 200, container sani, nessun errore nei log. La
|
||||||
|
mitigazione applicata a mano ai container la mattina (in `conf.d`) è
|
||||||
|
sparita con la ricostruzione, come previsto: vale il `php.ini`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,6 +120,332 @@ e l'unica che richiede una decisione, non del lavoro.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
|
||||||
|
|
||||||
|
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo):
|
||||||
|
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
||||||
|
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
|
||||||
|
(integrità dati).
|
||||||
|
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
|
||||||
|
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
|
||||||
|
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||||
|
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
|
||||||
|
della tabella sono nel commit successivo.
|
||||||
|
|
||||||
|
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
|
||||||
|
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
|
||||||
|
|
||||||
|
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
|
||||||
|
|
||||||
|
Richieste dell'utente, con le sue scelte:
|
||||||
|
|
||||||
|
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
|
||||||
|
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
|
||||||
|
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
|
||||||
|
"Invia email di prova". Se compilato prende il posto del `.env`
|
||||||
|
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
|
||||||
|
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
|
||||||
|
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
|
||||||
|
un valore non valido per Laravel 11: ora vuoto.
|
||||||
|
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
|
||||||
|
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
|
||||||
|
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
|
||||||
|
considera italiano).
|
||||||
|
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
|
||||||
|
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
|
||||||
|
(`Assegna::DA_INVIARE`) non esiste più.
|
||||||
|
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
|
||||||
|
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
|
||||||
|
L'admin crea l'utente con la password come prima; parte l'email di conferma
|
||||||
|
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
|
||||||
|
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
|
||||||
|
link. In Utenti: stato "da confermare" e "Reinvia conferma".
|
||||||
|
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
|
||||||
|
e — per l'amministratore — il server di posta.
|
||||||
|
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
|
||||||
|
creato da riga di comando nasce confermato.
|
||||||
|
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
|
||||||
|
`$confermaUtentiCreati`).
|
||||||
|
|
||||||
|
Provato nel browser in locale con mailpit come server configurato dall'app:
|
||||||
|
email di prova e di conferma arrivate, link aperto senza login, account
|
||||||
|
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
||||||
|
e dati di prova rimossi dopo.
|
||||||
|
|
||||||
|
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
|
||||||
|
|
||||||
|
Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email
|
||||||
|
né telefono**, e WhatsApp ed email spariscono come canali. Restano:
|
||||||
|
|
||||||
|
- **Copia link**;
|
||||||
|
- **Condividi…**: `navigator.share()` con il messaggio già scritto
|
||||||
|
(`Assegnazione::messaggioLink()`), cioè il menu di condivisione del telefono
|
||||||
|
(WhatsApp, Telegram, SMS…), da cui chi usa l'app sceglie app e contatto.
|
||||||
|
Compare solo dove il browser lo offre (telefoni, alcuni computer); il canale
|
||||||
|
si annota a condivisione fatta, non se la si annulla. Nell'elenco dei
|
||||||
|
territori assegnati "Condividi" andato a buon fine segna il link come
|
||||||
|
inviato (`ElencoAssegnati::segnaCondiviso`).
|
||||||
|
|
||||||
|
Cosa è cambiato:
|
||||||
|
- Migrazione `2026_10_19_000020_togli_contatti_proclamatori`: elimina le
|
||||||
|
colonne `email` e `telefono`. In produzione erano vuote (0 su 111) al
|
||||||
|
momento della decisione; su dev c'era un solo valore di prova.
|
||||||
|
- Tolti dal form, dalla scheda e dall'elenco dei proclamatori; tolti
|
||||||
|
`Proclamatore::telefonoInternazionale()/whatsappUrl()`, il componente
|
||||||
|
`<x-whatsapp>`, gli stili `tm-whatsapp`, la notifica `LinkTerritorio` e
|
||||||
|
`Assegna::inviaEmail()`.
|
||||||
|
- XML: non si esportano più; un file vecchio che li contiene si importa
|
||||||
|
ignorandoli.
|
||||||
|
- `Assegnazione::CANALI_ATTIVI` = copia, condividi. In `CANALI_LINK` restano
|
||||||
|
anche "whatsapp" ed "email" solo per le assegnazioni già inviate così.
|
||||||
|
- La posta resta, ma solo per le email agli utenti (conferma dell'indirizzo).
|
||||||
|
- Informativa privacy, README e test (`InvioLinkTest`, al posto di
|
||||||
|
`ContattiProclamatoriTest`) aggiornati.
|
||||||
|
|
||||||
|
**In produzione dal 19 ottobre 2026, ore 17:10** (`main` = `7384a4b`). Prima:
|
||||||
|
ricontrollato che email e telefono fossero vuoti (0 su 111) e backup in
|
||||||
|
`/etc/dokploy/termanager2-backups/prod-20261019-150408/`. Dopo: migrazione
|
||||||
|
applicata, colonne tolte, conteggi identici a prima (1130 assegnazioni, 77
|
||||||
|
aperte), 111/111 proclamatori decifrati, 272/272 PDF, 4/4 utenti confermati.
|
||||||
|
L'autodeploy non era partito entro 2 minuti dal push: deploy avviato a mano.
|
||||||
|
|
||||||
|
### Allineamento di main in dev e passaggio in produzione (19 ottobre 2026)
|
||||||
|
|
||||||
|
`main` è stato unito in `dev` (merge, non rebase): da `main` arrivano solo
|
||||||
|
`S-12_I.pdf` nella radice e una riga di `.gitignore`. Quel PDF, copia
|
||||||
|
identica di `resources/pdf/S-12_I.pdf` e non usato dal codice, è stato poi
|
||||||
|
tolto su richiesta dell'utente; il modulo usato da `PiantinaPdfService` è
|
||||||
|
quello in `resources/pdf/` e resta. I 5 conflitti (README,
|
||||||
|
`CampagnaShow`, `Registro`, `Assegnazione`, vista della campagna) nascevano
|
||||||
|
dalle correzioni sulle campagne fatte su `main` la mattina e già portate su
|
||||||
|
`dev`: si è tenuta la versione di `dev`, che le contiene già (con
|
||||||
|
`proclamatore_id` nel log al posto del nome). Dopo, `main` va portato avanti
|
||||||
|
fino a `dev` senza un nuovo merge, così in produzione va esattamente il codice
|
||||||
|
provato su dev.
|
||||||
|
|
||||||
|
Analisi d'impatto per prod (fatta sui dati di prod copiati in dev):
|
||||||
|
- 11 migrazioni nuove, provate sulla copia: nessun errore, conteggi identici.
|
||||||
|
Due toccano dati esistenti: `nomi_proclamatori_fuori_dal_log` (irreversibile,
|
||||||
|
solo da backup) e `posta_contatti_e_conferma_email`, che **azzera la
|
||||||
|
conferma email di tutti gli utenti**.
|
||||||
|
- In prod non c'è un server di posta: subito dopo il deploy va eseguito
|
||||||
|
`php artisan utenti:conferma-email <email>` per ogni utente, altrimenti
|
||||||
|
nessuno riesce a lavorare. La posta si configura poi dall'app.
|
||||||
|
- `MAIL_SCHEME=tls` nell'ambiente di prod non è valido per Laravel 11:
|
||||||
|
svuotarlo. Prod è in HTTPS con `SESSION_SECURE_COOKIE=false`: valutare `true`.
|
||||||
|
- Il nuovo controllo dell'entrypoint su `APP_KEY` passa: chiave d'ambiente e
|
||||||
|
`storage/app/.app_key` coincidono.
|
||||||
|
- Ritorno indietro: ripristino del dump fatto prima del deploy e deploy del
|
||||||
|
`main` precedente (`bde7a4a`).
|
||||||
|
|
||||||
|
**Rilasciato in produzione il 19 ottobre 2026 alle 16:13** (`main` =
|
||||||
|
`e4549f8`, fast-forward da `dev`). Prima: backup in
|
||||||
|
`/etc/dokploy/termanager2-backups/prod-20261019-141137/` sul server (database,
|
||||||
|
file, conteggi) e copia del dump sul PC dell'utente; `MAIL_SCHEME` svuotato
|
||||||
|
nell'ambiente di prod. Deploy di 32 secondi, 11 migrazioni applicate senza
|
||||||
|
errori, email dei 4 utenti confermata con `utenti:conferma-email`. Verifica:
|
||||||
|
conteggi identici a prima del deploy, 111/111 proclamatori decifrati, 272/272
|
||||||
|
PDF presenti, campagna di settembre a 13 conteggiati (4,7%), nessun nome in
|
||||||
|
chiaro rimasto nel log. Subito dopo, su decisione dell'utente,
|
||||||
|
`SESSION_SECURE_COOKIE=true` nell'ambiente di prod (nuovo deploy: i cookie di
|
||||||
|
sessione e XSRF ora sono `secure; httponly; samesite=lax`). Resta da fare:
|
||||||
|
configurare la posta dall'app (*Impostazioni → Server di posta*) per poter
|
||||||
|
usare il canale Email.
|
||||||
|
|
||||||
|
Deploy: su Dokploy l'autodeploy da push è attivo (`triggerType=push`, nessun
|
||||||
|
filtro di percorso) e secondo l'utente funziona. Le variabili d'ambiente
|
||||||
|
invece diventano effettive solo con un nuovo deploy, che una loro modifica
|
||||||
|
non avvia da sola.
|
||||||
|
|
||||||
|
### Informativa privacy aggiornata e dinamica (19 ottobre 2026)
|
||||||
|
|
||||||
|
Richiesta dell'utente: aggiornare la sezione privacy con i dati attuali e
|
||||||
|
prendere i parametri dalle impostazioni. Il testo era fermo a una versione
|
||||||
|
precedente (solo nome e cognome, "tre ruoli: Admin, Servitore di Territorio,
|
||||||
|
Proclamatore Semplificato" che non esistono).
|
||||||
|
|
||||||
|
- `App\Livewire\Privacy` legge al momento: congregazione,
|
||||||
|
`audit_retention_days`, `giorni_per_smarrito` (scadenza del link),
|
||||||
|
`pdf_viewer_show_download`, server di posta (`ConfigurazionePosta`),
|
||||||
|
`config('app.cipher')`, `config('session.lifetime')`, `APP_URL` (avvisa se
|
||||||
|
non è HTTPS), i ruoli con i permessi veri (`Privacy::PERMESSI` li spiega) e
|
||||||
|
quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano
|
||||||
|
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
|
||||||
|
- Aggiunte: email/telefono cifrati, link alla piantina, invio via WhatsApp
|
||||||
|
(parte dal telefono di chi assegna) ed email (server di posta configurato),
|
||||||
|
mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi.
|
||||||
|
- Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali,
|
||||||
|
da far rivedere a chi ne ha la competenza.
|
||||||
|
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
|
||||||
|
legge i proclamatori dal modello e quindi esporta nome, cognome, email e
|
||||||
|
telefono **decifrati**. L'informativa ora lo dice; valutare se cifrare
|
||||||
|
l'archivio (per esempio con una password scelta al download).
|
||||||
|
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
|
||||||
|
i conteggi senza nomi e l'accesso solo amministratore.
|
||||||
|
|
||||||
|
### Pagina Assegna più leggera; "Vedi tutti" in testata nella Home (19 ottobre 2026)
|
||||||
|
|
||||||
|
Segnalazione dell'utente: la pagina Assegnazioni era lenta. Misurato su dev
|
||||||
|
con i dati di prod (79 assegnazioni aperte): il server la genera in ~80 ms
|
||||||
|
con 10 query, ma l'HTML pesava **387 KB** (299 l'elenco dei territori
|
||||||
|
assegnati, di cui 58 di icone SVG ripetute; 75 le opzioni dei campi di scelta)
|
||||||
|
e Livewire lo rimandava **per intero a ogni interazione**: scegliere un
|
||||||
|
territorio (anteprima con `wire:model.live`), scrivere nella ricerca
|
||||||
|
dell'elenco (`linkSearch`), aprire l'invio del link. Rimedi:
|
||||||
|
|
||||||
|
- L'elenco è un componente a sé, `Assegnazioni\ElencoAssegnati`, **lazy**: la
|
||||||
|
pagina mostra subito il form e le sue azioni non lo ridisegnano. "Invia
|
||||||
|
link" nell'elenco chiede alla pagina di aprire l'invio con l'evento
|
||||||
|
`apri-invio` (`Assegna::apriInvio` ha `#[On('apri-invio')]`);
|
||||||
|
`toggleLinkSent` sta nell'elenco.
|
||||||
|
- La ricerca nell'elenco filtra nel browser (`data-cerca` senza accenti,
|
||||||
|
Alpine `elencoCercabile`): nessuna richiesta mentre si scrive.
|
||||||
|
- Il territorio va a Livewire con `wire:model` (non più `.live`): l'anteprima
|
||||||
|
della miniatura segue la scelta nel browser (evento `scelta` di
|
||||||
|
`<x-scelta>`, URL della miniatura nelle opzioni).
|
||||||
|
- Righe compatte: icone come simboli SVG definiti una volta e richiamati con
|
||||||
|
`<use>`, classi brevi `tm-assegnato-*` in `app.css` al posto delle catene
|
||||||
|
di utility, copia del link con il metodo condiviso `copia()` di
|
||||||
|
`elencoCercabile`. I pulsanti restano `<x-btn>` (lo impone
|
||||||
|
`StileUnificatoTest`). Il ramo "link scaduto" dell'elenco (solo per non autenticati, cioè
|
||||||
|
mai su questa pagina) è stato tolto.
|
||||||
|
- `AssegnaPrestazioniTest` tiene fermi questi punti, compreso un tetto al
|
||||||
|
peso per riga dell'elenco.
|
||||||
|
- Corretto anche `Assegnazione::giorni`: con Carbon 3 `diffInDays()` è un
|
||||||
|
float e PHP segnalava una conversione implicita a ogni riga.
|
||||||
|
|
||||||
|
Home: "Vedi tutti" di *Da assegnare* e *Da rientrare* è ora un pulsante nella
|
||||||
|
testata del riquadro, accanto al PDF, invece che in fondo all'elenco. Compare
|
||||||
|
alle stesse condizioni di prima (elenco troncato al limite della Home).
|
||||||
|
|
||||||
|
### Scelta di territori e proclamatori con ricerca (19 ottobre 2026)
|
||||||
|
|
||||||
|
Al posto delle tendine di territorio e proclamatore (Assegna, finestra del
|
||||||
|
Registro, editor delle piantine) c'è `<x-scelta>`: si scrive e compaiono le
|
||||||
|
proposte più vicine; campo vuoto o appena aperto = la tendina completa.
|
||||||
|
|
||||||
|
- Ordine: uguale → inizia con → contiene → a una lettera di distanza ("forse
|
||||||
|
cercavi", due lettere dalle 7 in su; mai sui numeri sotto le 4 cifre, così
|
||||||
|
"35" non trova "36"). Le parole scritte valgono in qualsiasi ordine ("mario
|
||||||
|
ros"); maiuscole e accenti non contano. Logica pura in
|
||||||
|
`resources/js/scelta.js`, provata da `tests/js/scelta.test.mjs`
|
||||||
|
(`npm run test:js`, oppure `node --test tests/js/`).
|
||||||
|
- Le opzioni le prepara il server (`Territorio::opzioneScelta()`,
|
||||||
|
`Proclamatore::opzioneScelta()/opzioniScelta()`). Con `motivo` un'opzione
|
||||||
|
compare cercando ma non si sceglie: in Assegna i territori già assegnati
|
||||||
|
("già assegnato a …"), nel Registro territori e proclamatori disattivati.
|
||||||
|
- Nomi e cognomi sono cifrati: si ordinano e si cercano decifrati, nella
|
||||||
|
pagina. Prima il Registro li ordinava in SQL, cioè a caso.
|
||||||
|
- Telefono: testo a 16px sotto i 640px (iOS non ingrandisce), voci alte 44px,
|
||||||
|
elenco in mezzo schermo, scelta al tocco senza perdere il focus; dopo la
|
||||||
|
scelta col dito il campo perde il focus e la tastiera si chiude.
|
||||||
|
- Con Livewire: `wire:model` sul componente (x-modelable). `vuoto` è il valore
|
||||||
|
di "nessuna scelta": `null` per le proprietà `?int` (Assegna), `''` per le
|
||||||
|
stringhe. Il componente si ridisegna quando cambiano le opzioni (wire:key
|
||||||
|
con l'impronta), perché x-data le legge una volta sola.
|
||||||
|
- Il filtro "Filtra territori" di Assegna non serve più ed è stato tolto.
|
||||||
|
|
||||||
|
### Si torna da dove si è partiti (19 ottobre 2026)
|
||||||
|
|
||||||
|
Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna
|
||||||
|
alla pagina da cui il percorso era partito, con i suoi filtri, ricerca,
|
||||||
|
ordinamento e pagina. Elenco filtrato → scheda → Rientra → conferma riporta
|
||||||
|
all'**elenco filtrato**, non alla scheda (scelta esplicita).
|
||||||
|
|
||||||
|
- Gli elenchi (territori, proclamatori, registro) tengono ricerca, filtri e
|
||||||
|
ordinamento nell'indirizzo con `#[Url]`; la pagina c'era già (paginazione).
|
||||||
|
- I link verso schede e operazioni hanno `data-ritorno`: `resources/js/app.js`
|
||||||
|
gli aggiunge `?da=<percorso>` al momento dell'uso (pointerdown/click/menu
|
||||||
|
contestuale/focus, prima di wire:navigate). Se la pagina corrente ha già un
|
||||||
|
`da`, passa quello: si risale sempre all'origine.
|
||||||
|
- `App\Support\Ritorno::valida()` accetta solo percorsi interni (niente
|
||||||
|
`https://…`, `//…`, `\`, caratteri di controllo, `/livewire/…`): il
|
||||||
|
parametro non può diventare un rimando verso l'esterno.
|
||||||
|
- Le pagine di scheda e operazione usano il trait `RicordaRitorno`
|
||||||
|
(`$ritorno`, `destinazione()`): redirect dopo il salvataggio, "Annulla" e
|
||||||
|
"Torna…" (che con un ritorno diventa "Torna indietro").
|
||||||
|
- Aperte senza `da` (menu laterale, link salvato) tutto resta com'era.
|
||||||
|
- Link nuovi verso una scheda o un'operazione: metterci `data-ritorno`;
|
||||||
|
pagine nuove di operazione: usare `RicordaRitorno`.
|
||||||
|
|
||||||
|
### Invio del link dopo l'assegnazione, nuovo territorio dal designer (19 ottobre 2026)
|
||||||
|
|
||||||
|
Richieste dell'utente:
|
||||||
|
|
||||||
|
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
|
||||||
|
apre una finestra che blocca la pagina (`Assegna::apriInvio`) con i canali
|
||||||
|
- *Copia link*: sempre;
|
||||||
|
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
|
||||||
|
- *Email*: solo se ha un indirizzo **e** la posta funziona
|
||||||
|
(`ConfigurazionePosta::funzionante`). La manda l'app con
|
||||||
|
`Notification::route('mail', …)->notifyNow(new LinkTerritorio)`: **mai in
|
||||||
|
coda**, perché il job salverebbe in chiaro su Redis/`failed_jobs`
|
||||||
|
l'indirizzo che nel database è cifrato.
|
||||||
|
|
||||||
|
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
|
||||||
|
possibile. Solo dopo la risposta si ricarica la pagina.
|
||||||
|
- **Se la procedura non si conclude** (scheda chiusa) o si risponde *No*, resta
|
||||||
|
`assegnazioni.link_da_inviare = true` e compare "Link ancora da inviare"
|
||||||
|
nell'elenco della pagina Assegna (con "Invia link", che riapre la finestra)
|
||||||
|
e nella scheda del territorio (che rimanda a `assegnazioni/assegna?invia=<id>`).
|
||||||
|
L'avviso sparisce quando il link risulta inviato, anche dal pulsante
|
||||||
|
"Non inviato / Link inviato" dell'elenco.
|
||||||
|
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
|
||||||
|
assegnazioni già esistenti restano false: niente avvisi a posteriori su
|
||||||
|
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`,
|
||||||
|
`Assegnazione::CANALI_LINK`). Entrambe nell'XML.
|
||||||
|
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
|
||||||
|
mai nome, numero o email. Email e telefono restano solo, cifrati, nel
|
||||||
|
proclamatore.
|
||||||
|
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
|
||||||
|
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
|
||||||
|
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
|
||||||
|
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
|
||||||
|
`settings.manage` resta sul form `territori.create`, che esiste ancora.
|
||||||
|
|
||||||
|
### Revisione profonda: blocchi 2 e 3 chiusi
|
||||||
|
|
||||||
|
| Problema | Correzione |
|
||||||
|
|---|---|
|
||||||
|
| Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) | trait `Avvisa` + `<x-avvisi>`; un test vieta `session()->flash()` senza redirect |
|
||||||
|
| PDF, miniature e versioni scaricabili da `/storage/territori-*` senza login (nomi indovinabili, cache Cloudflare 7 giorni) | nginx risponde 404 su `/storage/territori-`; `TerritorioFileController` con permessi della pagina e cache privata; `Territorio::pdfUrl()` / `miniaturaUrl()` |
|
||||||
|
| Ripristino non atomico | estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (`rename`, annullabile) → file vecchi cancellati solo dopo il commit |
|
||||||
|
| Piantine, storico PDF, area KML fuori dal backup | formato archivio 2 (`piantine.json`, `piantine/…`, `PiantineBackup`); gli archivi di formato 1 si ripristinano lo stesso |
|
||||||
|
| Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi | `BackupOperationState::occupa()` sotto `Cache::lock`, usato da tutte le porte; rilascio anche su errore e in `failed()` |
|
||||||
|
| Import PDF da cartella: `1-A` → territorio 1, `12 B` ambiguo, solo 20 file, `Thumbs.db` blocca tutto, `__MACOSX` | abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati |
|
||||||
|
| Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo | prima il nuovo, poi il vecchio nello storico (`PiantinaPdfService::archiviaCorrente`, origini `import` / `manuale`) |
|
||||||
|
| L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) | l'import elimina i file non più usati; per i vecchi c'è `php artisan territori:file-orfani` (elenca; `--elimina` cancella) — **da lanciare quando si decide** |
|
||||||
|
| Azzeramento lasciava piantine orfane, area KML, anteprime | cancellate anche quelle |
|
||||||
|
| `/cambia-password` senza password attuale | richiesta, tranne dopo un ripristino (cambio obbligatorio) |
|
||||||
|
| Sessioni aperte altrove non chiuse al cambio password | `AuthenticateSession` nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori |
|
||||||
|
| Nomi dei proclamatori in chiaro nel registro attività | si salva `proclamatore_id`, il registro mostra il nome attuale; migrazione `2026_10_12_000010` ripulisce le voci vecchie |
|
||||||
|
| Export XML con `remember_token`; l'import XML non obbligava a cambiare password | token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password |
|
||||||
|
| Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% | confronto per data; si contano i territori attivi distinti |
|
||||||
|
| `retry_after` 90 s con job fino a 900 s | `config/queue.php`: 960 s |
|
||||||
|
| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` |
|
||||||
|
| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` |
|
||||||
|
| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) |
|
||||||
|
| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) |
|
||||||
|
| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` |
|
||||||
|
| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore |
|
||||||
|
|
||||||
|
Non cambiato, di proposito: `trustProxies('*')` (commento in
|
||||||
|
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
|
||||||
|
|
||||||
|
Suite: **429 test verdi**. Provati in browser in locale: file protetti (404
|
||||||
|
senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2
|
||||||
|
lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato).
|
||||||
|
|
||||||
|
**Non provato / aperto:**
|
||||||
|
- chiusura delle sessioni con due browser veri; coda e worker con un riavvio
|
||||||
|
reale; modifica vertici al tocco su tablet vero;
|
||||||
|
- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche
|
||||||
|
lotto riceve 429 (il browser lo segnala);
|
||||||
|
- numeri di territorio con lettere davanti ("A12") non si abbinano da nome
|
||||||
|
file: il PDF si carica dalla scheda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Da dove veniamo
|
## Da dove veniamo
|
||||||
|
|
||||||
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
|
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
|
||||||
@@ -212,13 +583,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
|
|||||||
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
|
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
|
||||||
`territori` come fa ogni import XML, non porta più via i disegni.
|
`territori` come fa ogni import XML, non porta più via i disegni.
|
||||||
|
|
||||||
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e
|
**Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
|
||||||
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo
|
piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
|
||||||
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga
|
scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
|
||||||
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato.
|
si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
|
||||||
|
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
|
||||||
|
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
|
||||||
|
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
|
||||||
|
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
|
||||||
|
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
|
||||||
|
|
||||||
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si
|
**Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
|
||||||
rigenera un territorio alla volta dall'elenco.
|
il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
|
||||||
|
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
|
||||||
|
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
|
||||||
|
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
|
||||||
|
PDF cambia solo con **Pubblica**, uguale in editor e gestione
|
||||||
|
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
|
||||||
|
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
|
||||||
|
Cloudflare), che genera immagine e PDF sul disco **privato**
|
||||||
|
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
|
||||||
|
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
|
||||||
|
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
|
||||||
|
qualcosa di stampato, la conferma rifiuta.
|
||||||
|
|
||||||
|
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
|
||||||
|
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
|
||||||
|
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
|
||||||
|
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
|
||||||
|
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
|
||||||
|
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
|
||||||
|
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
|
||||||
|
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
|
||||||
|
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
|
||||||
|
in vigore del territorio veniva da una piantina.
|
||||||
|
|
||||||
|
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
|
||||||
|
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
|
||||||
|
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
|
||||||
|
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
|
||||||
|
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
|
||||||
|
componente `<x-avvisi>`): i flash di sessione nel telaio non si vedono dopo
|
||||||
|
un'azione Livewire.
|
||||||
|
|
||||||
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
|
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
|
||||||
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
|
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
|
||||||
@@ -234,11 +640,6 @@ nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
|
|||||||
marca i territori collegati senza zona: senza, l'intestazione della cartolina
|
marca i territori collegati senza zona: senza, l'intestazione della cartolina
|
||||||
esce vuota e ce se ne accorge in stampa.
|
esce vuota e ce se ne accorge in stampa.
|
||||||
|
|
||||||
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
|
|
||||||
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
|
|
||||||
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
|
|
||||||
dopo significa trovarselo già nello storico.
|
|
||||||
|
|
||||||
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
||||||
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
|
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
|
||||||
|
|
||||||
@@ -650,13 +1051,16 @@ Scelte non ovvie:
|
|||||||
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
|
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
|
||||||
sopravvive, quella dell'azzeramento stesso.
|
sopravvive, quella dell'azzeramento stesso.
|
||||||
|
|
||||||
- **Ordine del restore: staging → database → file.** I file si estraggono
|
- **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026
|
||||||
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
|
(`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il
|
||||||
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi
|
`piantine.json` del formato 2) prima di toccare qualcosa; poi una sola
|
||||||
`importXmlFromContent()` in transazione; poi la sostituzione dei file. Il
|
`DB::transaction` contiene import XML, tabelle delle piantine, blocco degli
|
||||||
disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe
|
account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`:
|
||||||
appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna
|
solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa
|
||||||
schermata mostra e nessuna cancellazione raggiunge.
|
fallisce si torna allo stato di prima; i file vecchi aspettano in
|
||||||
|
`storage/app/backup-precedenti/<uuid>` e si cancellano solo dopo il commit.
|
||||||
|
Il disco `public` viene sostituito, non fuso: i percorsi vivono nelle righe
|
||||||
|
appena riscritte.
|
||||||
|
|
||||||
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
|
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
|
||||||
numero di entry, dimensione della singola entry e totale decompresso, pochi
|
numero di entry, dimensione della singola entry e totale decompresso, pochi
|
||||||
@@ -938,10 +1342,12 @@ Le scelte che vale la pena conoscere:
|
|||||||
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con
|
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con
|
||||||
la chiave di produzione, che vive nell'ambiente e nel volume.
|
la chiave di produzione, che vive nell'ambiente e nel volume.
|
||||||
|
|
||||||
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è
|
- **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e
|
||||||
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto:
|
PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano
|
||||||
renderla pubblica solo per poterla testare avrebbe allargato la superficie
|
direttamente; il componente `Registro` si limita a validare l'anno e
|
||||||
del componente per comodità del test.
|
scaricare. Il PDF scrive sopra il modulo ufficiale
|
||||||
|
(`resources/pdf/S-13_I.pdf`, convertito in PDF 1.4 per FPDI) alle
|
||||||
|
coordinate della sua griglia, come la cartolina S-12.
|
||||||
|
|
||||||
### Un solo container esegue le migrazioni
|
### Un solo container esegue le migrazioni
|
||||||
|
|
||||||
@@ -956,6 +1362,12 @@ mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
|
|||||||
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
|
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
|
||||||
provider **solleva un'eccezione** se manca, invece di rigenerarla.
|
provider **solleva un'eccezione** se manca, invece di rigenerarla.
|
||||||
|
|
||||||
|
Dal 09/10/2026 l'entrypoint **si rifiuta di partire** se `APP_KEY`
|
||||||
|
nell'ambiente è diversa dalla copia in `storage/app/.app_key`: prima la
|
||||||
|
sovrascriveva, e un valore sbagliato in Dokploy distruggeva l'unica copia della
|
||||||
|
chiave giusta. Per un cambio voluto si aggiunge `APP_KEY_ROTATE=1` (la chiave
|
||||||
|
vecchia resta in `.app_key.prev-<data>`) e poi la si toglie.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug corretti — i più significativi
|
## Bug corretti — i più significativi
|
||||||
@@ -984,6 +1396,14 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
|
|||||||
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` |
|
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` |
|
||||||
| Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` |
|
| Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` |
|
||||||
| Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` |
|
| Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` |
|
||||||
|
| Date esportate in UTC | Ogni backup → ripristino (e export → import XML) spostava **tutte** le date indietro di un giorno, sommandosi a ogni giro. I file vecchi vengono riportati al giorno giusto all'import | `XmlExchange::righeEsportate()`, `normalizeDateForDb()` |
|
||||||
|
| Import di qualsiasi XML | Un XML ben formato ma estraneo svuotava il database con "completato con successo". Ora servono la radice `<termanager2>` e le sezioni principali | `XmlExchange::importXmlFromContent()` |
|
||||||
|
| Caratteri di controllo nell'export | Uno solo, in una nota, rendeva il file (e il `data.xml` del backup) non reimportabile | `XmlExchange::addXmlText()` |
|
||||||
|
| Territori senza zona scartati all'import | Sparivano con tutto il loro storico di assegnazioni | `XmlExchange::importXmlFromContent()` |
|
||||||
|
| Import che estromette chi lo esegue | Password vecchia e ruoli del file applicati anche all'amministratore che importava | `XmlExchange::importXmlFromContent()` |
|
||||||
|
| `whereNull` fuori da `latestOfMany` | Con una chiusa più recente dell'aperta il territorio risultava libero mentre era fuori, e assegnabile due volte | `Territorio::assegnazioneCorrente()` |
|
||||||
|
| Periodi di assegnazione accavallati | Uscite retrodatate a prima dell'ultimo rientro, doppie aperte dal Registro, anno scelto a mano incoerente con la data | `Assegnazione::sovrappostaA()`, `Assegna`, `Registro` |
|
||||||
|
| Declassamento dell'ultimo amministratore | Togliendosi il ruolo l'installazione restava senza nessuno che potesse gestirla | `Settings/UsersIndex` |
|
||||||
| `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` |
|
| `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` |
|
||||||
| `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
|
| `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
|
||||||
| Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` |
|
| Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` |
|
||||||
@@ -1031,7 +1451,7 @@ docker compose --profile test run --build --rm test
|
|||||||
|------|---------------|
|
|------|---------------|
|
||||||
| `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio |
|
| `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio |
|
||||||
| `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata |
|
| `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata |
|
||||||
| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" compreso il caso in cui l'ultima del giro precedente è ancora aperta, esclusione di territori disattivati e cestinati, filtro sull'anno di servizio, ordinamento naturale dei numeri |
|
| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" (fine ciclo e ultimo rientro prima dell'anno), assegnazioni a cavallo d'anno su entrambi i fogli, una riga per ogni territorio attivo anche senza assegnazioni, esclusione di territori disattivati e cestinati, ordinamento naturale dei numeri, fogli da 20 righe e generazione del PDF |
|
||||||
| `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede |
|
| `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede |
|
||||||
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
||||||
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
||||||
@@ -1073,15 +1493,14 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
|
|||||||
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
||||||
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
||||||
|
|
||||||
### 2. Race su doppia assegnazione
|
### 2. Race su doppia assegnazione — chiusa il 09/10/2026
|
||||||
|
|
||||||
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma
|
`Assegna::save()` e il salvataggio del Registro controllano e scrivono dentro
|
||||||
nulla a livello di database impedisce due assegnazioni aperte sullo stesso
|
una transazione con `lockForUpdate()` sulla riga del territorio, e rifiutano
|
||||||
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`,
|
ogni periodo che si accavalla a un altro (`Assegnazione::sovrappostaA()`). Non
|
||||||
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi.
|
c'è ancora un vincolo nel database: l'import XML e i dati storici possono
|
||||||
|
contenere sovrapposizioni, che il controllo segnala solo quando si modifica
|
||||||
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna
|
una di quelle righe.
|
||||||
generata che imponga una sola assegnazione aperta per territorio.
|
|
||||||
|
|
||||||
### 3. Scrittura durante il rendering
|
### 3. Scrittura durante il rendering
|
||||||
|
|
||||||
@@ -1132,21 +1551,12 @@ pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
|
|||||||
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
|
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
|
||||||
`pagination::`. L'esclusione resta a guardia di future viste di terze parti.
|
`pagination::`. L'esclusione resta a guardia di future viste di terze parti.
|
||||||
|
|
||||||
### 7. Il backup non contiene l'area KML delle piantine
|
### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026
|
||||||
|
|
||||||
`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e
|
Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe`
|
||||||
`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non
|
e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
|
||||||
dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a
|
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
|
||||||
mano dal KML originale, che quindi conviene conservare fuori di qui.
|
lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
|
||||||
|
|
||||||
I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave
|
|
||||||
esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che
|
|
||||||
fa ogni import XML — scollegati, ma recuperabili.
|
|
||||||
|
|
||||||
Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che
|
|
||||||
tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è
|
|
||||||
stato fatto perché il KML è un file che l'utente ha già sul suo computer: la
|
|
||||||
perdita è un fastidio, non un dato irrecuperabile.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
85
README.md
85
README.md
@@ -338,6 +338,16 @@ Cosa serve sapere:
|
|||||||
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
|
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
|
||||||
provato sullo stack vero, non qui.
|
provato sullo stack vero, non qui.
|
||||||
|
|
||||||
|
La logica JavaScript senza DOM (la ricerca nei campi di scelta,
|
||||||
|
`resources/js/scelta.js`) ha i suoi test in `tests/js/`, con il test runner di
|
||||||
|
Node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:js
|
||||||
|
# oppure, senza Node installato:
|
||||||
|
docker run --rm -v "$PWD":/app -w /app node:20-alpine node --test tests/js/
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Variabili d'ambiente
|
## Variabili d'ambiente
|
||||||
@@ -678,11 +688,15 @@ docker compose --profile dev up -d --build
|
|||||||
- Media mensile di percorrenza e durata media delle assegnazioni
|
- Media mensile di percorrenza e durata media delle assegnazioni
|
||||||
- Campagna attiva con barra di progresso
|
- Campagna attiva con barra di progresso
|
||||||
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
|
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
|
||||||
**da rientrare**
|
**da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante
|
||||||
|
in testata che apre l'elenco territori già filtrato
|
||||||
|
|
||||||
### Territori
|
### Territori
|
||||||
- CRUD completo con upload PDF e miniatura generata automaticamente
|
- CRUD completo con upload PDF e miniatura generata automaticamente
|
||||||
- Filtri per zona, tipologia e stato
|
- **"Nuovo territorio"** apre il designer delle piantine già in disegno: il
|
||||||
|
territorio si crea dal confine disegnato (per chi non è amministratore resta
|
||||||
|
il form)
|
||||||
|
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
|
||||||
- Badge di stato colorati e indicatore di priorità (manuale o automatica)
|
- Badge di stato colorati e indicatore di priorità (manuale o automatica)
|
||||||
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
|
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
|
||||||
- Link PDF pubblico condivisibile, a scadenza configurabile
|
- Link PDF pubblico condivisibile, a scadenza configurabile
|
||||||
@@ -696,12 +710,31 @@ docker compose --profile dev up -d --build
|
|||||||
- Soft delete con cestino
|
- Soft delete con cestino
|
||||||
|
|
||||||
### Assegnazione e rientro
|
### Assegnazione e rientro
|
||||||
- Selezione territorio disponibile e proclamatore attivo
|
- **Scelta con ricerca** di territorio e proclamatore (anche nel Registro e nel
|
||||||
|
designer delle piantine): si scrive e compaiono le proposte più vicine —
|
||||||
|
uguale, inizia con, contiene, errore di battitura ("forse cercavi") — con
|
||||||
|
le parole in qualsiasi ordine, senza badare a maiuscole e accenti. Campo
|
||||||
|
vuoto: l'elenco completo. Pensata anche per il telefono
|
||||||
|
- I territori già assegnati si trovano cercando ("già assegnato a …") ma non
|
||||||
|
si possono scegliere; l'anteprima della miniatura segue la scelta
|
||||||
- Anno teocratico rilevato automaticamente dalla data
|
- Anno teocratico rilevato automaticamente dalla data
|
||||||
- Validazione: territorio non già assegnato, entrambi attivi
|
- Validazione: territorio non già assegnato, entrambi attivi
|
||||||
|
- **Invio del link dopo l'assegnazione**: una finestra chiede il canale —
|
||||||
|
*Copia link*, oppure *Condividi…* (il menu di condivisione del telefono:
|
||||||
|
WhatsApp, Telegram, SMS…, dove il browser lo offre) — e se il link è stato
|
||||||
|
inviato. Il destinatario lo sceglie chi usa l'app dai propri contatti: i
|
||||||
|
proclamatori non hanno numero né email. Solo dopo la risposta si va avanti;
|
||||||
|
altrimenti l'assegnazione resta segnalata **"Link ancora da inviare"**, con
|
||||||
|
"Invia link" per riprendere. Anche dall'elenco dei territori assegnati si
|
||||||
|
può copiare o condividere il link
|
||||||
|
- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra
|
||||||
|
nel browser, senza attese
|
||||||
- Rientro con calcolo automatico dei giorni
|
- Rientro con calcolo automatico dei giorni
|
||||||
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene
|
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
|
||||||
chiesto se conteggiarlo
|
chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
|
||||||
|
compare un avviso di tempo di percorrenza molto alto
|
||||||
|
- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i
|
||||||
|
suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima)
|
||||||
|
|
||||||
### Campagne
|
### Campagne
|
||||||
- CRUD con date di inizio e fine
|
- CRUD con date di inizio e fine
|
||||||
@@ -764,8 +797,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
## Sicurezza e GDPR
|
## Sicurezza e GDPR
|
||||||
|
|
||||||
### Sicurezza
|
### Sicurezza
|
||||||
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con AES-256-CBC
|
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
|
||||||
tramite `APP_KEY`
|
AES-256-CBC tramite `APP_KEY`
|
||||||
- **Password**: hash bcrypt
|
- **Password**: hash bcrypt
|
||||||
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
|
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
|
||||||
richieste Livewire
|
richieste Livewire
|
||||||
@@ -779,15 +812,32 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
||||||
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
||||||
restituisce 404
|
restituisce 404
|
||||||
|
- **Versione di PHP fissata alla patch** (`ARG PHP_VERSION` nel Dockerfile,
|
||||||
|
oggi 8.3.36 per CVE-2026-109618/109619): una build o prende quella versione
|
||||||
|
o fallisce, mai una base vecchia rimasta in cache. Va alzata a ogni rilascio
|
||||||
|
di sicurezza del ramo 8.3
|
||||||
|
- **Sessioni native di PHP chiuse**: `session.upload_progress.enabled = 0` e
|
||||||
|
`session.use_strict_mode = 1` in `php.ini` (l'applicazione usa le sessioni
|
||||||
|
di Laravel; è la mitigazione indicata per le stesse CVE)
|
||||||
|
|
||||||
### GDPR
|
### GDPR
|
||||||
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e stato
|
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
|
||||||
|
cambiare — congregazione, conservazione del log, scadenza dei link,
|
||||||
|
download del PDF, server di posta, durata della sessione, indirizzo e HTTPS,
|
||||||
|
ruoli con i loro permessi, quanti dati sono conservati — si leggono al
|
||||||
|
momento da impostazioni e configurazione (`App\Livewire\Privacy`)
|
||||||
|
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e
|
||||||
|
stato. Niente telefono né email: il link lo manda chi assegna, scegliendo il
|
||||||
|
destinatario dai propri contatti, e l'app non vede quei contatti
|
||||||
- **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB
|
- **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB
|
||||||
- **Audit trail**: registro completo delle operazioni
|
- **Audit trail**: registro completo delle operazioni
|
||||||
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
||||||
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
||||||
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
||||||
in chiaro
|
in chiaro
|
||||||
|
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
||||||
|
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
||||||
|
in un luogo protetto e cancellati quando non servono
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -799,11 +849,11 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
| `settings` | Configurazione singleton |
|
| `settings` | Configurazione singleton |
|
||||||
| `zone` | Zone territoriali |
|
| `zone` | Zone territoriali |
|
||||||
| `tipologie` | Tipologie di territorio |
|
| `tipologie` | Tipologie di territorio |
|
||||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete |
|
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) |
|
||||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
||||||
| `anni_teocratici` | Periodi settembre → agosto |
|
| `anni_teocratici` | Periodi settembre → agosto |
|
||||||
| `campagne` | Periodi speciali con date e descrizione |
|
| `campagne` | Periodi speciali con date e descrizione |
|
||||||
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna |
|
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`) |
|
||||||
| `activity_log` | Audit trail |
|
| `activity_log` | Audit trail |
|
||||||
|
|
||||||
**Vincoli e indici principali**
|
**Vincoli e indici principali**
|
||||||
@@ -842,8 +892,17 @@ sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
|
|||||||
quando la soglia è scattata.
|
quando la soglia è scattata.
|
||||||
|
|
||||||
### Percentuale di percorrenza delle campagne
|
### Percentuale di percorrenza delle campagne
|
||||||
- **Denominatore**: tutti i territori assegnati nel periodo della campagna
|
- **Denominatore**: tutti i territori attivi
|
||||||
- **Numeratore**: le assegnazioni contrassegnate come conteggiate
|
- **Numeratore**: i territori attivi distinti con almeno un'assegnazione
|
||||||
|
collegata alla campagna e contrassegnata come conteggiata al rientro (un
|
||||||
|
territorio percorso due volte conta una volta sola)
|
||||||
|
|
||||||
|
Al rientro la campagna viene proposta se il territorio era fuori almeno in
|
||||||
|
parte del periodo: assegnato durante la campagna, rientrato durante la
|
||||||
|
campagna, oppure fuori per tutta la sua durata. Se sia stato davvero percorso
|
||||||
|
lo decide l'operatore con la spunta. Se più campagne corrispondono viene
|
||||||
|
proposta la più recente. I territori assegnati durante la campagna e non
|
||||||
|
ancora rientrati compaiono nel dettaglio come "in corso".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -854,7 +913,7 @@ quando la soglia è scattata.
|
|||||||
| Componente | Tecnologia |
|
| Componente | Tecnologia |
|
||||||
|-------------------|--------------------------------------------|
|
|-------------------|--------------------------------------------|
|
||||||
| Framework | Laravel 11 |
|
| Framework | Laravel 11 |
|
||||||
| PHP | 8.3 (FPM) |
|
| PHP | 8.3 (FPM), versione fissata nel Dockerfile |
|
||||||
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
||||||
| Database | MariaDB 11 |
|
| Database | MariaDB 11 |
|
||||||
| Cache/Sessioni/Code| Redis 7 |
|
| Cache/Sessioni/Code| Redis 7 |
|
||||||
|
|||||||
44
app/Console/Commands/ConfermaEmailUtente.php
Normal file
44
app/Console/Commands/ConfermaEmailUtente.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conferma a mano l'email di un utente, dalla riga di comando.
|
||||||
|
*
|
||||||
|
* Per l'emergenza: la posta non funziona e nessun amministratore confermato
|
||||||
|
* può sistemarla (per esempio subito dopo l'aggiornamento che ha reso
|
||||||
|
* obbligatoria la conferma). Chi ha accesso al server è già fidato.
|
||||||
|
*/
|
||||||
|
class ConfermaEmailUtente extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'utenti:conferma-email {email : Indirizzo dell\'utente da confermare}';
|
||||||
|
|
||||||
|
protected $description = 'Segna come confermata l\'email di un utente (emergenza: posta non funzionante)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$user = User::where('email', $this->argument('email'))->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
$this->error('Nessun utente con questo indirizzo.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
$this->info("{$user->email} è già confermato.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
activity()->performedOn($user)->log('email_confermata_da_riga_di_comando');
|
||||||
|
|
||||||
|
$this->info("Email di {$user->email} confermata.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,11 @@ class CreateInitialAdmin extends Command
|
|||||||
'password' => Hash::make($password),
|
'password' => Hash::make($password),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Confermato da subito: su un'installazione nuova il server di
|
||||||
|
// posta non c'è ancora, e chi ha accesso alla riga di comando del
|
||||||
|
// server non ha bisogno di dimostrare di chi è l'indirizzo. Senza,
|
||||||
|
// il primo amministratore arriverebbe solo alla pagina della posta.
|
||||||
|
$user->markEmailAsVerified();
|
||||||
$user->assignRole('amministratore');
|
$user->assignRole('amministratore');
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
|||||||
79
app/Console/Commands/TerritoriFileOrfani.php
Normal file
79
app/Console/Commands/TerritoriFileOrfani.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenca (e su richiesta cancella) i file dei territori che nessuna riga usa.
|
||||||
|
*
|
||||||
|
* Fino all'ottobre 2026 l'import XML ricreava i territori con id nuovi senza
|
||||||
|
* toccare i loro file: PDF, miniature e versioni dei territori di prima
|
||||||
|
* restavano sul disco, e passavano da un backup all'altro perché l'archivio
|
||||||
|
* copia tutto il disco pubblico. Oggi l'import li elimina da sé; questo
|
||||||
|
* comando serve a ripulire quelli accumulati prima.
|
||||||
|
*
|
||||||
|
* Di default non cancella niente: elenca. Con --elimina cancella.
|
||||||
|
*/
|
||||||
|
class TerritoriFileOrfani extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'territori:file-orfani {--elimina : Cancella i file trovati invece di elencarli}';
|
||||||
|
|
||||||
|
protected $description = 'Elenca (o cancella con --elimina) i file dei territori che nessuna riga usa più';
|
||||||
|
|
||||||
|
private const CARTELLE = ['territori-pdf', 'territori-thumbnails', 'territori-pdf-versioni', 'territori-mappe'];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$usati = array_flip(array_merge(
|
||||||
|
XmlExchange::fileDeiTerritori(),
|
||||||
|
array_filter([Setting::instance()->logo_path]),
|
||||||
|
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$disco = Storage::disk('public');
|
||||||
|
$orfani = [];
|
||||||
|
$byte = 0;
|
||||||
|
|
||||||
|
foreach (self::CARTELLE as $cartella) {
|
||||||
|
foreach ($disco->files($cartella) as $file) {
|
||||||
|
if (! isset($usati[$file])) {
|
||||||
|
$orfani[] = $file;
|
||||||
|
$byte += $disco->size($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$peso = number_format($byte / 1048576, 1, ',', '.') . ' MB';
|
||||||
|
|
||||||
|
if ($orfani === []) {
|
||||||
|
$this->info('Nessun file orfano.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->option('elimina')) {
|
||||||
|
foreach ($orfani as $file) {
|
||||||
|
$this->line($file);
|
||||||
|
}
|
||||||
|
$this->info(count($orfani) . " file orfani ({$peso}). Rilancia con --elimina per cancellarli.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cancellati = 0;
|
||||||
|
foreach ($orfani as $file) {
|
||||||
|
if ($disco->delete($file)) {
|
||||||
|
$cancellati++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("{$cancellati} file orfani cancellati ({$peso}).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,23 +43,30 @@ class PasswordChangeController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
// La password attuale NON viene richiesta, ed e' una scelta.
|
$user = $request->user();
|
||||||
|
|
||||||
|
// La password attuale si chiede solo a chi arriva qui di sua
|
||||||
|
// iniziativa, come fa il profilo.
|
||||||
//
|
//
|
||||||
// Il ripristino sovrascrive gli hash con quelli del backup: subito dopo,
|
// Dopo un ripristino no: gli hash sono quelli del backup, e la
|
||||||
// la "password attuale" di un account e' quella che aveva all'epoca del
|
// "password attuale" di un account e' quella che aveva all'epoca, che
|
||||||
// backup, che l'utente puo' benissimo non ricordare — l'amministratore
|
// l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
|
||||||
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori
|
// proprio le persone che questa schermata deve sbloccare; la garanzia li'
|
||||||
// proprio le persone che questa schermata deve sbloccare. La garanzia
|
// e' la sessione. Fuori da quel caso, invece, bastava una sessione
|
||||||
// qui e' la sessione: si arriva a questa pagina gia' autenticati.
|
// lasciata aperta per cambiare la password a qualcun altro e chiuderlo
|
||||||
$validated = $request->validate([
|
// fuori dal suo account.
|
||||||
'password' => ['required', 'confirmed', Password::min(8)],
|
$regole = ['password' => ['required', 'confirmed', Password::min(8)]];
|
||||||
], [
|
if (! $user->password_change_required) {
|
||||||
|
$regole['current_password'] = ['required', 'current_password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate($regole, [
|
||||||
'password.required' => 'Inserisci la nuova password.',
|
'password.required' => 'Inserisci la nuova password.',
|
||||||
'password.confirmed' => 'Le due password non coincidono.',
|
'password.confirmed' => 'Le due password non coincidono.',
|
||||||
|
'current_password.required' => 'Inserisci la password attuale.',
|
||||||
|
'current_password.current_password' => 'La password attuale non è corretta.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
// Il cast `hashed` sul modello cifra il valore al salvataggio.
|
// Il cast `hashed` sul modello cifra il valore al salvataggio.
|
||||||
$user->update([
|
$user->update([
|
||||||
'password' => $validated['password'],
|
'password' => $validated['password'],
|
||||||
@@ -68,7 +75,10 @@ class PasswordChangeController extends Controller
|
|||||||
|
|
||||||
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||||
// ripristino le vecchie credenziali sono da considerarsi compromesse e
|
// ripristino le vecchie credenziali sono da considerarsi compromesse e
|
||||||
// una sessione aperta altrove lo e' altrettanto.
|
// una sessione aperta altrove lo e' altrettanto. Le altre le chiude
|
||||||
|
// AuthenticateSession (bootstrap/app.php) alla loro prossima richiesta,
|
||||||
|
// perche' l'hash della password e' cambiato; qui si cambia l'id di
|
||||||
|
// questa e si invalida il "ricordami".
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
$user->setRememberToken(null);
|
$user->setRememberToken(null);
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|||||||
77
app/Http/Controllers/Auth/VerificaEmailController.php
Normal file
77
app/Http/Controllers/Auth/VerificaEmailController.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ConfigurazionePosta;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conferma dell'indirizzo email degli utenti.
|
||||||
|
*
|
||||||
|
* Finché l'indirizzo non è confermato l'account non funziona: il middleware
|
||||||
|
* `verified` porta qui. Il link della mail funziona anche senza essere
|
||||||
|
* entrati: lo si apre spesso dal telefono, dove la sessione non c'è, e la
|
||||||
|
* firma dell'indirizzo (con scadenza) basta a garantire che venga dalla mail.
|
||||||
|
*/
|
||||||
|
class VerificaEmailController extends Controller
|
||||||
|
{
|
||||||
|
public function avviso(Request $request, ConfigurazionePosta $posta)
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.verifica-email', [
|
||||||
|
'user' => $request->user(),
|
||||||
|
'postaFunzionante' => $posta->funzionante(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invia(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Email di conferma non inviata.', ['user' => $user->id, 'exception' => $e]);
|
||||||
|
|
||||||
|
return back()->with('error', 'L\'email non è partita: il server di posta non risponde o rifiuta l\'invio. Avvisa un amministratore.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', "Email inviata a {$user->email}. Apri il link che contiene (controlla anche lo spam).");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function conferma(Request $request, int $id, string $hash)
|
||||||
|
{
|
||||||
|
$user = User::find($id);
|
||||||
|
|
||||||
|
// La firma garantisce id e scadenza; l'hash lega il link all'indirizzo
|
||||||
|
// di allora, così un link vecchio non conferma un'email cambiata dopo.
|
||||||
|
if (! $user || ! hash_equals(sha1($user->getEmailForVerification()), $hash)) {
|
||||||
|
abort(403, 'Link di conferma non valido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasVerifiedEmail()) {
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
event(new Verified($user));
|
||||||
|
activity()->causedBy($user)->log('email_confermata');
|
||||||
|
}
|
||||||
|
|
||||||
|
$messaggio = 'Indirizzo email confermato.';
|
||||||
|
|
||||||
|
if ($request->user()?->is($user)) {
|
||||||
|
return redirect()->route('dashboard')->with('success', $messaggio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('success', $messaggio . ' Ora puoi accedere.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,17 +110,22 @@ class BackupController extends Controller
|
|||||||
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
|
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$operationId = (string) Str::uuid();
|
||||||
|
|
||||||
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
|
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
|
||||||
// una porta d'ingresso separata: un ripristino avviato mentre gira un
|
// una porta d'ingresso separata: un ripristino avviato mentre gira un
|
||||||
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
|
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
|
||||||
if ($this->stato->corrente() !== null) {
|
// Il posto si prende subito e in un passo solo (occupa()): controllarlo
|
||||||
|
// e prenderlo solo dopo aver salvato il file lasciava una finestra in cui
|
||||||
|
// un import o un secondo ripristino passavano entrambi.
|
||||||
|
$inCorso = $this->stato->occupa($operationId, BackupOperationState::TIPO_RESTORE, 'In coda');
|
||||||
|
if ($inCorso !== null) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.',
|
'message' => $this->stato->messaggioOccupato($inCorso),
|
||||||
], 409);
|
], 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
$operationId = (string) Str::uuid();
|
try {
|
||||||
|
|
||||||
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||||
// fine richiesta: il job lo troverebbe gia' cancellato.
|
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||||
$percorso = $request->file('backupZip')->storeAs(
|
$percorso = $request->file('backupZip')->storeAs(
|
||||||
@@ -128,12 +133,22 @@ class BackupController extends Controller
|
|||||||
$operationId . '.zip',
|
$operationId . '.zip',
|
||||||
'local'
|
'local'
|
||||||
);
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Il posto e' gia' preso: senza chiuderlo resterebbe occupato da un
|
||||||
|
// ripristino che non partira' mai, per l'ora intera del TTL.
|
||||||
|
$this->stato->markFailed($operationId, 'Archivio non salvato sul server: ' . $e->getMessage());
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($percorso === false) {
|
||||||
|
$this->stato->markFailed($operationId, 'Archivio non salvato sul server.');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Il server non è riuscito a salvare il pacchetto ricevuto (spazio o permessi sul disco).',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
$this->stato->initialize(
|
|
||||||
$operationId,
|
|
||||||
BackupOperationState::TIPO_RESTORE,
|
|
||||||
'In coda'
|
|
||||||
);
|
|
||||||
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
|
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
|
||||||
|
|
||||||
RestoreBackupArchive::dispatch(
|
RestoreBackupArchive::dispatch(
|
||||||
|
|||||||
@@ -3,37 +3,30 @@
|
|||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Services\Piantine\PubblicazionePiantina;
|
||||||
use App\Services\Piantine\PiantinaPdfService;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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
|
* Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
|
||||||
* territorio, non archivia versioni, non lascia file sul disco. Serve a
|
* soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
|
||||||
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione
|
* e scade con l'anteprima; la rotta resta comunque riservata
|
||||||
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un
|
* all'amministratore come il resto delle piantine.
|
||||||
* confine sbagliato quando è già nello storico.
|
|
||||||
*/
|
*/
|
||||||
class PiantinaAnteprimaController extends Controller
|
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()->file($percorso, [
|
||||||
|
|
||||||
return response($pdf, 200, [
|
|
||||||
'Content-Type' => 'application/pdf',
|
'Content-Type' => 'application/pdf',
|
||||||
// `inline`: si apre nel visualizzatore del browser invece di
|
// `inline`: si apre nel visualizzatore del browser invece di
|
||||||
// finire fra i download, che di un'anteprima non se ne fa niente.
|
// finire fra i download, che di un'anteprima non se ne fa niente.
|
||||||
'Content-Disposition' => 'inline; filename="anteprima-territorio-'
|
'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
|
||||||
. $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',
|
'Cache-Control' => 'no-store, max-age=0',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Livewire\Settings\XmlExchange;
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class XmlExchangeUploadController extends Controller
|
class XmlExchangeUploadController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Quanto resta occupato il posto unico se la richiesta di import muore senza
|
||||||
|
* liberarlo (timeout, errore fatale: casi in cui il `finally` non gira).
|
||||||
|
* L'import e' sincrono e PHP lo ferma comunque dopo max_execution_time
|
||||||
|
* (120 s): dieci minuti bastano a coprirlo, invece dell'ora intera pensata
|
||||||
|
* per i job in coda.
|
||||||
|
*/
|
||||||
|
private const TTL_POSTO_IMPORT_XML = 600;
|
||||||
|
|
||||||
public function convertSqlToXml(Request $request)
|
public function convertSqlToXml(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
|
|||||||
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
|
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'import svuota e riscrive le tabelle: non puo' girare insieme a un
|
||||||
|
// ripristino (che fa lo stesso), a un azzeramento, a un backup che le sta
|
||||||
|
// fotografando o a un import PDF che sta aggiornando i territori. Il
|
||||||
|
// ripristino chiama importXmlFromContent() per conto suo, gia' dentro il
|
||||||
|
// proprio posto: per questo il blocco sta qui, sulla porta d'ingresso, e
|
||||||
|
// non dentro l'import.
|
||||||
|
$stato = app(BackupOperationState::class);
|
||||||
|
$operationId = (string) Str::uuid();
|
||||||
|
$inCorso = $stato->occupa($operationId, BackupOperationState::TIPO_IMPORT_XML, 'Import XML', self::TTL_POSTO_IMPORT_XML);
|
||||||
|
|
||||||
|
if ($inCorso !== null) {
|
||||||
|
return back()->withErrors(['xmlImport' => $stato->messaggioOccupato($inCorso) . ' Nessun dato è stato modificato.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stato->markRunning($operationId);
|
||||||
|
|
||||||
|
try {
|
||||||
$exchange = new XmlExchange();
|
$exchange = new XmlExchange();
|
||||||
|
$fileDiPrima = XmlExchange::fileDeiTerritori();
|
||||||
$result = $exchange->importXmlFromContent($content);
|
$result = $exchange->importXmlFromContent($content);
|
||||||
|
|
||||||
|
if (! isset($result['error'])) {
|
||||||
|
$result['stats']['file_rimossi'] = XmlExchange::eliminaFileNonPiuUsati($fileDiPrima);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$stato->markFailed($operationId, $e->getMessage());
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
|
$stato->markFailed($operationId, $result['error']);
|
||||||
|
|
||||||
return back()->withErrors(['xmlImport' => $result['error']]);
|
return back()->withErrors(['xmlImport' => $result['error']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stato->markCompleted($operationId);
|
||||||
|
|
||||||
$stats = $result['stats'];
|
$stats = $result['stats'];
|
||||||
$issues = $result['issues'];
|
$issues = $result['issues'];
|
||||||
|
|
||||||
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
|
|||||||
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
|
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
|
||||||
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
|
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
|
||||||
}
|
}
|
||||||
|
if (($stats['file_rimossi'] ?? 0) > 0) {
|
||||||
|
$message .= ' Eliminati ' . $stats['file_rimossi'] . ' file (PDF e miniature) dei territori sostituiti, che i dati importati non usano più.';
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('xml.exchange')
|
return redirect()->route('xml.exchange')
|
||||||
->with('success', $message)
|
->with('success', $message)
|
||||||
|
|||||||
62
app/Http/Controllers/TerritorioFileController.php
Normal file
62
app/Http/Controllers/TerritorioFileController.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\TerritorioPdfVersione;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF, miniature e versioni archiviate dei territori, solo per chi è dentro.
|
||||||
|
*
|
||||||
|
* I file stanno sul disco `public`, ma nginx non serve più le cartelle
|
||||||
|
* `territori-*` (docker/nginx/default.conf): prima chiunque scaricava
|
||||||
|
* /storage/territori-pdf/territorio-12-34.pdf senza login, con nomi facili da
|
||||||
|
* indovinare, e Cloudflare li teneva in cache pubblica per una settimana. I
|
||||||
|
* proclamatori continuano a riceverli dal link con codice e scadenza
|
||||||
|
* (AssignmentPdfController); da qui passano solo gli utenti autenticati, con i
|
||||||
|
* permessi della pagina che li mostra.
|
||||||
|
*/
|
||||||
|
class TerritorioFileController extends Controller
|
||||||
|
{
|
||||||
|
public function pdf(Territorio $territorio): Response
|
||||||
|
{
|
||||||
|
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function miniatura(Territorio $territorio): Response
|
||||||
|
{
|
||||||
|
return $this->servi($territorio->thumbnail_path, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function versione(TerritorioPdfVersione $versione): Response
|
||||||
|
{
|
||||||
|
$numero = $versione->territorio?->numero ?? 'senza-territorio';
|
||||||
|
|
||||||
|
return $this->servi($versione->pdf_path, 'application/pdf', "territorio-{$numero}-versione-{$versione->id}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function servi(?string $percorso, ?string $tipo, ?string $nome): Response
|
||||||
|
{
|
||||||
|
$disco = Storage::disk('public');
|
||||||
|
|
||||||
|
abort_unless($percorso && $disco->exists($percorso), 404);
|
||||||
|
|
||||||
|
$intestazioni = [];
|
||||||
|
if ($tipo) {
|
||||||
|
$intestazioni['Content-Type'] = $tipo;
|
||||||
|
}
|
||||||
|
if ($nome) {
|
||||||
|
$intestazioni['Content-Disposition'] = 'inline; filename="' . $nome . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Privata: niente cache intermedie (Cloudflare). Va impostata dopo,
|
||||||
|
// perché BinaryFileResponse si dichiara pubblica da sola e
|
||||||
|
// sovrascriverebbe un Cache-Control passato fra le intestazioni.
|
||||||
|
// L'indirizzo porta l'impronta del percorso (?v=), che cambia col file.
|
||||||
|
return response()->file($disco->path($percorso), $intestazioni)
|
||||||
|
->setPrivate()
|
||||||
|
->setMaxAge(86400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ class RequirePasswordChange
|
|||||||
'password.change',
|
'password.change',
|
||||||
'password.change.store',
|
'password.change.store',
|
||||||
'logout',
|
'logout',
|
||||||
|
// Il link della mail di conferma: aperto in quel momento andrebbe
|
||||||
|
// perso, e la persona dovrebbe chiederne un altro.
|
||||||
|
'verification.verify',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Jobs;
|
|||||||
use App\Livewire\Settings\XmlExchange;
|
use App\Livewire\Settings\XmlExchange;
|
||||||
use App\Services\BackupArchive;
|
use App\Services\BackupArchive;
|
||||||
use App\Services\BackupOperationState;
|
use App\Services\BackupOperationState;
|
||||||
|
use App\Services\Piantine\PiantineBackup;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -44,7 +45,7 @@ class CreateBackupArchive implements ShouldQueue
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
|
||||||
{
|
{
|
||||||
$stato->markRunning($this->operationId);
|
$stato->markRunning($this->operationId);
|
||||||
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
|
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
|
||||||
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
|
|||||||
$this->rimuoviArchiviVecchi($archivio);
|
$this->rimuoviArchiviVecchi($archivio);
|
||||||
|
|
||||||
$xml = $exchange->currentXmlBackup();
|
$xml = $exchange->currentXmlBackup();
|
||||||
|
$datiPiantine = $piantine->esporta();
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Compressione dei file');
|
$stato->fase($this->operationId, 'Compressione dei file');
|
||||||
|
|
||||||
$archivio->create(
|
$archivio->create(
|
||||||
$xml,
|
$xml,
|
||||||
|
$datiPiantine,
|
||||||
$destinazione,
|
$destinazione,
|
||||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Jobs;
|
|||||||
|
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
|
use App\Services\Piantine\PiantinaPdfService;
|
||||||
use App\Services\TerritorioPdfImportState;
|
use App\Services\TerritorioPdfImportState;
|
||||||
use App\Services\TerritorioThumbnailService;
|
use App\Services\TerritorioThumbnailService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -35,14 +37,21 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
|
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService, BackupOperationState $postoUnico, PiantinaPdfService $pdfService): void
|
||||||
{
|
{
|
||||||
$stateService->markRunning($this->importId);
|
$stateService->markRunning($this->importId);
|
||||||
|
$postoUnico->markRunning($this->importId);
|
||||||
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
|
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
|
||||||
|
|
||||||
|
// Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si
|
||||||
|
// tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede
|
||||||
|
// e scarta il file come ambiguo invece di sceglierne uno a caso.
|
||||||
$territoriMap = [];
|
$territoriMap = [];
|
||||||
foreach (Territorio::withTrashed()->get() as $territorio) {
|
foreach (Territorio::withTrashed()->get() as $territorio) {
|
||||||
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
|
$chiave = $this->chiaveTerritorio((string) $territorio->numero);
|
||||||
|
if ($chiave !== '') {
|
||||||
|
$territoriMap[$chiave][] = $territorio;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$actor = User::find($this->actorId);
|
$actor = User::find($this->actorId);
|
||||||
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalizedNumber = $territoryMatch['normalized_number'];
|
if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') {
|
||||||
|
$codici = implode(', ', $territoryMatch['codes']);
|
||||||
|
$esistenti = implode(', ', $territoryMatch['matched_numbers']);
|
||||||
|
$stateService->increment($this->importId, 'skipped');
|
||||||
|
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.');
|
||||||
|
$stateService->addIssue($this->importId, [
|
||||||
|
'file' => $originalName,
|
||||||
|
'type' => 'unknown-suffix',
|
||||||
|
'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.',
|
||||||
|
'matched_numbers' => $territoryMatch['matched_numbers'],
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$matchedNumber = $territoryMatch['matched_number'];
|
$matchedNumber = $territoryMatch['matched_number'];
|
||||||
$territorio = $territoryMatch['territorio'];
|
$territorio = $territoryMatch['territorio'];
|
||||||
|
// Per id, non per numero: due file che nominano lo stesso
|
||||||
|
// territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono
|
||||||
|
// comunque un doppione.
|
||||||
|
$normalizedNumber = $territorio->id;
|
||||||
|
|
||||||
if (isset($seenNumbers[$normalizedNumber])) {
|
if (isset($seenNumbers[$normalizedNumber])) {
|
||||||
$stateService->increment($this->importId, 'skipped');
|
$stateService->increment($this->importId, 'skipped');
|
||||||
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($territorio->pdf_path) {
|
|
||||||
Storage::disk('public')->delete($territorio->pdf_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($territorio->thumbnail_path) {
|
|
||||||
$thumbnailService->delete($territorio->thumbnail_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The territory id is part of the filename because the slug
|
// The territory id is part of the filename because the slug
|
||||||
// alone is not unique: "1 A" and "1-A" are distinct
|
// alone is not unique: "1 A" and "1-A" are distinct
|
||||||
// territory numbers that both slug to "territorio-1-a".
|
// territory numbers that both slug to "territorio-1-a".
|
||||||
// Without it the second import overwrites the first's PDF
|
// Without it the second import overwrites the first's PDF
|
||||||
// and both rows point at one file — then removing the PDF
|
// and both rows point at one file — then removing the PDF
|
||||||
// from either territory breaks the other.
|
// from either territory breaks the other.
|
||||||
|
//
|
||||||
|
// Il nuovo file si scrive per primo, con un nome che non
|
||||||
|
// coincide con quello in vigore; solo dopo il vecchio va
|
||||||
|
// nello storico, come per le piantine. Prima si cancellava
|
||||||
|
// il vecchio e poi si scriveva: con una scrittura fallita il
|
||||||
|
// territorio restava senza PDF, e un import sbagliato non si
|
||||||
|
// poteva annullare.
|
||||||
$storedFilename = Str::slug('territorio-' . $territorio->numero)
|
$storedFilename = Str::slug('territorio-' . $territorio->numero)
|
||||||
. '-' . $territorio->id . '.pdf';
|
. '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
|
||||||
$publicPath = 'territori-pdf/' . $storedFilename;
|
$publicPath = 'territori-pdf/' . $storedFilename;
|
||||||
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
|
if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) {
|
||||||
|
throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdfService->archiviaCorrente($territorio, 'import');
|
||||||
$thumbnailPath = $thumbnailService->generate($publicPath);
|
$thumbnailPath = $thumbnailService->generate($publicPath);
|
||||||
|
|
||||||
$territorio->update([
|
$territorio->update([
|
||||||
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
|||||||
|
|
||||||
$stateService->appendLog($this->importId, 'Import completato.');
|
$stateService->appendLog($this->importId, 'Import completato.');
|
||||||
$stateService->markCompleted($this->importId);
|
$stateService->markCompleted($this->importId);
|
||||||
|
$postoUnico->markCompleted($this->importId);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$stateService->increment($this->importId, 'errors');
|
$stateService->increment($this->importId, 'errors');
|
||||||
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
|
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
|
||||||
$stateService->markFailed($this->importId);
|
$stateService->markFailed($this->importId);
|
||||||
|
$postoUnico->markFailed($this->importId, $exception->getMessage());
|
||||||
|
|
||||||
throw $exception;
|
throw $exception;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
|||||||
* Without this the import state is stranded in "running" — the UI polls it
|
* Without this the import state is stranded in "running" — the UI polls it
|
||||||
* until the 24h cache entry expires — and the uploaded temp directory is
|
* until the 24h cache entry expires — and the uploaded temp directory is
|
||||||
* never reclaimed, since handle()'s finally never executes.
|
* never reclaimed, since handle()'s finally never executes.
|
||||||
|
*
|
||||||
|
* Libera anche il posto unico: senza, un import ucciso dal timeout
|
||||||
|
* lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera
|
||||||
|
* del TTL, con un'operazione che non avanza piu'.
|
||||||
*/
|
*/
|
||||||
public function failed(?\Throwable $exception): void
|
public function failed(?\Throwable $exception): void
|
||||||
{
|
{
|
||||||
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
|||||||
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||||
);
|
);
|
||||||
$stateService->markFailed($this->importId);
|
$stateService->markFailed($this->importId);
|
||||||
|
app(BackupOperationState::class)->markFailed(
|
||||||
|
$this->importId,
|
||||||
|
'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||||
|
);
|
||||||
|
|
||||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
|
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abbina il nome di un file al territorio, oppure spiega perche' non lo fa.
|
||||||
|
*
|
||||||
|
* La regola: nel nome si cercano i "numeri di territorio", cioe' cifre
|
||||||
|
* seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un
|
||||||
|
* trattino, un trattino basso o un punto ("12", "012", "12B", "12 B",
|
||||||
|
* "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo
|
||||||
|
* stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero
|
||||||
|
* deve stare da solo — "T12" o "12AB" non contano — e quelli che non
|
||||||
|
* corrispondono a nessun territorio (un anno, un CAP) vengono ignorati.
|
||||||
|
*
|
||||||
|
* Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A"
|
||||||
|
* conteneva "1", e in assenza di un territorio 1A il PDF finiva sul
|
||||||
|
* territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera
|
||||||
|
* dopo il numero fa parte del numero, e se quel territorio non esiste il
|
||||||
|
* file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si
|
||||||
|
* tocca.
|
||||||
|
*
|
||||||
|
* Il file si scarta anche quando l'abbinamento non e' univoco: due numeri
|
||||||
|
* di territori esistenti nello stesso nome, o due territori che si
|
||||||
|
* scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire
|
||||||
|
* la mappa sbagliata e' peggio che non sostituirne nessuna: un file
|
||||||
|
* scartato si ricarica, un PDF sovrascritto non torna.
|
||||||
|
*
|
||||||
|
* I territori con numeri di altra forma (lettere davanti, piu' lettere in
|
||||||
|
* fondo) non si abbinano da nome file: il PDF va caricato dalla loro
|
||||||
|
* scheda.
|
||||||
|
*/
|
||||||
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
|
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
|
||||||
{
|
{
|
||||||
if ($territoriMap === []) {
|
$trovati = [];
|
||||||
return null;
|
$suffissiSconosciuti = [];
|
||||||
|
|
||||||
|
foreach ($this->numeriNelNomeFile($filename) as $codice) {
|
||||||
|
if (isset($territoriMap[$codice])) {
|
||||||
|
foreach ($territoriMap[$codice] as $territorio) {
|
||||||
|
$trovati[$territorio->id] = $territorio;
|
||||||
}
|
}
|
||||||
|
|
||||||
$basename = pathinfo($filename, PATHINFO_FILENAME);
|
|
||||||
$normalizedBasename = mb_strtoupper($basename);
|
|
||||||
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
|
|
||||||
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
|
|
||||||
|
|
||||||
$territoryKeys = array_keys($territoriMap);
|
|
||||||
usort($territoryKeys, function (string $left, string $right) {
|
|
||||||
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
|
|
||||||
|
|
||||||
if ($lengthComparison !== 0) {
|
|
||||||
return $lengthComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strnatcasecmp($left, $right);
|
|
||||||
});
|
|
||||||
|
|
||||||
$matches = [];
|
|
||||||
|
|
||||||
foreach ($territoryKeys as $normalizedNumber) {
|
|
||||||
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$matches[] = [
|
// "1A" che non esiste, mentre 1 si': e' il caso che prima
|
||||||
'normalized_number' => $normalizedNumber,
|
// sovrascriveva il territorio 1. Lo si annota per spiegarlo.
|
||||||
'matched_number' => $territoriMap[$normalizedNumber]->numero,
|
$base = preg_replace('/\p{L}$/u', '', $codice);
|
||||||
'territorio' => $territoriMap[$normalizedNumber],
|
if ($base !== $codice && isset($territoriMap[$base])) {
|
||||||
|
$suffissiSconosciuti[$codice] = $territoriMap[$base];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori)));
|
||||||
|
|
||||||
|
if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) {
|
||||||
|
return [
|
||||||
|
'type' => 'ambiguous',
|
||||||
|
'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($matches === []) {
|
if ($trovati === [] && $suffissiSconosciuti !== []) {
|
||||||
|
return [
|
||||||
|
'type' => 'unknown-suffix',
|
||||||
|
'codes' => array_keys($suffissiSconosciuti),
|
||||||
|
'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($trovati === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($matches) > 1) {
|
$territorio = reset($trovati);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => 'ambiguous',
|
'type' => 'single',
|
||||||
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
|
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
|
||||||
|
'matched_number' => $territorio->numero,
|
||||||
|
'territorio' => $territorio,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $matches[0];
|
/**
|
||||||
}
|
* I numeri di territorio presenti nel nome del file, gia' nella forma di
|
||||||
|
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
|
||||||
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected function numeriNelNomeFile(string $filename): array
|
||||||
{
|
{
|
||||||
$escapedNumber = preg_quote($normalizedNumber, '/');
|
$nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME));
|
||||||
|
|
||||||
if (preg_match('/^\d+$/', $normalizedNumber)) {
|
// Il lookahead finale e' quello che separa "12 B" (numero 12B) da
|
||||||
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
|
// "12 Via Roma" (numero 12): la lettera vale come parte del numero solo
|
||||||
|
// se dopo di lei la parola finisce.
|
||||||
|
preg_match_all('/(?<![\p{L}\p{N}])(\d+)(?:[ _.\-]?(\p{L}))?(?![\p{L}\p{N}])/u', $nome, $trovati, PREG_SET_ORDER);
|
||||||
|
|
||||||
|
$codici = [];
|
||||||
|
foreach ($trovati as $trovato) {
|
||||||
|
$codici[] = (ltrim($trovato[1], '0') ?: '0') . ($trovato[2] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
|
return array_values(array_unique($codici));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function normalizeTerritoryNumber(string $number): string
|
/**
|
||||||
|
* Forma di confronto di un numero di territorio: maiuscolo, senza
|
||||||
|
* separatori, senza zeri iniziali. "12 B", "12-b" e "012B" diventano tutti
|
||||||
|
* "12B"; due territori con la stessa chiave rendono ambiguo ogni file che
|
||||||
|
* la nomina.
|
||||||
|
*/
|
||||||
|
protected function chiaveTerritorio(string $numero): string
|
||||||
{
|
{
|
||||||
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
|
$chiave = preg_replace('/[^\p{L}\p{N}]+/u', '', mb_strtoupper($numero));
|
||||||
|
|
||||||
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
|
return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
|
||||||
return ltrim($normalized, '0') ?: '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use App\Models\Campagna;
|
|||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
use App\Models\TerritorioPdfVersione;
|
||||||
use App\Models\Tipologia;
|
use App\Models\Tipologia;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
@@ -21,6 +23,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
|||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
||||||
@@ -72,12 +75,17 @@ class ResetApplication implements ShouldQueue
|
|||||||
$stato->avanza($this->operationId, 1);
|
$stato->avanza($this->operationId, 1);
|
||||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8);
|
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
|
||||||
|
|
||||||
DB::transaction(function () use ($stato) {
|
DB::transaction(function () use ($stato) {
|
||||||
$passi = [
|
$passi = [
|
||||||
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||||
'Campagne' => fn () => Campagna::query()->delete(),
|
'Campagne' => fn () => Campagna::query()->delete(),
|
||||||
|
// Prima dei territori, e per esteso: le piantine hanno la
|
||||||
|
// chiave a null on delete, quindi sopravvivevano scollegate
|
||||||
|
// e con l'immagine che puntava a un file appena cancellato.
|
||||||
|
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
||||||
|
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
||||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||||
@@ -100,6 +108,13 @@ class ResetApplication implements ShouldQueue
|
|||||||
// ricreare la riga.
|
// ricreare la riga.
|
||||||
Setting::flushCache();
|
Setting::flushCache();
|
||||||
|
|
||||||
|
// Area della città e anteprime delle pubblicazioni stanno sul disco
|
||||||
|
// privato, che svuotaDiscoPubblico() non tocca: restavano lì e
|
||||||
|
// l'editor ripartiva con la città di prima.
|
||||||
|
Storage::disk('local')->deleteDirectory('piantine');
|
||||||
|
Storage::disk('local')->deleteDirectory('piantine-anteprime');
|
||||||
|
$stato->log($this->operationId, 'Area della città e anteprime delle piantine rimosse.');
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
|
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
|
||||||
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Livewire\Settings\XmlExchange;
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Services\BackupArchive;
|
use App\Services\BackupArchive;
|
||||||
use App\Services\BackupOperationState;
|
use App\Services\BackupOperationState;
|
||||||
|
use App\Services\Piantine\PiantineBackup;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
use Spatie\Permission\PermissionRegistrar;
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
/**
|
||||||
|
* Il ripristino e' tutto o niente. Tre tempi:
|
||||||
|
*
|
||||||
|
* 1. estrazione e verifica dell'archivio nello staging, piantine.json
|
||||||
|
* compreso: un archivio rotto si ferma qui, a installazione intatta;
|
||||||
|
* 2. un'unica transazione con l'import XML, le tabelle delle piantine, il
|
||||||
|
* blocco degli account e — per ultimo, ancora dentro — lo scambio dei
|
||||||
|
* file. Se lo scambio fallisce si disfa da solo e la transazione si
|
||||||
|
* annulla; se fallisce il commit, i file si rimettono a posto qui;
|
||||||
|
* 3. a commit avvenuto, si buttano i file precedenti.
|
||||||
|
*
|
||||||
|
* Prima import, blocco account e file erano tre passi separati: un errore
|
||||||
|
* sui file lasciava il database gia' riscritto con il disco a meta', e un
|
||||||
|
* errore sul blocco lasciava attive le password del backup.
|
||||||
|
*
|
||||||
|
* Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit
|
||||||
|
* implicito, chiudendo la transazione a meta'. L'import XML e
|
||||||
|
* PiantineBackup cancellano con DELETE.
|
||||||
|
*/
|
||||||
|
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
|
||||||
{
|
{
|
||||||
$stato->markRunning($this->operationId);
|
$stato->markRunning($this->operationId);
|
||||||
|
|
||||||
$staging = null;
|
$staging = null;
|
||||||
|
$scambio = null;
|
||||||
|
$confermato = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
|
$stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
|
||||||
|
|
||||||
$estratto = $archivio->extract(
|
$estratto = $archivio->extract(
|
||||||
$this->zipPath,
|
$this->zipPath,
|
||||||
@@ -56,34 +80,47 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
);
|
);
|
||||||
$staging = $estratto['staging'];
|
$staging = $estratto['staging'];
|
||||||
|
|
||||||
|
$piantine->valida($estratto['piantine']);
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Riscrittura del database', 1);
|
$stato->fase($this->operationId, 'Riscrittura del database', 1);
|
||||||
|
|
||||||
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
|
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
|
||||||
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
|
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
|
||||||
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
|
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
|
||||||
// primo id disponibile invece che su chi ha avviato il ripristino.
|
// primo id disponibile invece che su chi ha avviato il ripristino.
|
||||||
|
//
|
||||||
|
// E' anche cio' che tiene dentro chi ripristina: l'import riconosce
|
||||||
|
// l'attore e ne conserva hash della password, remember_token e
|
||||||
|
// ruolo di amministratore. Con AuthenticateSession attivo, un hash
|
||||||
|
// cambiato chiuderebbe la sua sessione e lo costringerebbe a
|
||||||
|
// rientrare con la password dell'epoca del backup, che puo' non
|
||||||
|
// ricordare. Se il suo account nel backup non c'e', l'import (che
|
||||||
|
// fa upsert per email, senza cancellare nessuno) non lo tocca
|
||||||
|
// affatto: resta con password e ruoli attuali.
|
||||||
Auth::onceUsingId($this->actorId);
|
Auth::onceUsingId($this->actorId);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) {
|
||||||
|
// La transazione dell'import diventa un savepoint di questa:
|
||||||
|
// il suo commit non e' piu' definitivo.
|
||||||
$result = $exchange->importXmlFromContent($estratto['xml']);
|
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||||
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
$stato->markFailed($this->operationId, $result['error']);
|
throw new RuntimeException($result['error']);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$stato->avanza($this->operationId, 1, 1);
|
$esitoPiantine = $piantine->ripristina($estratto['piantine']);
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Ripristino dei file');
|
|
||||||
|
|
||||||
$fileRipristinati = $archivio->restoreFiles(
|
|
||||||
$staging,
|
|
||||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
|
||||||
);
|
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Blocco degli account', 1);
|
|
||||||
$utenti = $this->richiediCambioPasswordATutti();
|
$utenti = $this->richiediCambioPasswordATutti();
|
||||||
|
|
||||||
|
$stato->avanza($this->operationId, 1, 1);
|
||||||
|
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
|
||||||
|
|
||||||
|
$scambio = $archivio->scambiaFile($estratto);
|
||||||
|
});
|
||||||
|
|
||||||
|
$confermato = true;
|
||||||
|
$archivio->confermaScambio($scambio);
|
||||||
|
|
||||||
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
|
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
|
||||||
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
|
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
|
||||||
// caricata continuano a decidere gli accessi con quella di prima
|
// caricata continuano a decidere gli accessi con quella di prima
|
||||||
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
|
|
||||||
$stato->avanza($this->operationId, 1, 1);
|
$stato->avanza($this->operationId, 1, 1);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
$messaggio = $e->getMessage();
|
||||||
|
|
||||||
|
// Lo scambio era riuscito ma il commit no: il database e' tornato
|
||||||
|
// indietro, i file devono seguirlo.
|
||||||
|
if ($scambio !== null && ! $confermato) {
|
||||||
|
try {
|
||||||
|
$archivio->annullaScambio($scambio, $e);
|
||||||
|
} catch (\Throwable $annullamento) {
|
||||||
|
$messaggio = $annullamento->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La transazione annullata non svuota le cache in memoria del
|
||||||
|
// processo: Setting terrebbe l'istanza creata dall'import (una
|
||||||
|
// riga che non esiste piu') e Spatie i ruoli letti durante l'import.
|
||||||
|
Setting::flushCache();
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
|
||||||
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
||||||
$stato->markFailed($this->operationId, $e->getMessage());
|
$stato->markFailed($this->operationId, $messaggio);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stats = $result['stats'];
|
$stats = $result['stats'];
|
||||||
|
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
|
||||||
|
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
|
||||||
|
$fileRipristinati = $scambio['file'];
|
||||||
|
|
||||||
$messaggio = sprintf(
|
$messaggio = sprintf(
|
||||||
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
|
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. '
|
||||||
. 'Per sicurezza %s impostare una nuova password.',
|
. 'Per sicurezza %s impostare una nuova password.',
|
||||||
$stats['territori_importati'] ?? 0,
|
$stats['territori_importati'] ?? 0,
|
||||||
$stats['proclamatori_importati'] ?? 0,
|
$stats['proclamatori_importati'] ?? 0,
|
||||||
$stats['assegnazioni_importate'] ?? 0,
|
$stats['assegnazioni_importate'] ?? 0,
|
||||||
|
$esitoPiantine['mappe'],
|
||||||
$fileRipristinati,
|
$fileRipristinati,
|
||||||
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
|
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Un archivio di formato 1 non conteneva le piantine: va detto, o
|
||||||
|
// l'amministratore le cercherebbe senza sapere perche' sono sparite.
|
||||||
|
if ($estratto['piantine'] === null) {
|
||||||
|
$stato->log(
|
||||||
|
$this->operationId,
|
||||||
|
'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. '
|
||||||
|
. 'L\'area KML in uso non e\' stata toccata.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
|
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
|
||||||
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
|
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
|
||||||
// il middleware dirotta l'amministratore sul cambio password. Lo si
|
// il middleware dirotta l'amministratore sul cambio password. Lo si
|
||||||
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
$stato->log($this->operationId, $messaggio);
|
$stato->log($this->operationId, $messaggio);
|
||||||
$stato->markCompleted($this->operationId, [
|
$stato->markCompleted($this->operationId, [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'issues' => array_slice($result['issues'], 0, 50),
|
'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
|
||||||
'file_ripristinati' => $fileRipristinati,
|
'file_ripristinati' => $fileRipristinati,
|
||||||
'messaggio' => $messaggio,
|
'messaggio' => $messaggio,
|
||||||
]);
|
]);
|
||||||
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
* muore per cause che handle() non intercetta, cosi' il posto unico si
|
* muore per cause che handle() non intercetta, cosi' il posto unico si
|
||||||
* libera subito invece di restare occupato fino alla scadenza.
|
* libera subito invece di restare occupato fino alla scadenza.
|
||||||
*
|
*
|
||||||
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino
|
* Qui il messaggio dice anche in che stato si e' rimasti. Un processo
|
||||||
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi
|
* ucciso a meta' non arriva al commit, e il database annulla da se' la
|
||||||
* con database e file disallineati, e chi legge deve sapere che la strada
|
* transazione rimasta aperta: i dati restano quelli di prima. L'unica
|
||||||
* e' rilanciare il ripristino, non ignorare l'errore.
|
* finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei
|
||||||
|
* file e il commit — in quel caso i file precedenti sono ancora in
|
||||||
|
* storage/app/backup-precedenti/. La strada resta rilanciare il
|
||||||
|
* ripristino, non ignorare l'errore.
|
||||||
*/
|
*/
|
||||||
public function failed(?\Throwable $e): void
|
public function failed(?\Throwable $e): void
|
||||||
{
|
{
|
||||||
app(BackupOperationState::class)->markFailed(
|
app(BackupOperationState::class)->markFailed(
|
||||||
$this->operationId,
|
$this->operationId,
|
||||||
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
|
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
|
||||||
. ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.'
|
. ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. '
|
||||||
|
. 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.'
|
||||||
);
|
);
|
||||||
|
|
||||||
@unlink($this->zipPath);
|
@unlink($this->zipPath);
|
||||||
|
|||||||
@@ -2,21 +2,42 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Assegnazioni;
|
namespace App\Livewire\Assegnazioni;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class Assegna extends Component
|
class Assegna extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
|
// ─── Invio del link ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Dopo l'assegnazione non si torna subito alla pagina: si sceglie il
|
||||||
|
// canale (copia link o Condividi: il destinatario lo sceglie chi usa
|
||||||
|
// l'app dai suoi contatti, i proclamatori non hanno numero né email),
|
||||||
|
// si dice se il link è partito, e solo allora si va avanti. Se la
|
||||||
|
// procedura si interrompe, l'assegnazione resta con link_da_inviare e lo
|
||||||
|
// si segnala dove compare.
|
||||||
|
|
||||||
|
/** L'assegnazione di cui si sta mandando il link. */
|
||||||
|
#[Locked]
|
||||||
|
public ?int $invioId = null;
|
||||||
|
|
||||||
|
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
|
||||||
|
#[Locked]
|
||||||
|
public ?string $canaleUsato = null;
|
||||||
|
|
||||||
|
public string $erroreInvio = '';
|
||||||
|
|
||||||
public ?int $territorio_id = null;
|
public ?int $territorio_id = null;
|
||||||
public ?int $proclamatore_id = null;
|
public ?int $proclamatore_id = null;
|
||||||
public string $assigned_at = '';
|
public string $assigned_at = '';
|
||||||
public string $territorioSearch = '';
|
|
||||||
public string $linkSearch = '';
|
|
||||||
|
|
||||||
// Optional pre-selection from parent context
|
// Optional pre-selection from parent context
|
||||||
public ?int $preselectedTerritorioId = null;
|
public ?int $preselectedTerritorioId = null;
|
||||||
@@ -26,6 +47,12 @@ class Assegna extends Component
|
|||||||
$this->preselectedTerritorioId = $territorioId;
|
$this->preselectedTerritorioId = $territorioId;
|
||||||
$this->territorio_id = $territorioId;
|
$this->territorio_id = $territorioId;
|
||||||
$this->assigned_at = now()->format('Y-m-d');
|
$this->assigned_at = now()->format('Y-m-d');
|
||||||
|
|
||||||
|
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
|
||||||
|
// scheda del territorio).
|
||||||
|
if ($invia = request()->integer('invia')) {
|
||||||
|
$this->apriInvio($invia);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function rules(): array
|
protected function rules(): array
|
||||||
@@ -43,118 +70,196 @@ class Assegna extends Component
|
|||||||
|
|
||||||
$territorio = Territorio::findOrFail($this->territorio_id);
|
$territorio = Territorio::findOrFail($this->territorio_id);
|
||||||
|
|
||||||
// Check territory is available (not currently assigned)
|
// Gli errori vanno sul campo e non in sessione: il messaggio flash
|
||||||
|
// sta nel telaio della pagina, che Livewire non ridisegna, e il
|
||||||
|
// rifiuto passerebbe inosservato.
|
||||||
if ($territorio->assegnazioneCorrente) {
|
if ($territorio->assegnazioneCorrente) {
|
||||||
session()->flash('error', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
|
$this->addError('territorio_id', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check territory is active
|
|
||||||
if (!$territorio->attivo) {
|
if (!$territorio->attivo) {
|
||||||
session()->flash('error', "Il territorio {$territorio->numero} è inattivo.");
|
$this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||||
if (!$proclamatore->attivo) {
|
if (!$proclamatore->attivo) {
|
||||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
|
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
|
||||||
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
|
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
|
||||||
|
|
||||||
$assegnazione = Assegnazione::create([
|
// Controllo e inserimento sotto lo stesso lock sulla riga del
|
||||||
|
// territorio: un doppio clic o due schede aperte non possono più
|
||||||
|
// superare entrambi il controllo e creare due assegnazioni aperte.
|
||||||
|
$esito = DB::transaction(function () use ($territorio, $assignedDate, $annoTeocratico) {
|
||||||
|
Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||||
|
|
||||||
|
$conflitto = Assegnazione::sovrappostaA($territorio->id, $assignedDate);
|
||||||
|
if ($conflitto) {
|
||||||
|
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}: "
|
||||||
|
. ($conflitto->returned_at
|
||||||
|
? 'la data di assegnazione non può essere precedente al suo rientro.'
|
||||||
|
: 'registrane prima il rientro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Assegnazione::create([
|
||||||
'territorio_id' => $this->territorio_id,
|
'territorio_id' => $this->territorio_id,
|
||||||
'proclamatore_id' => $this->proclamatore_id,
|
'proclamatore_id' => $this->proclamatore_id,
|
||||||
'anno_teocratico_id' => $annoTeocratico->id,
|
'anno_teocratico_id' => $annoTeocratico->id,
|
||||||
'assigned_at' => $assignedDate,
|
'assigned_at' => $assignedDate,
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
|
// C'è un link da mandare solo se il territorio ha il PDF.
|
||||||
|
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (is_string($esito)) {
|
||||||
|
$this->addError('assigned_at', $esito);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assegnazione = $esito;
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())
|
activity()->causedBy(auth()->user())
|
||||||
->performedOn($assegnazione)
|
->performedOn($assegnazione)
|
||||||
|
// L'id, non il nome: nome e cognome sono cifrati nel database, e
|
||||||
|
// copiati qui in chiaro restavano leggibili anche dopo
|
||||||
|
// l'anonimizzazione. Il registro mostra il nome di oggi.
|
||||||
->withProperties([
|
->withProperties([
|
||||||
'territorio' => $territorio->numero,
|
'territorio' => $territorio->numero,
|
||||||
'proclamatore' => $proclamatore->nome_completo,
|
'proclamatore_id' => $proclamatore->id,
|
||||||
])
|
])
|
||||||
->log('assigned');
|
->log('assigned');
|
||||||
|
|
||||||
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
|
// Non si va avanti: si apre l'invio del link (vedi in cima).
|
||||||
return $this->redirect(route('territori.show', $territorio), navigate: true);
|
$this->apriInvio($assegnazione->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleLinkSent(int $assegnazioneId): void
|
/**
|
||||||
|
* Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione
|
||||||
|
* rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé
|
||||||
|
* e lo chiede con l'evento apri-invio).
|
||||||
|
*/
|
||||||
|
#[On('apri-invio')]
|
||||||
|
public function apriInvio(int $assegnazioneId): void
|
||||||
{
|
{
|
||||||
$this->authorize('territori.assign');
|
$this->authorize('territori.assign');
|
||||||
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
|
|
||||||
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
|
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
|
||||||
|
if (! $assegnazione) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
$this->invioId = $assegnazione->id;
|
||||||
public function selectedThumbnailUrl(): ?string
|
$this->canaleUsato = null;
|
||||||
|
$this->erroreInvio = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copia link o Condividi: l'invio vero lo fa chi usa l'app (il
|
||||||
|
* destinatario lo sceglie lui dai suoi contatti), qui si annota il canale.
|
||||||
|
*/
|
||||||
|
public function canale(string $canale): void
|
||||||
{
|
{
|
||||||
if (!$this->territorio_id) {
|
$assegnazione = $this->assegnazioneInInvio();
|
||||||
|
if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->canaleUsato = $canale;
|
||||||
|
$this->erroreInvio = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La risposta a "Hai inviato il link?": la si registra e solo adesso si
|
||||||
|
* va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare.
|
||||||
|
*/
|
||||||
|
public function concludiInvio(bool $inviato)
|
||||||
|
{
|
||||||
|
$assegnazione = $this->assegnazioneInInvio();
|
||||||
|
if (! $assegnazione) {
|
||||||
|
$this->invioId = null;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$t = Territorio::find($this->territorio_id);
|
|
||||||
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
|
if ($inviato && $this->canaleUsato === null) {
|
||||||
|
$this->erroreInvio = 'Scegli prima come mandare il link.';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
|
||||||
|
|
||||||
|
// Nel log il canale e l'id del proclamatore, mai nome, numero o email.
|
||||||
|
activity()->causedBy(auth()->user())
|
||||||
|
->performedOn($assegnazione)
|
||||||
|
->withProperties([
|
||||||
|
'territorio' => $assegnazione->territorio?->numero,
|
||||||
|
'proclamatore_id' => $assegnazione->proclamatore_id,
|
||||||
|
'canale' => $inviato ? $this->canaleUsato : null,
|
||||||
|
])
|
||||||
|
->log($inviato ? 'link_inviato' : 'link_non_inviato');
|
||||||
|
|
||||||
|
$numero = $assegnazione->territorio?->numero;
|
||||||
|
$nome = $assegnazione->proclamatore?->nome_completo;
|
||||||
|
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||||
|
|
||||||
|
session()->flash('success', $inviato
|
||||||
|
? "Territorio {$numero} assegnato a {$nome}: link inviato."
|
||||||
|
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
|
||||||
|
return $this->redirect($this->destinazione(route('assegnazioni.assegna')), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assegnazioneInInvio(): ?Assegnazione
|
||||||
|
{
|
||||||
|
if (! $this->invioId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('territori.assign');
|
||||||
|
|
||||||
|
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$territoriQuery = Territorio::where('attivo', true)
|
// Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
|
||||||
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
|
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||||
->with(['zona', 'tipologia']);
|
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
||||||
|
// serve all'anteprima, che si aggiorna nel browser.
|
||||||
if (trim($this->territorioSearch) !== '') {
|
$territori = Territorio::attivi()
|
||||||
$term = trim($this->territorioSearch);
|
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
|
||||||
$territoriQuery->where(function ($q) use ($term) {
|
|
||||||
$q->where('numero', 'like', "%{$term}%")
|
|
||||||
->orWhereHas('zona', fn($z) => $z->where('nome', 'like', "%{$term}%"))
|
|
||||||
->orWhereHas('tipologia', fn($t) => $t->where('nome', 'like', "%{$term}%"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$territoriDisponibili = $territoriQuery
|
|
||||||
->orderBy('numero')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$proclamatoriAttivi = Proclamatore::attivi()
|
|
||||||
->get()
|
->get()
|
||||||
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
|
->sortBy('numero', SORT_NATURAL);
|
||||||
|
|
||||||
// All currently assigned territories with links
|
$opzioniTerritori = $territori
|
||||||
$assegnazioniAperte = Assegnazione::aperte()
|
->map(fn (Territorio $t) => $t->opzioneScelta(
|
||||||
->with(['territorio.zona', 'proclamatore'])
|
$t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
|
||||||
->get()
|
) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||||
->sortBy(fn($a) => (int) $a->territorio?->numero);
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
// Ricerca rapida nella lista dei link: numero territorio, zona,
|
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
||||||
// proclamatore in un solo campo. In memoria perché nome/cognome del
|
|
||||||
// proclamatore sono cifrati e non filtrabili via SQL.
|
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
||||||
$assegnazioniAperteVisibili = $assegnazioniAperte;
|
// L'invio in corso; se nel frattempo l'assegnazione è rientrata o
|
||||||
if (trim($this->linkSearch) !== '') {
|
// sparita non c'è più niente da mandare.
|
||||||
$term = mb_strtolower(trim($this->linkSearch));
|
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
|
||||||
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) {
|
if ($this->invioId && ! $invio) {
|
||||||
$haystack = mb_strtolower(
|
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||||
($a->territorio?->numero ?? '') . ' ' .
|
|
||||||
($a->territorio?->zona?->nome ?? '') . ' ' .
|
|
||||||
($a->proclamatore?->nome_completo ?? '')
|
|
||||||
);
|
|
||||||
return str_contains($haystack, $term);
|
|
||||||
})->values();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Il link scade alla soglia rientro (giorni_per_smarrito).
|
|
||||||
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
|
|
||||||
|
|
||||||
return view('livewire.assegnazioni.assegna', [
|
return view('livewire.assegnazioni.assegna', [
|
||||||
'territoriDisponibili' => $territoriDisponibili,
|
'invio' => $invio,
|
||||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
'opzioniTerritori' => $opzioniTerritori,
|
||||||
'assegnazioniAperte' => $assegnazioniAperte,
|
'opzioniProclamatori' => $opzioniProclamatori,
|
||||||
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
|
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
||||||
'sogliaRientro' => $sogliaRientro,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
app/Livewire/Assegnazioni/ElencoAssegnati.php
Normal file
67
app/Livewire/Assegnazioni/ElencoAssegnati.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Assegnazioni;
|
||||||
|
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'elenco dei territori assegnati della pagina Assegna, separato dal form.
|
||||||
|
*
|
||||||
|
* Prima stava nello stesso componente: con ~80 righe la pagina pesava quasi
|
||||||
|
* 400 KB e Livewire li rimandava tutti a ogni interazione (scegliere un
|
||||||
|
* territorio, scrivere nella ricerca, aprire l'invio del link). Ora:
|
||||||
|
* - è un componente a sé, caricato subito dopo la pagina (#[Lazy]): il form
|
||||||
|
* compare subito e le sue azioni non ridisegnano l'elenco;
|
||||||
|
* - la ricerca filtra le righe nel browser (data-cerca), senza richieste;
|
||||||
|
* - le icone sono simboli SVG definiti una volta e richiamati con <use>.
|
||||||
|
*/
|
||||||
|
#[Lazy]
|
||||||
|
class ElencoAssegnati extends Component
|
||||||
|
{
|
||||||
|
public function toggleLinkSent(int $assegnazioneId): void
|
||||||
|
{
|
||||||
|
$this->authorize('territori.assign');
|
||||||
|
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
|
||||||
|
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Condividi" dall'elenco andato a buon fine: il link risulta inviato. */
|
||||||
|
public function segnaCondiviso(int $assegnazioneId): void
|
||||||
|
{
|
||||||
|
$this->authorize('territori.assign');
|
||||||
|
Assegnazione::aperte()->find($assegnazioneId)?->registraInvioLink(true, 'condividi');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function placeholder()
|
||||||
|
{
|
||||||
|
return view('livewire.assegnazioni.elenco-assegnati-attesa');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$assegnazioni = Assegnazione::aperte()
|
||||||
|
->with(['territorio.zona', 'proclamatore'])
|
||||||
|
->get()
|
||||||
|
->sortBy(fn ($a) => (string) $a->territorio?->numero, SORT_NATURAL)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
// Il testo su cui filtra la ricerca nel browser: numero, zona e nome
|
||||||
|
// (decifrato qui: in SQL non si potrebbe cercare), senza accenti.
|
||||||
|
$cerca = $assegnazioni->mapWithKeys(fn ($a) => [$a->id => Str::lower(Str::ascii(implode(' ', array_filter([
|
||||||
|
$a->territorio?->numero,
|
||||||
|
$a->territorio?->zona?->nome,
|
||||||
|
$a->proclamatore?->nome_completo,
|
||||||
|
]))))]);
|
||||||
|
|
||||||
|
return view('livewire.assegnazioni.elenco-assegnati', [
|
||||||
|
'assegnazioni' => $assegnazioni,
|
||||||
|
'cerca' => $cerca,
|
||||||
|
// Il link scade alla soglia rientro (giorni_per_smarrito).
|
||||||
|
'sogliaRientro' => max(1, (int) Setting::getValue('giorni_per_smarrito', 120)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@
|
|||||||
namespace App\Livewire\Assegnazioni;
|
namespace App\Livewire\Assegnazioni;
|
||||||
|
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
|
||||||
class Rientra extends Component
|
class Rientra extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public Assegnazione $assegnazione;
|
public Assegnazione $assegnazione;
|
||||||
public string $returned_at = '';
|
public string $returned_at = '';
|
||||||
public bool $showCampaignPrompt = false;
|
public bool $showCampaignPrompt = false;
|
||||||
@@ -92,22 +95,43 @@ class Rientra extends Component
|
|||||||
->performedOn($this->assegnazione)
|
->performedOn($this->assegnazione)
|
||||||
->withProperties([
|
->withProperties([
|
||||||
'territorio' => $territorio->numero,
|
'territorio' => $territorio->numero,
|
||||||
'proclamatore' => $proclamatore->nome_completo,
|
'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
|
||||||
'giorni' => $this->assegnazione->giorni,
|
'giorni' => $this->assegnazione->giorni,
|
||||||
'campagna' => $this->counted_in_campaign,
|
'campagna' => $this->counted_in_campaign,
|
||||||
])
|
])
|
||||||
->log('returned');
|
->log('returned');
|
||||||
|
|
||||||
|
// The operator answered the campaign prompt: record the choice
|
||||||
|
// (counted or not) as its own audit entry.
|
||||||
|
if ($this->showCampaignPrompt && $this->campagna_id) {
|
||||||
|
$this->assegnazione->logCampagnaPercorso(
|
||||||
|
Campagna::find($this->campagna_id),
|
||||||
|
$this->counted_in_campaign,
|
||||||
|
'Rientro'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
|
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
|
||||||
return $this->redirect(route('territori.show', $territorio), navigate: true);
|
return $this->redirect($this->destinazione(route('territori.show', $territorio)), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null;
|
$campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null;
|
||||||
|
|
||||||
|
// Warning shown inside the campaign prompt only: holding time up to
|
||||||
|
// the selected return date above the "da rientrare" threshold.
|
||||||
|
$giorniPercorrenza = null;
|
||||||
|
$sogliaPercorrenza = null;
|
||||||
|
if ($campagna && $this->returned_at) {
|
||||||
|
$giorniPercorrenza = $this->assegnazione->giorniPercorrenza(\Carbon\Carbon::parse($this->returned_at));
|
||||||
|
$sogliaPercorrenza = Assegnazione::sogliaPercorrenzaElevata();
|
||||||
|
}
|
||||||
|
|
||||||
return view('livewire.assegnazioni.rientra', [
|
return view('livewire.assegnazioni.rientra', [
|
||||||
'campagna' => $campagna,
|
'campagna' => $campagna,
|
||||||
|
'giorniPercorrenza' => $giorniPercorrenza,
|
||||||
|
'sogliaPercorrenza' => $sogliaPercorrenza,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,22 @@ class AuditLog extends Component
|
|||||||
$events = Activity::select('description')->distinct()->pluck('description');
|
$events = Activity::select('description')->distinct()->pluck('description');
|
||||||
$users = \App\Models\User::orderBy('name')->get();
|
$users = \App\Models\User::orderBy('name')->get();
|
||||||
|
|
||||||
|
$activities = $query->paginate(30);
|
||||||
|
|
||||||
|
// Il log conserva solo l'id del proclamatore: il nome si legge adesso,
|
||||||
|
// così chi è stato anonimizzato compare come tale anche nello storico.
|
||||||
|
$idProclamatori = $activities->getCollection()
|
||||||
|
->map(fn ($a) => data_get($a->properties, 'proclamatore_id'))
|
||||||
|
->filter()
|
||||||
|
->unique();
|
||||||
|
$nomiProclamatori = \App\Models\Proclamatore::withTrashed()
|
||||||
|
->whereIn('id', $idProclamatori)
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn ($p) => [$p->id => $p->nome_completo]);
|
||||||
|
|
||||||
return view('livewire.audit-log', [
|
return view('livewire.audit-log', [
|
||||||
'activities' => $query->paginate(30),
|
'activities' => $activities,
|
||||||
|
'nomiProclamatori' => $nomiProclamatori,
|
||||||
'events' => $events,
|
'events' => $events,
|
||||||
'users' => $users,
|
'users' => $users,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Campagne;
|
namespace App\Livewire\Campagne;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
|
||||||
class CampagnaCreate extends Component
|
class CampagnaCreate extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public string $descrizione = '';
|
public string $descrizione = '';
|
||||||
public string $start_date = '';
|
public string $start_date = '';
|
||||||
public string $end_date = '';
|
public string $end_date = '';
|
||||||
@@ -34,7 +37,7 @@ class CampagnaCreate extends Component
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
session()->flash('success', "Campagna '{$campagna->descrizione}' creata.");
|
session()->flash('success', "Campagna '{$campagna->descrizione}' creata.");
|
||||||
return $this->redirect(route('campagne.index'), navigate: true);
|
return $this->redirect($this->destinazione(route('campagne.index')), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Campagne;
|
namespace App\Livewire\Campagne;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
|
||||||
class CampagnaEdit extends Component
|
class CampagnaEdit extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public Campagna $campagna;
|
public Campagna $campagna;
|
||||||
public string $descrizione = '';
|
public string $descrizione = '';
|
||||||
public string $start_date = '';
|
public string $start_date = '';
|
||||||
@@ -47,7 +50,7 @@ class CampagnaEdit extends Component
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
session()->flash('success', "Campagna aggiornata.");
|
session()->flash('success', "Campagna aggiornata.");
|
||||||
return $this->redirect(route('campagne.index'), navigate: true);
|
return $this->redirect($this->destinazione(route('campagne.index')), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Campagne;
|
namespace App\Livewire\Campagne;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
|
||||||
class CampagnaIndex extends Component
|
class CampagnaIndex extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
public function deleteCampagna(int $id)
|
public function deleteCampagna(int $id)
|
||||||
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
|
|||||||
|
|
||||||
$campagna = Campagna::findOrFail($id);
|
$campagna = Campagna::findOrFail($id);
|
||||||
$campagna->delete();
|
$campagna->delete();
|
||||||
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
|
$this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Campagne;
|
namespace App\Livewire\Campagne;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
@@ -9,6 +10,8 @@ use App\Models\Territorio;
|
|||||||
|
|
||||||
class CampagnaShow extends Component
|
class CampagnaShow extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public Campagna $campagna;
|
public Campagna $campagna;
|
||||||
|
|
||||||
public function mount(Campagna $campagna)
|
public function mount(Campagna $campagna)
|
||||||
@@ -18,16 +21,28 @@ class CampagnaShow extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
// Assignments counted for this campaign:
|
// Le assegnazioni contate per la campagna, cioè quelle per cui al
|
||||||
// - assigned on or after campaign start
|
// rientro si è risposto sì (campagnaApplicabile propone anche quelle
|
||||||
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
|
// uscite poco prima dell'inizio e rientrate durante). Le stesse della
|
||||||
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
|
// percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
|
||||||
->where('counted_in_campaign', true)
|
// e l'elenco era più corto di quanto la percentuale contasse.
|
||||||
->where('assigned_at', '>=', $this->campagna->start_date)
|
$conteggiate = $this->campagna->assegnazioniConteggiate()
|
||||||
->with(['territorio', 'proclamatore'])
|
->with(['territorio', 'proclamatore'])
|
||||||
->orderBy('returned_at')
|
->orderBy('returned_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
// Open assignments can still be counted for this campaign only while
|
||||||
|
// no later campaign has started: on return the most recent one is
|
||||||
|
// offered (see Assegnazione::campagnaApplicabile()).
|
||||||
|
$superata = Campagna::where('start_date', '>', $this->campagna->start_date)
|
||||||
|
->where('start_date', '<=', now()->toDateString())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$inCorso = $superata ? null : $this->campagna->assegnazioniInCorso()
|
||||||
|
->with(['territorio', 'proclamatore'])
|
||||||
|
->orderBy('assigned_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
// All assignments that were active during this campaign range
|
// All assignments that were active during this campaign range
|
||||||
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
|
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
|
||||||
->where(function ($q) {
|
->where(function ($q) {
|
||||||
@@ -38,6 +53,7 @@ class CampagnaShow extends Component
|
|||||||
|
|
||||||
return view('livewire.campagne.campagna-show', [
|
return view('livewire.campagne.campagna-show', [
|
||||||
'conteggiate' => $conteggiate,
|
'conteggiate' => $conteggiate,
|
||||||
|
'inCorso' => $inCorso,
|
||||||
'assegnateNelRange' => $assegnateNelRange,
|
'assegnateNelRange' => $assegnateNelRange,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Livewire/Concerns/RicordaRitorno.php
Normal file
33
app/Livewire/Concerns/RicordaRitorno.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns;
|
||||||
|
|
||||||
|
use App\Support\Ritorno;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per le pagine che si aprono da un'altra (schede e operazioni): ricorda da
|
||||||
|
* dove si è partiti (?da=…, vedi App\Support\Ritorno) e ci torna dopo la
|
||||||
|
* conferma. Annulla e "Torna…" nella vista usano $ritorno allo stesso modo.
|
||||||
|
*
|
||||||
|
* Aperta senza `da` (menu laterale, link salvato) la pagina si comporta come
|
||||||
|
* prima: ogni componente passa la sua destinazione predefinita.
|
||||||
|
*/
|
||||||
|
trait RicordaRitorno
|
||||||
|
{
|
||||||
|
/** URL della pagina di partenza; null se aperta direttamente. */
|
||||||
|
#[Locked]
|
||||||
|
public ?string $ritorno = null;
|
||||||
|
|
||||||
|
/** Hook di Livewire: gira al mount di ogni componente che usa il trait. */
|
||||||
|
public function mountRicordaRitorno(): void
|
||||||
|
{
|
||||||
|
$this->ritorno = Ritorno::daRichiesta();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dove andare dopo la conferma: la pagina di partenza, o quella predefinita. */
|
||||||
|
protected function destinazione(string $predefinita): string
|
||||||
|
{
|
||||||
|
return $this->ritorno ?? $predefinita;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,22 +174,46 @@ trait SegueOperazioneBackup
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
$this->rifiutaPerOccupato($inCorso);
|
||||||
// premuto il pulsante vuole comunque vedere a che punto e'.
|
|
||||||
$this->operationId = $inCorso;
|
|
||||||
$this->logSpediti = 0;
|
|
||||||
$this->aggiorna();
|
|
||||||
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function nuovaOperazione(string $tipo): string
|
/**
|
||||||
|
* Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
||||||
|
* premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio
|
||||||
|
* dice quale operazione occupa il posto, perche' puo' essere un import
|
||||||
|
* avviato da un'altra zona della pagina, o da un altro amministratore.
|
||||||
|
*/
|
||||||
|
private function rifiutaPerOccupato(string $inCorso): void
|
||||||
|
{
|
||||||
|
$this->operationId = $inCorso;
|
||||||
|
$this->logSpediti = 0;
|
||||||
|
$this->aggiorna();
|
||||||
|
$this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prende il posto e crea l'operazione; null se il posto e' occupato.
|
||||||
|
*
|
||||||
|
* postoLibero() resta il controllo rapido prima di validare il resto, ma fra
|
||||||
|
* quel controllo e questo passo un'altra richiesta puo' arrivare prima — una
|
||||||
|
* seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex
|
||||||
|
* di BackupOperationState::occupa(), che si decide davvero.
|
||||||
|
*/
|
||||||
|
protected function nuovaOperazione(string $tipo): ?string
|
||||||
{
|
{
|
||||||
$operationId = (string) Str::uuid();
|
$operationId = (string) Str::uuid();
|
||||||
|
|
||||||
$stato = app(BackupOperationState::class);
|
$stato = app(BackupOperationState::class);
|
||||||
$stato->initialize($operationId, $tipo, 'In coda');
|
$inCorso = $stato->occupa($operationId, $tipo, 'In coda');
|
||||||
|
|
||||||
|
if ($inCorso !== null) {
|
||||||
|
$this->rifiutaPerOccupato($inCorso);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
|
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
|
||||||
|
|
||||||
$this->logSpediti = 0;
|
$this->logSpediti = 0;
|
||||||
|
|||||||
@@ -2,45 +2,54 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Piantine;
|
namespace App\Livewire\Piantine;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
use App\Models\Tipologia;
|
use App\Models\Tipologia;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
use App\Services\Piantine\PiantinaRenderer;
|
|
||||||
use App\Services\Piantine\PiantineArea;
|
use App\Services\Piantine\PiantineArea;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor delle piantine a schermo intero (solo amministratore).
|
* Editor delle piantine a schermo intero (solo amministratore).
|
||||||
*
|
*
|
||||||
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
|
* Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
|
||||||
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
|
* disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
|
||||||
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
|
* La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
|
||||||
* modulo che poi gira di mano in mano.
|
* (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
|
* Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
|
||||||
* solo dopo si compila la scheda del territorio — che può anche non esistere
|
* aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
|
||||||
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
|
* disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
|
||||||
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
|
*
|
||||||
|
* 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')]
|
#[Layout('components.layouts.editor')]
|
||||||
class PiantineEditor extends Component
|
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 ?int $mappaId = null;
|
||||||
|
|
||||||
public string $nome = '';
|
public string $nome = '';
|
||||||
|
|
||||||
/** Geometria appena disegnata, non ancora salvata. */
|
/** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
|
||||||
public ?string $geometriaInAttesa = null;
|
public string $salvatoAlle = '';
|
||||||
|
|
||||||
public bool $pannelloAperto = false;
|
// ─── Territorio ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Scheda del territorio ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
||||||
public string $territorioScelto = '';
|
public string $territorioScelto = '';
|
||||||
@@ -53,15 +62,24 @@ class PiantineEditor extends Component
|
|||||||
|
|
||||||
public string $confini = '';
|
public string $confini = '';
|
||||||
|
|
||||||
/** Campi di creazione al volo, aperti solo quando servono. */
|
|
||||||
public string $nuovaZona = '';
|
public string $nuovaZona = '';
|
||||||
|
|
||||||
public string $nuovaTipologia = '';
|
public string $nuovaTipologia = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrivati da "Nuovo territorio" (?nuovo=1): la mappa parte già in
|
||||||
|
* disegno, e la prima piantina disegnata si apre con "nuovo territorio"
|
||||||
|
* scelto, pronta da compilare e creare.
|
||||||
|
*/
|
||||||
|
#[Locked]
|
||||||
|
public bool $creaTerritorio = false;
|
||||||
|
|
||||||
public function mount(?int $mappa = null): void
|
public function mount(?int $mappa = null): void
|
||||||
{
|
{
|
||||||
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
||||||
$this->apri($mappa);
|
$this->carica(TerritorioMappa::with('territorio')->find($mappa));
|
||||||
|
} elseif (request()->boolean('nuovo')) {
|
||||||
|
$this->creaTerritorio = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,38 +91,46 @@ class PiantineEditor extends Component
|
|||||||
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
||||||
|
|
||||||
if (! $mappa) {
|
if (! $mappa) {
|
||||||
|
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
|
||||||
|
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mappaId = $mappa->id;
|
$this->carica($mappa);
|
||||||
$this->nome = (string) $mappa->nome;
|
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
|
||||||
$this->geometriaInAttesa = null;
|
|
||||||
$this->pannelloAperto = true;
|
|
||||||
|
|
||||||
$this->caricaScheda($mappa->territorio);
|
|
||||||
$this->aggiornaMappaJs(inquadra: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
|
||||||
public function nuova(): void
|
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([
|
$this->reset([
|
||||||
'mappaId', 'nome', 'geometriaInAttesa',
|
'mappaId', 'nome', 'salvatoAlle',
|
||||||
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
||||||
'nuovaZona', 'nuovaTipologia',
|
'nuovaZona', 'nuovaTipologia',
|
||||||
]);
|
]);
|
||||||
$this->resetValidation();
|
|
||||||
$this->pannelloAperto = true;
|
|
||||||
|
|
||||||
$this->aggiornaMappaJs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function chiudiPannello(): void
|
private function caricaTerritorio(?Territorio $territorio): 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->territorioScelto = $territorio ? (string) $territorio->id : '';
|
||||||
$this->numero = (string) ($territorio->numero ?? '');
|
$this->numero = (string) ($territorio->numero ?? '');
|
||||||
@@ -113,12 +139,123 @@ class PiantineEditor extends Component
|
|||||||
$this->confini = (string) ($territorio->confini ?? '');
|
$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');
|
||||||
|
|
||||||
|
if ($this->creaTerritorio) {
|
||||||
|
$this->creaTerritorio = false;
|
||||||
|
$this->territorioScelto = 'nuovo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
public function updatedTerritorioScelto(string $valore): void
|
||||||
{
|
{
|
||||||
$this->resetValidation();
|
$this->resetValidation();
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
|
if (! $mappa) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($valore === 'nuovo') {
|
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->numero = '';
|
||||||
$this->zonaId = '';
|
$this->zonaId = '';
|
||||||
$this->tipologiaId = '';
|
$this->tipologiaId = '';
|
||||||
@@ -127,34 +264,158 @@ class PiantineEditor extends Component
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->geometriaInAttesa = json_encode($geometry);
|
$territorio = Territorio::find($valore);
|
||||||
$this->pannelloAperto = true;
|
if (! $territorio) {
|
||||||
|
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
|
||||||
|
$this->caricaTerritorio($mappa->territorio);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scartaDisegno(): void
|
$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 creaTerritorio(): void
|
||||||
{
|
{
|
||||||
$this->geometriaInAttesa = null;
|
$mappa = $this->mappaAperta();
|
||||||
$this->aggiornaMappaJs();
|
|
||||||
|
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 ──────────────────────────────────
|
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
||||||
|
|
||||||
public function creaZona()
|
public function creaZona(): void
|
||||||
{
|
{
|
||||||
|
$this->nuovaZona = trim($this->nuovaZona);
|
||||||
$dati = $this->validate(
|
$dati = $this->validate(
|
||||||
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
||||||
[
|
[
|
||||||
@@ -163,16 +424,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->zonaId = (string) $zona->id;
|
||||||
$this->nuovaZona = '';
|
$this->nuovaZona = '';
|
||||||
|
$this->updatedZonaId();
|
||||||
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
|
$this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function creaTipologia()
|
public function creaTipologia(): void
|
||||||
{
|
{
|
||||||
|
$this->nuovaTipologia = trim($this->nuovaTipologia);
|
||||||
$dati = $this->validate(
|
$dati = $this->validate(
|
||||||
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
||||||
[
|
[
|
||||||
@@ -181,221 +443,170 @@ 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->tipologiaId = (string) $tipologia->id;
|
||||||
$this->nuovaTipologia = '';
|
$this->nuovaTipologia = '';
|
||||||
|
$this->updatedTipologiaId();
|
||||||
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
|
$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;
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
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) {
|
if (! $mappa) {
|
||||||
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||||
|
$this->avvisaErrore($motivo);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||||
$renderer->render($mappa);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
session()->flash('error', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
#[On('piantina-pubblicata')]
|
||||||
}
|
public function pubblicata(int $mappaId): void
|
||||||
|
|
||||||
public function elimina()
|
|
||||||
{
|
{
|
||||||
$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) {
|
if (! $mappa) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$etichetta = $mappa->etichetta();
|
$etichetta = $mappa->etichetta();
|
||||||
|
$id = $mappa->id;
|
||||||
|
|
||||||
if ($mappa->image_path) {
|
if ($mappa->image_path) {
|
||||||
Storage::disk('public')->delete($mappa->image_path);
|
Storage::disk('public')->delete($mappa->image_path);
|
||||||
}
|
}
|
||||||
$mappa->delete();
|
$mappa->delete();
|
||||||
|
|
||||||
$this->nuova();
|
$this->svuota();
|
||||||
session()->flash('success', "{$etichetta} eliminata.");
|
$this->dispatch('piantina-rimossa', mappaId: $id);
|
||||||
|
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
// ─── 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 [
|
return [
|
||||||
'area' => $area->area(),
|
'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,
|
'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()
|
||||||
|
->with(['zona:id,nome', 'tipologia:id,nome'])
|
||||||
|
->whereNotIn('id', $occupati)
|
||||||
|
->orderByRaw('LENGTH(numero), numero')
|
||||||
|
->get(['id', 'numero', 'zona_id', 'tipologia_id', 'attivo', 'deleted_at']);
|
||||||
|
|
||||||
|
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
|
||||||
|
$territori->prepend($mappa->territorio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il campo di scelta con ricerca (<x-scelta>): due voci fisse in cima,
|
||||||
|
// poi i territori, che qui restano sceglibili anche se cestinati o
|
||||||
|
// disattivati (è il collegamento che già c'era).
|
||||||
|
$opzioniTerritori = [
|
||||||
|
['valore' => '', 'etichetta' => '— nessun territorio —', 'speciale' => true],
|
||||||
|
['valore' => 'nuovo', 'etichetta' => '+ Crea un territorio nuovo', 'speciale' => true],
|
||||||
|
...$territori->map(function (Territorio $t) {
|
||||||
|
$opzione = $t->opzioneScelta(nota: $t->trashed() ? 'nel cestino' : (! $t->attivo ? 'disattivato' : null));
|
||||||
|
$opzione['etichetta'] = str_replace('N° ', 'Territorio ', $opzione['etichetta']);
|
||||||
|
|
||||||
|
return $opzione;
|
||||||
|
})->all(),
|
||||||
|
];
|
||||||
|
|
||||||
return view('livewire.piantine.piantine-editor', [
|
return view('livewire.piantine.piantine-editor', [
|
||||||
'mappa' => $mappa,
|
'mappa' => $mappa,
|
||||||
'areaCaricata' => $area->area() !== null,
|
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
||||||
'datiMappaIniziali' => $this->datiMappa($area),
|
'opzioniTerritori' => $opzioniTerritori,
|
||||||
'territori' => Territorio::attivi()
|
|
||||||
->orderByRaw('LENGTH(numero), numero')
|
|
||||||
->get(['id', 'numero']),
|
|
||||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||||
'tipologie' => Tipologia::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;
|
namespace App\Livewire\Piantine;
|
||||||
|
|
||||||
use App\Models\Territorio;
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
use App\Models\TerritorioPdfVersione;
|
use App\Models\TerritorioPdfVersione;
|
||||||
use App\Services\Piantine\PiantinaPdfService;
|
use App\Services\Piantine\PiantinaPdfService;
|
||||||
use App\Services\Piantine\PiantinaRenderer;
|
|
||||||
use App\Services\Piantine\PiantineArea;
|
use App\Services\Piantine\PiantineArea;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagina di gestione delle piantine (solo amministratore).
|
* Pagina di gestione delle piantine (solo amministratore).
|
||||||
*
|
*
|
||||||
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano
|
* È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
|
||||||
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno
|
* con modifiche da pubblicare), l'area della città e lo storico dei PDF
|
||||||
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su
|
* sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
|
||||||
* una mappa larga un terzo di schermo non si taglia un confine.
|
* da qui si apre, si pubblica (con la stessa finestra di anteprima
|
||||||
|
* dell'editor) e si elimina.
|
||||||
*/
|
*/
|
||||||
class PiantineManager extends Component
|
class PiantineManager extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithFileUploads;
|
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;
|
public $kmlFile = null;
|
||||||
|
|
||||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
public function updatedFiltro(): void
|
||||||
public ?int $collegamentoAperto = null;
|
{
|
||||||
|
if (! array_key_exists($this->filtro, self::FILTRI)) {
|
||||||
public ?int $territorioDaCollegare = null;
|
$this->filtro = 'tutte';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function caricaKml(PiantineArea $area)
|
public function caricaKml(PiantineArea $area): void
|
||||||
{
|
{
|
||||||
$this->validate(
|
$this->validate(
|
||||||
['kmlFile' => 'required|file|max:10240'],
|
['kmlFile' => 'required|file|max:10240'],
|
||||||
@@ -62,111 +84,42 @@ class PiantineManager extends Component
|
|||||||
$this->kmlFile = null;
|
$this->kmlFile = null;
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
|
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 {
|
if (! $mappa) {
|
||||||
$renderer->render($mappa);
|
$this->avvisaErrore('La piantina non esiste più.');
|
||||||
} catch (\Throwable $e) {
|
|
||||||
session()->flash('error', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||||
}
|
$this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
|
||||||
|
|
||||||
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.');
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
}
|
||||||
} catch (\Throwable $e) {
|
|
||||||
session()->flash('error', $e->getMessage());
|
|
||||||
|
|
||||||
|
/** 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;
|
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();
|
$etichetta = $mappa->etichetta();
|
||||||
|
|
||||||
if ($mappa->image_path) {
|
if ($mappa->image_path) {
|
||||||
@@ -174,59 +127,106 @@ class PiantineManager extends Component
|
|||||||
}
|
}
|
||||||
$mappa->delete();
|
$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 ───────────────────────────────────────────────────
|
// ─── 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 ?? '?';
|
$numero = $versione->territorio?->numero ?? '?';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdfService->ripristina($versione);
|
$pdfService->ripristina($versione);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
session()->flash('error', $e->getMessage());
|
$this->avvisaErrore($e->getMessage());
|
||||||
|
|
||||||
return;
|
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)) {
|
if (! $versione) {
|
||||||
Storage::disk('public')->delete($versione->pdf_path);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Storage::disk('public')->delete($versione->pdf_path);
|
||||||
$versione->delete();
|
$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)
|
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', [
|
return view('livewire.piantine.piantine-manager', [
|
||||||
'config' => $area->config(),
|
'config' => $area->config(),
|
||||||
'piantine' => TerritorioMappa::with('territorio')
|
'elenco' => $elenco,
|
||||||
->get()
|
'totale' => $piantine->count(),
|
||||||
->sortBy(fn ($m) => [
|
'conteggi' => $conteggi,
|
||||||
$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')
|
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||||
->orderByDesc('sostituito_il')
|
->orderByDesc('sostituito_il')
|
||||||
->limit(30)
|
->orderByDesc('id')
|
||||||
->get(),
|
->paginate(15, pageName: 'versioni'),
|
||||||
'title' => 'Piantine dei territori',
|
'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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,84 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ConfigurazionePosta;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informativa privacy (solo amministratore).
|
||||||
|
*
|
||||||
|
* Tutto ciò che può cambiare si legge qui, al momento: impostazioni
|
||||||
|
* (congregazione, conservazione del log, scadenza dei link, download del
|
||||||
|
* PDF, server di posta), configurazione (indirizzo, cifratura, durata della
|
||||||
|
* sessione), ruoli con i loro permessi reali e quanti dati sono conservati.
|
||||||
|
* Il testo resta nella vista; i numeri non vanno mai scritti a mano.
|
||||||
|
*/
|
||||||
class Privacy extends Component
|
class Privacy extends Component
|
||||||
{
|
{
|
||||||
|
/** Data dell'ultima revisione del testo dell'informativa. */
|
||||||
|
public const REVISIONE = '19 ottobre 2026';
|
||||||
|
|
||||||
|
/** I permessi spiegati a chi legge l'informativa. */
|
||||||
|
public const PERMESSI = [
|
||||||
|
'settings.manage' => 'impostazioni, utenti, backup e import',
|
||||||
|
'proclamatori.manage' => 'anagrafica dei proclamatori',
|
||||||
|
'territori.manage' => 'gestione dei territori e delle piantine',
|
||||||
|
'campagne.manage' => 'campagne',
|
||||||
|
'registro.view' => 'consultazione del registro delle assegnazioni',
|
||||||
|
'registro.export' => 'esportazione del registro',
|
||||||
|
'territori.assign' => 'assegnazione dei territori',
|
||||||
|
'territori.return' => 'rientro dei territori',
|
||||||
|
'audit.view' => 'consultazione del registro delle attività',
|
||||||
|
'audit.export' => 'esportazione del registro delle attività',
|
||||||
|
];
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$settings = Setting::instance();
|
$impostazioni = Setting::instance();
|
||||||
|
$posta = app(ConfigurazionePosta::class);
|
||||||
|
|
||||||
|
$hostPosta = $posta->configurataNellApp()
|
||||||
|
? (string) $impostazioni->mail_host
|
||||||
|
: (string) config('mail.mailers.smtp.host');
|
||||||
|
|
||||||
|
$indirizzo = (string) config('app.url');
|
||||||
|
|
||||||
return view('livewire.privacy', [
|
return view('livewire.privacy', [
|
||||||
'congregazione' => $settings->congregazione_nome ?? 'Congregazione',
|
'revisione' => self::REVISIONE,
|
||||||
'auditRetention' => $settings->audit_retention_days ?? 365,
|
'congregazione' => $impostazioni->congregazione_nome ?: 'Congregazione',
|
||||||
|
'indirizzo' => $indirizzo,
|
||||||
|
'https' => str_starts_with($indirizzo, 'https://'),
|
||||||
|
'cifratura' => (string) config('app.cipher'),
|
||||||
|
'durataSessione' => (int) config('session.lifetime'),
|
||||||
|
'auditRetention' => (int) ($impostazioni->audit_retention_days ?: 365),
|
||||||
|
'scadenzaLink' => max(1, (int) ($impostazioni->giorni_per_smarrito ?: 120)),
|
||||||
|
'downloadPdf' => (bool) $impostazioni->pdf_viewer_show_download,
|
||||||
|
'postaAttiva' => $posta->funzionante(),
|
||||||
|
'hostPosta' => $hostPosta !== '' ? $hostPosta : null,
|
||||||
|
'ruoli' => Role::with('permissions')->orderBy('name')->get()->map(fn (Role $r) => [
|
||||||
|
'nome' => $r->name,
|
||||||
|
'permessi' => $r->permissions->pluck('name')
|
||||||
|
->map(fn ($p) => self::PERMESSI[$p] ?? $p)
|
||||||
|
->all(),
|
||||||
|
]),
|
||||||
|
// Quanti dati sono conservati adesso: solo conteggi, contati in
|
||||||
|
// SQL, senza decifrare né mostrare nomi.
|
||||||
|
'conteggi' => [
|
||||||
|
'proclamatori' => Proclamatore::count(),
|
||||||
|
'proclamatoriAttivi' => Proclamatore::where('attivo', true)->count(),
|
||||||
|
'anonimizzati' => Proclamatore::onlyTrashed()->count(),
|
||||||
|
'utenti' => User::count(),
|
||||||
|
'assegnazioni' => Assegnazione::count(),
|
||||||
|
'assegnazioniAperte' => Assegnazione::aperte()->count(),
|
||||||
|
'attivita' => Activity::count(),
|
||||||
|
'attivitaDal' => Activity::min('created_at'),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Proclamatori;
|
namespace App\Livewire\Proclamatori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreCestino extends Component
|
class ProclamatoreCestino extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
public function restore(int $id)
|
public function restore(int $id)
|
||||||
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
|
|||||||
activity()->causedBy(auth()->user())
|
activity()->causedBy(auth()->user())
|
||||||
->performedOn($proclamatore)
|
->performedOn($proclamatore)
|
||||||
->log('restored');
|
->log('restored');
|
||||||
session()->flash('success', "Proclamatore ripristinato.");
|
$this->avvisa("Proclamatore ripristinato.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function forceDelete(int $id)
|
public function forceDelete(int $id)
|
||||||
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
|
|||||||
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
||||||
|
|
||||||
if ($proclamatore->assegnazioni()->exists()) {
|
if ($proclamatore->assegnazioni()->exists()) {
|
||||||
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
|
$this->avvisaErrore('Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())
|
activity()->causedBy(auth()->user())
|
||||||
->log('force_deleted_proclamatore');
|
->log('force_deleted_proclamatore');
|
||||||
$proclamatore->forceDelete();
|
$proclamatore->forceDelete();
|
||||||
session()->flash('success', 'Proclamatore eliminato definitivamente.');
|
$this->avvisa('Proclamatore eliminato definitivamente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Proclamatori;
|
namespace App\Livewire\Proclamatori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreCreate extends Component
|
class ProclamatoreCreate extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public string $nome = '';
|
public string $nome = '';
|
||||||
public string $cognome = '';
|
public string $cognome = '';
|
||||||
public bool $attivo = true;
|
public bool $attivo = true;
|
||||||
@@ -37,7 +40,7 @@ class ProclamatoreCreate extends Component
|
|||||||
->log('created');
|
->log('created');
|
||||||
|
|
||||||
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato.");
|
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato.");
|
||||||
return $this->redirect(route('proclamatori.index'), navigate: true);
|
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Proclamatori;
|
namespace App\Livewire\Proclamatori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreEdit extends Component
|
class ProclamatoreEdit extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public Proclamatore $proclamatore;
|
public Proclamatore $proclamatore;
|
||||||
public string $nome = '';
|
public string $nome = '';
|
||||||
public string $cognome = '';
|
public string $cognome = '';
|
||||||
@@ -42,7 +45,7 @@ class ProclamatoreEdit extends Component
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
|
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
|
||||||
return $this->redirect(route('proclamatori.index'), navigate: true);
|
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,17 +2,25 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Proclamatori;
|
namespace App\Livewire\Proclamatori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreIndex extends Component
|
class ProclamatoreIndex extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
|
// Ricerca, filtro e ordinamento nell'indirizzo: si ritrovano tornando.
|
||||||
|
#[Url(except: '')]
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filtroStato = '';
|
public string $filtroStato = '';
|
||||||
|
#[Url(except: 'cognome')]
|
||||||
public string $sortField = 'cognome';
|
public string $sortField = 'cognome';
|
||||||
|
#[Url(except: 'asc')]
|
||||||
public string $sortDirection = 'asc';
|
public string $sortDirection = 'asc';
|
||||||
|
|
||||||
protected $queryString = [
|
protected $queryString = [
|
||||||
@@ -59,12 +67,12 @@ class ProclamatoreIndex extends Component
|
|||||||
$proclamatore = Proclamatore::findOrFail($id);
|
$proclamatore = Proclamatore::findOrFail($id);
|
||||||
|
|
||||||
if ($proclamatore->assegnazioni()->aperte()->exists()) {
|
if ($proclamatore->assegnazioni()->aperte()->exists()) {
|
||||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proclamatore->delete();
|
$proclamatore->delete();
|
||||||
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function anonimizza(int $id)
|
public function anonimizza(int $id)
|
||||||
@@ -73,7 +81,7 @@ class ProclamatoreIndex extends Component
|
|||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($id);
|
$proclamatore = Proclamatore::findOrFail($id);
|
||||||
$proclamatore->anonimizza();
|
$proclamatore->anonimizza();
|
||||||
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
|
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Proclamatori;
|
namespace App\Livewire\Proclamatori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
|
|
||||||
class ProclamatoreShow extends Component
|
class ProclamatoreShow extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public Proclamatore $proclamatore;
|
public Proclamatore $proclamatore;
|
||||||
|
|
||||||
public function mount(Proclamatore $proclamatore)
|
public function mount(Proclamatore $proclamatore)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Profile extends Component
|
class Profile extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
public string $email = '';
|
public string $email = '';
|
||||||
|
|
||||||
@@ -30,10 +32,22 @@ class Profile extends Component
|
|||||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->update([
|
$user->fill([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
]);
|
]);
|
||||||
|
$emailCambiata = $user->isDirty('email');
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// L'indirizzo nuovo va confermato (User::booted lo segna da
|
||||||
|
// confermare): finché non apre il link, l'account resta bloccato.
|
||||||
|
if ($emailCambiata) {
|
||||||
|
$user->inviaConfermaEmail()
|
||||||
|
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
|
||||||
|
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
|
||||||
|
|
||||||
|
return $this->redirect(route('verification.notice'), navigate: false);
|
||||||
|
}
|
||||||
|
|
||||||
session()->flash('profile_success', 'Profilo aggiornato con successo.');
|
session()->flash('profile_success', 'Profilo aggiornato con successo.');
|
||||||
|
|
||||||
@@ -67,7 +81,7 @@ class Profile extends Component
|
|||||||
|
|
||||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||||
|
|
||||||
session()->flash('password_success', 'Password aggiornata con successo.');
|
$this->avvisa('Password aggiornata con successo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
@@ -11,24 +12,28 @@ use App\Models\Proclamatore;
|
|||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
use App\Models\Tipologia;
|
use App\Models\Tipologia;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use App\Services\Registro\ModuloS13;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class Registro extends Component
|
class Registro extends Component
|
||||||
{
|
{
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */
|
// Ricerca, filtri e ordinamento nell'indirizzo: si ritrovano tornando.
|
||||||
private const S13_RIGHE_PER_PAGINA = 21;
|
#[Url(except: '')]
|
||||||
|
|
||||||
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
|
|
||||||
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
|
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filtroAnno = '';
|
public string $filtroAnno = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filtroZona = '';
|
public string $filtroZona = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filtroTipologia = '';
|
public string $filtroTipologia = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filtroStato = ''; // aperte, chiuse
|
public string $filtroStato = ''; // aperte, chiuse
|
||||||
|
#[Url(except: 'territorio_numero')]
|
||||||
public string $sortField = 'territorio_numero';
|
public string $sortField = 'territorio_numero';
|
||||||
|
#[Url(except: 'asc')]
|
||||||
public string $sortDirection = 'asc';
|
public string $sortDirection = 'asc';
|
||||||
|
|
||||||
// ─── Modal create/edit ──────────────────────────────────────
|
// ─── Modal create/edit ──────────────────────────────────────
|
||||||
@@ -37,7 +42,6 @@ class Registro extends Component
|
|||||||
|
|
||||||
public string $form_territorio_id = '';
|
public string $form_territorio_id = '';
|
||||||
public string $form_proclamatore_id = '';
|
public string $form_proclamatore_id = '';
|
||||||
public string $form_anno_id = '';
|
|
||||||
public string $form_assigned_at = '';
|
public string $form_assigned_at = '';
|
||||||
public string $form_returned_at = '';
|
public string $form_returned_at = '';
|
||||||
public bool $form_counted_in_campaign = false;
|
public bool $form_counted_in_campaign = false;
|
||||||
@@ -101,7 +105,6 @@ class Registro extends Component
|
|||||||
$this->editingId = null;
|
$this->editingId = null;
|
||||||
$this->form_territorio_id = '';
|
$this->form_territorio_id = '';
|
||||||
$this->form_proclamatore_id = '';
|
$this->form_proclamatore_id = '';
|
||||||
$this->form_anno_id = '';
|
|
||||||
$this->form_assigned_at = now()->format('Y-m-d');
|
$this->form_assigned_at = now()->format('Y-m-d');
|
||||||
$this->form_returned_at = '';
|
$this->form_returned_at = '';
|
||||||
$this->form_counted_in_campaign = false;
|
$this->form_counted_in_campaign = false;
|
||||||
@@ -118,7 +121,6 @@ class Registro extends Component
|
|||||||
$this->editingId = $id;
|
$this->editingId = $id;
|
||||||
$this->form_territorio_id = (string) $a->territorio_id;
|
$this->form_territorio_id = (string) $a->territorio_id;
|
||||||
$this->form_proclamatore_id = (string) $a->proclamatore_id;
|
$this->form_proclamatore_id = (string) $a->proclamatore_id;
|
||||||
$this->form_anno_id = (string) $a->anno_teocratico_id;
|
|
||||||
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
|
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
|
||||||
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
|
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
|
||||||
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
|
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
|
||||||
@@ -134,28 +136,69 @@ class Registro extends Component
|
|||||||
$this->validate([
|
$this->validate([
|
||||||
'form_territorio_id' => 'required|exists:territori,id',
|
'form_territorio_id' => 'required|exists:territori,id',
|
||||||
'form_proclamatore_id' => 'required|exists:proclamatori,id',
|
'form_proclamatore_id' => 'required|exists:proclamatori,id',
|
||||||
'form_anno_id' => 'required|exists:anni_teocratici,id',
|
'form_assigned_at' => 'required|date|before_or_equal:today',
|
||||||
'form_assigned_at' => 'required|date',
|
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
|
||||||
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
|
|
||||||
'form_campaign_id' => 'nullable|exists:campagne,id',
|
'form_campaign_id' => 'nullable|exists:campagne,id',
|
||||||
|
], [
|
||||||
|
'form_assigned_at.before_or_equal' => 'La data di assegnazione non può essere futura.',
|
||||||
|
'form_returned_at.before_or_equal' => 'La data di rientro non può essere futura.',
|
||||||
|
'form_returned_at.after_or_equal' => 'Il rientro non può precedere l\'assegnazione.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$uscita = Carbon::parse($this->form_assigned_at);
|
||||||
|
$rientro = $this->form_returned_at ? Carbon::parse($this->form_returned_at) : null;
|
||||||
|
|
||||||
|
// L'anno di servizio non si sceglie: è quello della data di uscita.
|
||||||
|
// Scelto a mano poteva restare incoerente con la data, e l'assegnazione
|
||||||
|
// finiva contata nell'anno sbagliato da Home, filtri e modulo S-13.
|
||||||
$data = [
|
$data = [
|
||||||
'territorio_id' => $this->form_territorio_id,
|
'territorio_id' => $this->form_territorio_id,
|
||||||
'proclamatore_id' => $this->form_proclamatore_id,
|
'proclamatore_id' => $this->form_proclamatore_id,
|
||||||
'anno_teocratico_id' => $this->form_anno_id,
|
'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
|
||||||
'assigned_at' => $this->form_assigned_at,
|
'assigned_at' => $uscita,
|
||||||
'returned_at' => $this->form_returned_at ?: null,
|
'returned_at' => $rientro,
|
||||||
'counted_in_campaign' => $this->form_counted_in_campaign,
|
// "Contata nella campagna" senza campagna non ha significato.
|
||||||
|
'counted_in_campaign' => $this->form_counted_in_campaign && $this->form_campaign_id !== '',
|
||||||
'campaign_id' => $this->form_campaign_id ?: null,
|
'campaign_id' => $this->form_campaign_id ?: null,
|
||||||
'note' => $this->form_note ?: null,
|
'note' => $this->form_note ?: null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$assegnazione = null;
|
||||||
|
$prima = [false, null];
|
||||||
|
|
||||||
|
$conflitto = DB::transaction(function () use ($data, $uscita, $rientro, &$assegnazione, &$prima) {
|
||||||
|
Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first();
|
||||||
|
|
||||||
|
$conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
|
||||||
|
if ($conflitto) {
|
||||||
|
return $conflitto;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->editingId) {
|
if ($this->editingId) {
|
||||||
Assegnazione::findOrFail($this->editingId)->update($data);
|
$assegnazione = Assegnazione::findOrFail($this->editingId);
|
||||||
|
$prima = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
|
||||||
|
$assegnazione->update($data);
|
||||||
} else {
|
} else {
|
||||||
$data['created_by'] = auth()->id();
|
$assegnazione = Assegnazione::create($data + ['created_by' => auth()->id()]);
|
||||||
Assegnazione::create($data);
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($conflitto) {
|
||||||
|
$this->addError('form_assigned_at', "Il periodo si sovrappone all'assegnazione {$conflitto->descrizionePeriodo()}.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the campaign choice when the admin sets or changes it here.
|
||||||
|
$dopo = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
|
||||||
|
if ($dopo !== $prima && ($dopo[0] || $prima[0])) {
|
||||||
|
$assegnazione->logCampagnaPercorso(
|
||||||
|
$assegnazione->campagna ?? ($prima[1] ? Campagna::find($prima[1]) : null),
|
||||||
|
$dopo[0],
|
||||||
|
'Registro'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->showModal = false;
|
$this->showModal = false;
|
||||||
@@ -199,16 +242,15 @@ class Registro extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del
|
* Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
|
||||||
* territorio") compilato con le assegnazioni dell'anno di servizio scelto
|
* modale; le regole di compilazione stanno in ModuloS13.
|
||||||
* nella modale.
|
|
||||||
*
|
*
|
||||||
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo
|
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo
|
||||||
* espone in un unico file l'anagrafica completa di chi ha avuto quale
|
* espone in un unico file l'anagrafica completa di chi ha avuto quale
|
||||||
* territorio, dati personali che il ruolo assegnatario non deve poter
|
* territorio, dati personali che il ruolo assegnatario non deve poter
|
||||||
* esportare in blocco.
|
* esportare in blocco.
|
||||||
*/
|
*/
|
||||||
public function downloadS13()
|
public function downloadS13(ModuloS13 $modulo)
|
||||||
{
|
{
|
||||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||||
|
|
||||||
@@ -224,134 +266,15 @@ class Registro extends Component
|
|||||||
|
|
||||||
$this->showS13Modal = false;
|
$this->showS13Modal = false;
|
||||||
|
|
||||||
$pdf = Pdf::loadView('pdf.registro-s13', [
|
$pdf = $modulo->genera($anno);
|
||||||
'annoLabel' => $anno->label,
|
|
||||||
'pagine' => $this->impaginaS13($this->righeS13($anno)),
|
|
||||||
])->setPaper('a4', 'portrait');
|
|
||||||
|
|
||||||
return response()->streamDownload(
|
return response()->streamDownload(
|
||||||
fn () => print($pdf->output()),
|
fn () => print($pdf),
|
||||||
'S-13-registro-assegnazioni-' . $anno->label . '.pdf'
|
'S-13-registro-assegnazioni-' . $anno->label . '.pdf',
|
||||||
|
['Content-Type' => 'application/pdf']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Impagina le righe in fogli da 21 blocchi, completando l'ultimo con
|
|
||||||
* blocchi vuoti: il foglio stampato resta identico al modulo cartaceo e le
|
|
||||||
* righe libere restano disponibili per la compilazione a mano.
|
|
||||||
*
|
|
||||||
* Senza righe da stampare si restituisce comunque un foglio vuoto, così il
|
|
||||||
* modulo scaricato è un S-13 in bianco anziché un PDF senza pagine.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Support\Collection<int, array> $righe
|
|
||||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array>>
|
|
||||||
*/
|
|
||||||
private function impaginaS13(\Illuminate\Support\Collection $righe): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
$vuota = [
|
|
||||||
'numero' => '',
|
|
||||||
'riporto' => '',
|
|
||||||
'assegnazioni' => $this->completaCelleS13([]),
|
|
||||||
];
|
|
||||||
|
|
||||||
$pagine = $righe->isEmpty()
|
|
||||||
? collect([collect()])
|
|
||||||
: $righe->chunk(self::S13_RIGHE_PER_PAGINA)->map->values()->values();
|
|
||||||
|
|
||||||
return $pagine->map(fn ($pagina) => collect(
|
|
||||||
array_pad($pagina->all(), self::S13_RIGHE_PER_PAGINA, $vuota)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Righe del modulo: un blocco per ogni territorio attivo con almeno
|
|
||||||
* un'assegnazione (chiusa o in corso) nell'anno indicato, in ordine di
|
|
||||||
* numero crescente.
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Support\Collection<int, array>
|
|
||||||
*/
|
|
||||||
private function righeS13(AnnoTeocratico $anno): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
// territorio() è definita withTrashed(), quindi la condizione sui
|
|
||||||
// cestinati va scritta a mano: senza, i territori eliminati
|
|
||||||
// rientrerebbero nel modulo.
|
|
||||||
$assegnazioni = Assegnazione::with(['proclamatore', 'territorio'])
|
|
||||||
->where('anno_teocratico_id', $anno->id)
|
|
||||||
->whereHas('territorio', fn ($q) => $q
|
|
||||||
->where('attivo', true)
|
|
||||||
->whereNull('territori.deleted_at'))
|
|
||||||
->orderBy('assigned_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get()
|
|
||||||
->groupBy('territorio_id');
|
|
||||||
|
|
||||||
return $assegnazioni
|
|
||||||
->map(function ($gruppo) {
|
|
||||||
$gruppo = $gruppo->values();
|
|
||||||
|
|
||||||
// Il ciclo di registrazione è lungo quanto i gruppi "Assegnato
|
|
||||||
// a": riempito il quarto si ricomincia da capo, come sul modulo
|
|
||||||
// cartaceo si aprirebbe una riga nuova per quel territorio.
|
|
||||||
// Cinque assegnazioni non mostrano quindi le ultime quattro, ma
|
|
||||||
// solo la quinta, in prima colonna.
|
|
||||||
$inizioCiclo = intdiv($gruppo->count() - 1, self::S13_ASSEGNAZIONI_PER_RIGA)
|
|
||||||
* self::S13_ASSEGNAZIONI_PER_RIGA;
|
|
||||||
|
|
||||||
$celle = $gruppo
|
|
||||||
->slice($inizioCiclo, self::S13_ASSEGNAZIONI_PER_RIGA)
|
|
||||||
->map(fn (Assegnazione $a) => [
|
|
||||||
'nome' => $a->proclamatore?->nome_completo ?? '',
|
|
||||||
'assegnato_il' => $a->assigned_at?->format('d/m/Y') ?? '',
|
|
||||||
'restituito_il' => $a->returned_at?->format('d/m/Y') ?? '',
|
|
||||||
])->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'numero' => $gruppo->first()->territorio?->numero ?? '',
|
|
||||||
'riporto' => $this->riportoS13($gruppo, $inizioCiclo),
|
|
||||||
'assegnazioni' => $this->completaCelleS13($celle),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->sort(fn (array $a, array $b) => strnatcasecmp((string) $a['numero'], (string) $b['numero']))
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contenuto della colonna "Restituito l'ultima volta il": la data di
|
|
||||||
* rientro dell'ultima assegnazione *prima* che il ciclo ricominciasse,
|
|
||||||
* cioè quella che occupava il quarto gruppo del giro precedente.
|
|
||||||
*
|
|
||||||
* È il riporto che il modulo cartaceo chiede quando si apre una riga nuova
|
|
||||||
* per un territorio già registrato: la riga mostra da dove si riparte,
|
|
||||||
* senza il nome di chi lo aveva (la colonna ha solo la data).
|
|
||||||
*
|
|
||||||
* Finché il primo ciclo non è esaurito — quattro assegnazioni o meno
|
|
||||||
* nell'anno — non c'è niente da riportare e la colonna resta vuota.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Support\Collection<int, Assegnazione> $gruppo
|
|
||||||
*/
|
|
||||||
private function riportoS13(\Illuminate\Support\Collection $gruppo, int $inizioCiclo): string
|
|
||||||
{
|
|
||||||
if ($inizioCiclo === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $gruppo->get($inizioCiclo - 1)?->returned_at?->format('d/m/Y') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Porta le celle a quattro riempiendo con voci vuote: le colonne
|
|
||||||
* inutilizzate restano stampate e a disposizione della compilazione a mano.
|
|
||||||
*
|
|
||||||
* @param array<int, array> $celle
|
|
||||||
* @return array<int, array>
|
|
||||||
*/
|
|
||||||
private function completaCelleS13(array $celle): array
|
|
||||||
{
|
|
||||||
$vuota = ['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''];
|
|
||||||
|
|
||||||
return array_pad($celle, self::S13_ASSEGNAZIONI_PER_RIGA, $vuota);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
if ($this->filtroAnno === '') {
|
if ($this->filtroAnno === '') {
|
||||||
@@ -425,8 +348,14 @@ class Registro extends Component
|
|||||||
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
||||||
'zone' => Zona::attive()->orderBy('nome')->get(),
|
'zone' => Zona::attive()->orderBy('nome')->get(),
|
||||||
'tipologie' => Tipologia::orderBy('nome')->get(),
|
'tipologie' => Tipologia::orderBy('nome')->get(),
|
||||||
'territori' => Territorio::attivi()->orderBy('numero')->get(),
|
// Campi di scelta con ricerca della finestra di modifica. Ci sono
|
||||||
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
|
// anche i disattivati, non sceglibili: modificando una vecchia
|
||||||
|
// assegnazione il campo mostra comunque il nome giusto.
|
||||||
|
'opzioniTerritori' => Territorio::with(['zona', 'tipologia'])->get()
|
||||||
|
->sortBy('numero', SORT_NATURAL)
|
||||||
|
->map(fn (Territorio $t) => $t->opzioneScelta($t->attivo ? null : 'territorio disattivato'))
|
||||||
|
->values()->all(),
|
||||||
|
'opzioniProclamatori' => Proclamatore::opzioniScelta(Proclamatore::all()),
|
||||||
'campagne' => Campagna::orderByDesc('created_at')->get(),
|
'campagne' => Campagna::orderByDesc('created_at')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ class BackupManager extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
||||||
|
if ($operationId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->operationId = $operationId;
|
||||||
|
|
||||||
CreateBackupArchive::dispatch($this->operationId, false);
|
CreateBackupArchive::dispatch($this->operationId, false);
|
||||||
|
|
||||||
|
|||||||
132
app/Livewire/Settings/PostaElettronica.php
Normal file
132
app/Livewire/Settings/PostaElettronica.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\ConfigurazionePosta;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server di posta (solo amministratore).
|
||||||
|
*
|
||||||
|
* Serve alle email di conferma degli utenti. Raggiungibile anche da un
|
||||||
|
* amministratore che non ha ancora confermato la sua email (vedi
|
||||||
|
* routes/web.php): se la posta non funziona, è l'unica pagina da cui
|
||||||
|
* sistemarla senza passare dal server.
|
||||||
|
*/
|
||||||
|
class PostaElettronica extends Component
|
||||||
|
{
|
||||||
|
use Avvisa;
|
||||||
|
|
||||||
|
public string $host = '';
|
||||||
|
|
||||||
|
public string $porta = '';
|
||||||
|
|
||||||
|
public string $cifratura = ConfigurazionePosta::CIFRATURA_AUTOMATICA;
|
||||||
|
|
||||||
|
public string $utente = '';
|
||||||
|
|
||||||
|
/** Vuota = si tiene quella salvata. Non torna mai al browser. */
|
||||||
|
public string $password = '';
|
||||||
|
|
||||||
|
public string $mittente = '';
|
||||||
|
|
||||||
|
public string $nomeMittente = '';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$impostazioni = Setting::instance();
|
||||||
|
|
||||||
|
$this->host = (string) $impostazioni->mail_host;
|
||||||
|
$this->porta = (string) ($impostazioni->mail_port ?? '');
|
||||||
|
$this->cifratura = $impostazioni->mail_cifratura ?: ConfigurazionePosta::CIFRATURA_AUTOMATICA;
|
||||||
|
$this->utente = (string) $impostazioni->mail_username;
|
||||||
|
$this->mittente = (string) $impostazioni->mail_from_address;
|
||||||
|
$this->nomeMittente = (string) $impostazioni->mail_from_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function salva(): void
|
||||||
|
{
|
||||||
|
$this->host = trim($this->host);
|
||||||
|
$this->mittente = trim($this->mittente);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'host' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9.\-]+$/'],
|
||||||
|
'porta' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||||
|
'cifratura' => ['required', Rule::in(array_keys(ConfigurazionePosta::CIFRATURE))],
|
||||||
|
'utente' => ['nullable', 'string', 'max:255'],
|
||||||
|
'password' => ['nullable', 'string', 'max:500'],
|
||||||
|
'mittente' => ['required', 'email', 'max:255'],
|
||||||
|
'nomeMittente' => ['nullable', 'string', 'max:255'],
|
||||||
|
], [
|
||||||
|
'host.required' => 'Indica il server SMTP (es. smtp.gmail.com).',
|
||||||
|
'host.regex' => 'Solo il nome del server, senza "smtp://" né porta.',
|
||||||
|
'mittente.required' => 'Indica l\'indirizzo da cui partono le email.',
|
||||||
|
'mittente.email' => 'L\'indirizzo del mittente non è valido.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$valori = [
|
||||||
|
'mail_host' => $this->host,
|
||||||
|
'mail_port' => $this->porta !== '' ? (int) $this->porta : null,
|
||||||
|
'mail_cifratura' => $this->cifratura,
|
||||||
|
'mail_username' => trim($this->utente) ?: null,
|
||||||
|
'mail_from_address' => $this->mittente,
|
||||||
|
'mail_from_name' => trim($this->nomeMittente) ?: null,
|
||||||
|
];
|
||||||
|
if ($this->password !== '') {
|
||||||
|
$valori['mail_password'] = $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
Setting::instance()->update($valori);
|
||||||
|
$this->password = '';
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->log('posta_configurata');
|
||||||
|
$this->avvisa('Server di posta salvato. Prova l\'invio con "Invia email di prova".');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inviaProva(ConfigurazionePosta $posta): void
|
||||||
|
{
|
||||||
|
if (! $posta->configurataNellApp()) {
|
||||||
|
$this->avvisaErrore('Salva prima il server di posta.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$posta->inviaProva(auth()->user()->email);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
$this->avvisaErrore($e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->avvisa('Email di prova inviata a ' . auth()->user()->email . '. Controlla anche lo spam.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Torna al server del .env. */
|
||||||
|
public function rimuovi(): void
|
||||||
|
{
|
||||||
|
Setting::instance()->update(array_fill_keys(Setting::CAMPI_POSTA, null));
|
||||||
|
$this->mount();
|
||||||
|
$this->password = '';
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->log('posta_rimossa');
|
||||||
|
$this->avvisa('Configurazione rimossa: valgono di nuovo le impostazioni del server (.env).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(ConfigurazionePosta $posta)
|
||||||
|
{
|
||||||
|
$impostazioni = Setting::instance();
|
||||||
|
|
||||||
|
return view('livewire.settings.posta-elettronica', [
|
||||||
|
'configurataNellApp' => $posta->configurataNellApp(),
|
||||||
|
'funzionante' => $posta->funzionante(),
|
||||||
|
'passwordSalvata' => $impostazioni->getRawOriginal('mail_password') !== null,
|
||||||
|
'cifrature' => ConfigurazionePosta::CIFRATURE,
|
||||||
|
'nonConfermato' => ! auth()->user()->hasVerifiedEmail(),
|
||||||
|
'title' => 'Server di posta',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
|
||||||
class SettingsEdit extends Component
|
class SettingsEdit extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
public string $congregazione_nome = '';
|
public string $congregazione_nome = '';
|
||||||
public int $giorni_giacenza_da_assegnare = 120;
|
public int $giorni_giacenza_da_assegnare = 120;
|
||||||
public int $giorni_giacenza_prioritari = 180;
|
public int $giorni_giacenza_prioritari = 180;
|
||||||
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
|
|||||||
'audit_retention_days' => $this->audit_retention_days,
|
'audit_retention_days' => $this->audit_retention_days,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
session()->flash('success', 'Impostazioni aggiornate.');
|
$this->avvisa('Impostazioni aggiornate.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Tipologia;
|
use App\Models\Tipologia;
|
||||||
|
|
||||||
class TipologieIndex extends Component
|
class TipologieIndex extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
public string $nuovaTipologia = '';
|
public string $nuovaTipologia = '';
|
||||||
public ?int $editingId = null;
|
public ?int $editingId = null;
|
||||||
public string $editingNome = '';
|
public string $editingNome = '';
|
||||||
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
|
|||||||
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
|
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
|
||||||
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
|
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
|
||||||
$this->nuovaTipologia = '';
|
$this->nuovaTipologia = '';
|
||||||
session()->flash('success', 'Tipologia aggiunta.');
|
$this->avvisa('Tipologia aggiunta.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function startEdit(int $id)
|
public function startEdit(int $id)
|
||||||
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
|
|||||||
$tipo->update(['nome' => $this->editingNome]);
|
$tipo->update(['nome' => $this->editingNome]);
|
||||||
$this->editingId = null;
|
$this->editingId = null;
|
||||||
$this->editingNome = '';
|
$this->editingNome = '';
|
||||||
session()->flash('success', 'Tipologia aggiornata.');
|
$this->avvisa('Tipologia aggiornata.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancelEdit()
|
public function cancelEdit()
|
||||||
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
|
|||||||
{
|
{
|
||||||
$tipo = Tipologia::findOrFail($id);
|
$tipo = Tipologia::findOrFail($id);
|
||||||
if ($tipo->territori()->exists()) {
|
if ($tipo->territori()->exists()) {
|
||||||
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
|
$this->avvisaErrore("Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$tipo->delete();
|
$tipo->delete();
|
||||||
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
|
$this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,21 +2,27 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Activitylog\Models\Activity;
|
use Spatie\Activitylog\Models\Activity;
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class UsersIndex extends Component
|
class UsersIndex extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
public string $email = '';
|
public string $email = '';
|
||||||
public string $password = '';
|
public string $password = '';
|
||||||
public string $password_confirmation = '';
|
public string $password_confirmation = '';
|
||||||
public string $selectedRole = '';
|
public string $selectedRole = '';
|
||||||
|
// Locked: è la lista che le regole di validazione usano per accettare un
|
||||||
|
// ruolo; modificabile dal client, ammetterebbe qualsiasi nome.
|
||||||
|
#[Locked]
|
||||||
public array $availableRoles = [];
|
public array $availableRoles = [];
|
||||||
|
|
||||||
public ?int $editingUserId = null;
|
public ?int $editingUserId = null;
|
||||||
@@ -79,7 +85,30 @@ class UsersIndex extends Component
|
|||||||
if (! empty($this->availableRoles)) {
|
if (! empty($this->availableRoles)) {
|
||||||
$this->selectedRole = $this->availableRoles[0];
|
$this->selectedRole = $this->availableRoles[0];
|
||||||
}
|
}
|
||||||
session()->flash('success', 'Utente creato con successo.');
|
|
||||||
|
// L'account funziona solo dopo la conferma dell'indirizzo.
|
||||||
|
if ($user->inviaConfermaEmail()) {
|
||||||
|
$this->avvisa("Utente creato. Gli è stata inviata un'email a {$user->email}: l'account funziona dopo che conferma l'indirizzo.");
|
||||||
|
} else {
|
||||||
|
$this->avvisaErrore("Utente creato, ma l'email di conferma non è partita: controlla il server di posta e usa \"Reinvia conferma\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reinviaConferma(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::query()->findOrFail($userId);
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
$this->avvisa("{$user->email} è già confermato.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->inviaConfermaEmail()) {
|
||||||
|
$this->avvisa("Email di conferma inviata a {$user->email}.");
|
||||||
|
} else {
|
||||||
|
$this->avvisaErrore('L\'email non è partita: controlla il server di posta.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function startEdit(int $userId): void
|
public function startEdit(int $userId): void
|
||||||
@@ -115,6 +144,23 @@ class UsersIndex extends Component
|
|||||||
$validated = $this->validate($this->editRules());
|
$validated = $this->validate($this->editRules());
|
||||||
$user = User::query()->findOrFail($this->editingUserId);
|
$user = User::query()->findOrFail($this->editingUserId);
|
||||||
|
|
||||||
|
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
||||||
|
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
||||||
|
// utenti, impostazioni e backup.
|
||||||
|
if ($user->hasRole('amministratore') && $validated['editSelectedRole'] !== 'amministratore') {
|
||||||
|
if ($user->id === auth()->id()) {
|
||||||
|
$this->addError('editSelectedRole', 'Non puoi togliere a te stesso il ruolo di amministratore: chiedilo a un altro amministratore.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (User::role('amministratore')->count() <= 1) {
|
||||||
|
$this->addError('editSelectedRole', "È l'ultimo amministratore: assegna prima il ruolo a un altro utente.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$user->name = $validated['editName'];
|
$user->name = $validated['editName'];
|
||||||
$user->email = $validated['editEmail'];
|
$user->email = $validated['editEmail'];
|
||||||
|
|
||||||
@@ -122,11 +168,25 @@ class UsersIndex extends Component
|
|||||||
$user->password = $validated['editPassword'];
|
$user->password = $validated['editPassword'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$emailCambiata = $user->isDirty('email');
|
||||||
$user->save();
|
$user->save();
|
||||||
$user->syncRoles([$validated['editSelectedRole']]);
|
$user->syncRoles([$validated['editSelectedRole']]);
|
||||||
|
|
||||||
$this->cancelEdit();
|
$this->cancelEdit();
|
||||||
session()->flash('success', 'Utente aggiornato con successo.');
|
|
||||||
|
// Indirizzo nuovo: torna da confermare (User::booted) e gli si manda
|
||||||
|
// il link, altrimenti il suo account smetterebbe di funzionare senza
|
||||||
|
// che sappia perché.
|
||||||
|
if ($emailCambiata) {
|
||||||
|
$inviata = $user->inviaConfermaEmail();
|
||||||
|
$inviata
|
||||||
|
? $this->avvisa("Utente aggiornato. Il nuovo indirizzo va confermato: email inviata a {$user->email}.")
|
||||||
|
: $this->avvisaErrore('Utente aggiornato, ma l\'email di conferma del nuovo indirizzo non è partita: controlla il server di posta.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->avvisa('Utente aggiornato con successo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteUser(int $userId): void
|
public function deleteUser(int $userId): void
|
||||||
@@ -135,12 +195,12 @@ class UsersIndex extends Component
|
|||||||
$user = User::query()->with('roles')->findOrFail($userId);
|
$user = User::query()->with('roles')->findOrFail($userId);
|
||||||
|
|
||||||
if (! $currentUser || $currentUser->id === $user->id) {
|
if (! $currentUser || $currentUser->id === $user->id) {
|
||||||
session()->flash('error', 'Non puoi cancellare il tuo utente.');
|
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
|
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
|
||||||
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
|
$this->avvisaErrore('Non puoi cancellare l\'ultimo amministratore.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +240,7 @@ class UsersIndex extends Component
|
|||||||
$this->cancelEdit();
|
$this->cancelEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
|
$this->avvisa('Utente cancellato. I log sono stati preservati.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Jobs\ImportTerritoryPdfFolder;
|
use App\Jobs\ImportTerritoryPdfFolder;
|
||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
@@ -9,20 +10,27 @@ use App\Models\Campagna;
|
|||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
use App\Models\TerritorioPdfVersione;
|
||||||
use App\Models\Tipologia;
|
use App\Models\Tipologia;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
use App\Services\TerritorioPdfImportDispatcher;
|
use App\Services\TerritorioPdfImportDispatcher;
|
||||||
use App\Services\TerritorioPdfImportState;
|
use App\Services\TerritorioPdfImportState;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
class XmlExchange extends Component
|
class XmlExchange extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public array $importStats = [];
|
public array $importStats = [];
|
||||||
@@ -52,23 +60,113 @@ class XmlExchange extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function importTerritoryPdfFolder(): void
|
/**
|
||||||
|
* Quanti file viaggiano in una singola richiesta di caricamento della
|
||||||
|
* cartella.
|
||||||
|
*
|
||||||
|
* Il browser non manda la cartella tutta insieme: la spezza in lotti e li
|
||||||
|
* aggiunge uno dopo l'altro a $pdfFolder. Un'unica richiesta con tutti i file
|
||||||
|
* si scontrava con `max_file_uploads` di PHP (20 per default), che non
|
||||||
|
* rifiuta: scarta in silenzio i file in piu', e l'import partiva con i primi
|
||||||
|
* venti di una cartella da trecento. Alzare il limite non basterebbe: la
|
||||||
|
* richiesta unica resterebbe soggetta al tetto di dimensione e al timeout di
|
||||||
|
* 100 secondi di Cloudflare, e un errore a meta' farebbe ricominciare da capo.
|
||||||
|
* A lotti, ogni richiesta e' piccola e un errore ne ferma una sola.
|
||||||
|
*
|
||||||
|
* Si legge il valore vero di PHP, perche' un'installazione con un limite piu'
|
||||||
|
* basso non perda file in silenzio come prima.
|
||||||
|
*/
|
||||||
|
public function getFilePerLottoProperty(): int
|
||||||
|
{
|
||||||
|
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controllo rapido prima di caricare la cartella: senza, si caricherebbero
|
||||||
|
* centinaia di file per poi sentirsi dire che c'e' un ripristino in corso.
|
||||||
|
* Non prende il posto — lo prende importTerritoryPdfFolder(), a caricamento
|
||||||
|
* finito — perche' una scheda chiusa a meta' caricamento lo terrebbe
|
||||||
|
* occupato per un'ora.
|
||||||
|
*/
|
||||||
|
public function cartellaPuoPartire(): bool
|
||||||
|
{
|
||||||
|
$inCorso = app(BackupOperationState::class)->corrente();
|
||||||
|
|
||||||
|
if ($inCorso === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->avvisaErrore(app(BackupOperationState::class)->messaggioOccupato($inCorso));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il browser azzera i lotti di un caricamento precedente prima di iniziarne uno nuovo. */
|
||||||
|
public function svuotaCartella(): void
|
||||||
|
{
|
||||||
|
foreach ($this->pdfFolder as $file) {
|
||||||
|
if ($file instanceof TemporaryUploadedFile) {
|
||||||
|
$file->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdfFolder = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mette in coda l'import dei lotti gia' caricati. Restituisce true se
|
||||||
|
* l'import e' partito: il browser tiene la cartella scelta finche' non lo e',
|
||||||
|
* cosi' dopo un rifiuto basta ripremere.
|
||||||
|
*/
|
||||||
|
public function importTerritoryPdfFolder(): bool
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$importId = (string) Str::uuid();
|
$importId = (string) Str::uuid();
|
||||||
$storedFiles = [];
|
$dispatcher = app(TerritorioPdfImportDispatcher::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dispatcher->occupaPosto($importId);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
// Il posto l'ha preso qualcun altro fra il controllo iniziale e la
|
||||||
|
// fine del caricamento: si avvisa, e non si tocca niente.
|
||||||
|
$this->avvisaErrore($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedFiles = [];
|
||||||
|
$ignorati = [];
|
||||||
|
$scartati = [];
|
||||||
|
|
||||||
|
try {
|
||||||
foreach ($this->pdfFolder as $index => $file) {
|
foreach ($this->pdfFolder as $index => $file) {
|
||||||
$originalName = $file->getClientOriginalName();
|
$originalName = $file->getClientOriginalName();
|
||||||
|
|
||||||
|
// Thumbs.db, .DS_Store, desktop.ini e simili: prima uno solo di
|
||||||
|
// questi faceva fallire la validazione e con lei l'intero import.
|
||||||
|
if (TerritorioPdfImportDispatcher::daIgnorare($originalName)) {
|
||||||
|
$ignorati[] = $originalName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un PDF rotto o troppo grande si scarta da solo, con il suo
|
||||||
|
// motivo nell'esito: non e' una ragione per buttare gli altri.
|
||||||
|
$verifica = Validator::make(['file' => $file], ['file' => ['file', 'mimes:pdf', 'max:10240']]);
|
||||||
|
if ($verifica->fails()) {
|
||||||
|
$scartati[] = [
|
||||||
|
'file' => $originalName,
|
||||||
|
'message' => 'File non valido: ' . $verifica->errors()->first('file'),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
|
|
||||||
$storedPath = $file->storeAs(
|
$storedPath = $file->storeAs(
|
||||||
'bulk-territori-imports/' . $importId,
|
'bulk-territori-imports/' . $importId,
|
||||||
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
|
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
|
||||||
'local'
|
'local'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,14 +175,34 @@ class XmlExchange extends Component
|
|||||||
'stored_path' => $storedPath,
|
'stored_path' => $storedPath,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
|
||||||
|
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||||
|
|
||||||
$this->pdfFolder = [];
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->svuotaCartella();
|
||||||
|
|
||||||
|
if ($storedFiles === []) {
|
||||||
|
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
|
||||||
|
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
|
||||||
|
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
|
||||||
|
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
|
||||||
|
. '.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$this->dispatchPdfImport(
|
$this->dispatchPdfImport(
|
||||||
$importId,
|
$importId,
|
||||||
$storedFiles,
|
$storedFiles,
|
||||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
|
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
|
||||||
|
$ignorati,
|
||||||
|
$scartati
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshPdfImportStatus(): void
|
public function refreshPdfImportStatus(): void
|
||||||
@@ -114,8 +232,9 @@ class XmlExchange extends Component
|
|||||||
* contenuti non possono essere ri-renderizzati dal server senza cancellare
|
* contenuti non possono essere ri-renderizzati dal server senza cancellare
|
||||||
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
|
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
|
||||||
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
|
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
|
||||||
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
|
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
|
||||||
* non partecipa al posto unico: puo' girare mentre un backup lavora.
|
* Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
|
||||||
|
* non gira mai insieme a un backup, un ripristino o un azzeramento.
|
||||||
*/
|
*/
|
||||||
protected function spedisciImportAllaConsole(): void
|
protected function spedisciImportAllaConsole(): void
|
||||||
{
|
{
|
||||||
@@ -156,10 +275,10 @@ class XmlExchange extends Component
|
|||||||
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
|
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
|
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
|
||||||
{
|
{
|
||||||
$state = app(TerritorioPdfImportDispatcher::class)
|
$state = app(TerritorioPdfImportDispatcher::class)
|
||||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
|
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
|
||||||
|
|
||||||
$this->currentPdfImportId = $importId;
|
$this->currentPdfImportId = $importId;
|
||||||
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
||||||
@@ -172,13 +291,13 @@ class XmlExchange extends Component
|
|||||||
$this->logPdfSpediti = 0;
|
$this->logPdfSpediti = 0;
|
||||||
$this->refreshPdfImportStatus();
|
$this->refreshPdfImportStatus();
|
||||||
|
|
||||||
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
|
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function downloadImportLogPdf()
|
public function downloadImportLogPdf()
|
||||||
{
|
{
|
||||||
if (empty($this->importStats)) {
|
if (empty($this->importStats)) {
|
||||||
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
|
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,16 +355,99 @@ class XmlExchange extends Component
|
|||||||
return $this->datasetToXml($dataset, $source);
|
return $this->datasetToXml($dataset, $source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
|
||||||
|
*
|
||||||
|
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
|
||||||
|
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
|
||||||
|
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
|
||||||
|
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
|
||||||
|
* file dei territori cancellati sul disco: nessuna schermata li mostra,
|
||||||
|
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
|
||||||
|
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
|
||||||
|
* PDF per territorio.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function fileDeiTerritori(): array
|
||||||
|
{
|
||||||
|
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
|
||||||
|
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
|
||||||
|
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina i file che prima dell'import appartenevano a un territorio e che
|
||||||
|
* adesso nessuna riga richiama piu'.
|
||||||
|
*
|
||||||
|
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
|
||||||
|
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
|
||||||
|
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
|
||||||
|
* XML che riporta gli stessi percorsi (un export di questa stessa
|
||||||
|
* installazione) non perde niente.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $fileDiPrima
|
||||||
|
*/
|
||||||
|
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
|
||||||
|
{
|
||||||
|
$usati = array_flip(array_merge(
|
||||||
|
self::fileDeiTerritori(),
|
||||||
|
array_filter([Setting::instance()->logo_path]),
|
||||||
|
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$disco = Storage::disk('public');
|
||||||
|
$eliminati = 0;
|
||||||
|
|
||||||
|
foreach ($fileDiPrima as $percorso) {
|
||||||
|
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($disco->delete($percorso)) {
|
||||||
|
$eliminati++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $eliminati;
|
||||||
|
}
|
||||||
|
|
||||||
public function importXmlFromContent(string $content): array
|
public function importXmlFromContent(string $content): array
|
||||||
{
|
{
|
||||||
$this->importStats = [];
|
$this->importStats = [];
|
||||||
$this->importIssues = [];
|
$this->importIssues = [];
|
||||||
|
|
||||||
$xml = @simplexml_load_string($content);
|
$xml = $this->leggiXml($content);
|
||||||
if (! $xml) {
|
if (is_string($xml)) {
|
||||||
return ['error' => 'Impossibile leggere il file XML.'];
|
return ['error' => $xml];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
|
||||||
|
// (un sitemap, l'export di un altro programma) lascerebbe
|
||||||
|
// l'installazione vuota con un "completato con successo". Si accetta
|
||||||
|
// solo un file prodotto da questa applicazione, con le sue sezioni.
|
||||||
|
if ($xml->getName() !== 'termanager2') {
|
||||||
|
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <termanager2>. Nessun dato è stato modificato.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mancanti = array_values(array_filter(
|
||||||
|
['territori', 'proclamatori', 'assegnazioni'],
|
||||||
|
fn (string $sezione) => ! isset($xml->{$sezione})
|
||||||
|
));
|
||||||
|
if ($mancanti !== []) {
|
||||||
|
return ['error' => 'Il file è incompleto: mancano le sezioni ' . implode(', ', $mancanti) . '. Nessun dato è stato modificato.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chi esegue l'import (o il ripristino, che imposta l'utente a mano)
|
||||||
|
// non deve uscirne estromesso: il file può contenere il suo account
|
||||||
|
// con una password vecchia o senza il ruolo di amministratore.
|
||||||
|
$attore = auth()->user();
|
||||||
|
$attoreEraAmministratore = $attore?->hasRole('amministratore') ?? false;
|
||||||
|
|
||||||
$actorId = auth()->id() ?? User::query()->value('id');
|
$actorId = auth()->id() ?? User::query()->value('id');
|
||||||
if (! $actorId) {
|
if (! $actorId) {
|
||||||
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
||||||
@@ -264,7 +466,8 @@ class XmlExchange extends Component
|
|||||||
'assegnazioni_saltate' => 0,
|
'assegnazioni_saltate' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
DB::transaction(function () use ($xml, $actorId, &$stats) {
|
try {
|
||||||
|
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||||
Assegnazione::query()->delete();
|
Assegnazione::query()->delete();
|
||||||
Campagna::query()->delete();
|
Campagna::query()->delete();
|
||||||
Territorio::query()->withTrashed()->forceDelete();
|
Territorio::query()->withTrashed()->forceDelete();
|
||||||
@@ -272,6 +475,13 @@ class XmlExchange extends Component
|
|||||||
Tipologia::query()->delete();
|
Tipologia::query()->delete();
|
||||||
Zona::query()->delete();
|
Zona::query()->delete();
|
||||||
AnnoTeocratico::query()->delete();
|
AnnoTeocratico::query()->delete();
|
||||||
|
|
||||||
|
// Il server di posta è configurazione di questa installazione, non
|
||||||
|
// un dato da trasferire: non si esporta (la password è cifrata con
|
||||||
|
// la sua APP_KEY) e un import non deve cancellarlo — senza posta
|
||||||
|
// nessuno riceverebbe più le email di conferma.
|
||||||
|
$posta = Setting::query()->first()?->only(Setting::CAMPI_POSTA) ?? [];
|
||||||
|
|
||||||
Setting::query()->delete();
|
Setting::query()->delete();
|
||||||
|
|
||||||
// The mass delete above fires no model events, so Setting's
|
// The mass delete above fires no model events, so Setting's
|
||||||
@@ -299,6 +509,10 @@ class XmlExchange extends Component
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($posta !== []) {
|
||||||
|
Setting::instance()->update($posta);
|
||||||
|
}
|
||||||
|
|
||||||
// Users are UPSERTED by email, never wiped. Deleting the users
|
// Users are UPSERTED by email, never wiped. Deleting the users
|
||||||
// table during a restore risks locking out the administrator
|
// table during a restore risks locking out the administrator
|
||||||
// performing the import (e.g. a backup that predates their account,
|
// performing the import (e.g. a backup that predates their account,
|
||||||
@@ -334,6 +548,10 @@ class XmlExchange extends Component
|
|||||||
$roleNames[] = $roleName;
|
$roleNames[] = $roleName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$eAttore = $attore !== null && $user->id === $attore->id;
|
||||||
|
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
|
||||||
|
$roleNames[] = 'amministratore';
|
||||||
|
}
|
||||||
$user->syncRoles($roleNames);
|
$user->syncRoles($roleNames);
|
||||||
|
|
||||||
// Password hash, remember_token, verification flag and
|
// Password hash, remember_token, verification flag and
|
||||||
@@ -341,13 +559,23 @@ class XmlExchange extends Component
|
|||||||
// hash is preserved exactly (the 'hashed' cast would re-validate
|
// hash is preserved exactly (the 'hashed' cast would re-validate
|
||||||
// the bcrypt cost and could reject a hash made with a different
|
// the bcrypt cost and could reject a hash made with a different
|
||||||
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
|
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
|
||||||
|
// Per chi sta importando password e token restano i suoi:
|
||||||
|
// quelli del file possono essere di mesi fa.
|
||||||
|
//
|
||||||
|
// Chi riceve l'hash dal file deve scegliere una password nuova,
|
||||||
|
// come dopo un ripristino: chi ha avuto il file fra le mani ha
|
||||||
|
// potuto tentarne il crack con calma. Il "ricordami" si azzera
|
||||||
|
// e basta: non si esporta più, e quello di un file vecchio
|
||||||
|
// sarebbe una credenziale persistente regalata a chi lo tiene.
|
||||||
$meta = [];
|
$meta = [];
|
||||||
$passwordHash = (string) ($userNode->password ?? '');
|
$passwordHash = (string) ($userNode->password ?? '');
|
||||||
if ($passwordHash !== '') {
|
if ($passwordHash !== '' && ! $eAttore) {
|
||||||
$meta['password'] = $passwordHash;
|
$meta['password'] = $passwordHash;
|
||||||
|
$meta['password_change_required'] = true;
|
||||||
|
}
|
||||||
|
if (! $eAttore) {
|
||||||
|
$meta['remember_token'] = null;
|
||||||
}
|
}
|
||||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
|
||||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
|
||||||
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
||||||
|
|
||||||
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
||||||
@@ -401,6 +629,8 @@ class XmlExchange extends Component
|
|||||||
'nome' => (string) $proclamatoreNode->nome,
|
'nome' => (string) $proclamatoreNode->nome,
|
||||||
'cognome' => (string) $proclamatoreNode->cognome,
|
'cognome' => (string) $proclamatoreNode->cognome,
|
||||||
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
|
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
|
||||||
|
// <email> e <telefono>, presenti nei file del 13-19 ottobre
|
||||||
|
// 2026, si ignorano: i proclamatori non li hanno più.
|
||||||
]);
|
]);
|
||||||
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
||||||
$stats['proclamatori_importati']++;
|
$stats['proclamatori_importati']++;
|
||||||
@@ -415,8 +645,8 @@ class XmlExchange extends Component
|
|||||||
$label = (string) $annoNode->label;
|
$label = (string) $annoNode->label;
|
||||||
$anno = AnnoTeocratico::create([
|
$anno = AnnoTeocratico::create([
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'start_date' => (string) $annoNode->start_date,
|
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
|
||||||
'end_date' => (string) $annoNode->end_date,
|
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
|
||||||
]);
|
]);
|
||||||
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
||||||
$stats['anni_importati']++;
|
$stats['anni_importati']++;
|
||||||
@@ -430,8 +660,8 @@ class XmlExchange extends Component
|
|||||||
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
||||||
$campagna = Campagna::create([
|
$campagna = Campagna::create([
|
||||||
'descrizione' => (string) $campagnaNode->descrizione,
|
'descrizione' => (string) $campagnaNode->descrizione,
|
||||||
'start_date' => (string) $campagnaNode->start_date,
|
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
|
||||||
'end_date' => (string) $campagnaNode->end_date,
|
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
|
||||||
]);
|
]);
|
||||||
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
||||||
$stats['campagne_importate']++;
|
$stats['campagne_importate']++;
|
||||||
@@ -455,9 +685,14 @@ class XmlExchange extends Component
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
|
// Zona e tipologia sono facoltative (le colonne sono nullable e
|
||||||
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
|
// un territorio resta senza quando la sua zona viene
|
||||||
continue;
|
// eliminata): un riferimento vuoto o sconosciuto non è motivo
|
||||||
|
// per scartare il territorio, che si porterebbe dietro tutto
|
||||||
|
// il suo storico di assegnazioni.
|
||||||
|
if (($legacyZonaId !== '' && ! isset($zoneMap[$legacyZonaId]))
|
||||||
|
|| ($legacyTipologiaId !== '' && ! isset($tipologiaMap[$legacyTipologiaId]))) {
|
||||||
|
$this->pushImportIssue('territorio', $legacyId, 'Zona o tipologia non trovata (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . '): territorio importato senza');
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
|
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
|
||||||
@@ -465,8 +700,8 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
$territorio = Territorio::create([
|
$territorio = Territorio::create([
|
||||||
'numero' => $numero,
|
'numero' => $numero,
|
||||||
'zona_id' => $zoneMap[$legacyZonaId],
|
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
|
||||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
|
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
|
||||||
'confini' => (string) ($territorioNode->confini ?? ''),
|
'confini' => (string) ($territorioNode->confini ?? ''),
|
||||||
'note' => (string) ($territorioNode->note ?? ''),
|
'note' => (string) ($territorioNode->note ?? ''),
|
||||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||||
@@ -532,6 +767,8 @@ class XmlExchange extends Component
|
|||||||
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
|
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
|
||||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||||
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
||||||
|
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
|
||||||
|
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
|
||||||
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
||||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||||
'created_by' => $createdBy,
|
'created_by' => $createdBy,
|
||||||
@@ -541,6 +778,14 @@ class XmlExchange extends Component
|
|||||||
$stats['assegnazioni_importate']++;
|
$stats['assegnazioni_importate']++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (\Illuminate\Database\QueryException $e) {
|
||||||
|
// La transazione è già stata annullata: il database è com'era.
|
||||||
|
// Senza questo l'amministratore vedrebbe un "Server Error" senza
|
||||||
|
// sapere quale dato del file ha violato un vincolo.
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return ['error' => 'Import annullato, nessun dato è stato modificato: il file contiene un dato che il database rifiuta (' . Str::limit($e->getPrevious()?->getMessage() ?? $e->getMessage(), 240) . ').'];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
@@ -576,21 +821,81 @@ class XmlExchange extends Component
|
|||||||
'email' => (string) $u->email,
|
'email' => (string) $u->email,
|
||||||
'password' => (string) $u->getAuthPassword(),
|
'password' => (string) $u->getAuthPassword(),
|
||||||
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
|
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
|
||||||
'remember_token' => (string) ($u->getRememberToken() ?? ''),
|
// remember_token no: all'import si azzera comunque, e nel file
|
||||||
|
// sarebbe solo una credenziale in più per chi lo tiene.
|
||||||
'roles' => $u->roles->pluck('name')->all(),
|
'roles' => $u->roles->pluck('name')->all(),
|
||||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||||
])->all(),
|
])->all(),
|
||||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
|
||||||
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
|
||||||
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||||
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at'])->toArray(),
|
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'pdf_access_code', 'created_at', 'updated_at']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Righe da esportare, con le date scritte in modo che il reimport le
|
||||||
|
* ritrovi identiche.
|
||||||
|
*
|
||||||
|
* toArray() serializza ogni data in UTC: con l'applicazione su
|
||||||
|
* Europe/Rome il 15/03 a mezzanotte diventava "2024-03-14T23:00:00Z" e
|
||||||
|
* al reimport tornava 14/03, un giorno perso a ogni backup e ripristino.
|
||||||
|
* Le colonne con cast date escono come Y-m-d, che non ha fuso; i
|
||||||
|
* timestamp come ISO 8601 con lo scostamento esplicito.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $colonne
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function righeEsportate(\Illuminate\Database\Eloquent\Builder $query, array $colonne): array
|
||||||
|
{
|
||||||
|
return $query->get($colonne)->map(function (\Illuminate\Database\Eloquent\Model $riga) use ($colonne) {
|
||||||
|
$dati = [];
|
||||||
|
foreach ($colonne as $colonna) {
|
||||||
|
$valore = $riga->getAttribute($colonna);
|
||||||
|
|
||||||
|
if ($valore instanceof \Carbon\CarbonInterface) {
|
||||||
|
$valore = $riga->hasCast($colonna, ['date', 'immutable_date'])
|
||||||
|
? $valore->format('Y-m-d')
|
||||||
|
: $valore->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
$dati[$colonna] = $valore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dati;
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il file come SimpleXMLElement, oppure il messaggio d'errore con riga e
|
||||||
|
* motivo: "impossibile leggere il file" da solo non aiuta a correggerlo.
|
||||||
|
*/
|
||||||
|
private function leggiXml(string $content): \SimpleXMLElement|string
|
||||||
|
{
|
||||||
|
$precedente = libxml_use_internal_errors(true);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = simplexml_load_string($content);
|
||||||
|
if ($xml !== false) {
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errore = libxml_get_errors()[0] ?? null;
|
||||||
|
|
||||||
|
return $errore
|
||||||
|
? sprintf('Impossibile leggere il file XML (riga %d): %s.', $errore->line, trim($errore->message))
|
||||||
|
: 'Impossibile leggere il file XML.';
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($precedente);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function legacySqlToDataset(string $sql): array
|
private function legacySqlToDataset(string $sql): array
|
||||||
{
|
{
|
||||||
$rows = $this->extractInsertRows($sql);
|
$rows = $this->extractInsertRows($sql);
|
||||||
@@ -880,8 +1185,12 @@ class XmlExchange extends Component
|
|||||||
return $nullable ? null : null;
|
return $nullable ? null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Riportata nel fuso dell'applicazione prima di tenere solo il giorno:
|
||||||
|
// i backup fatti prima della correzione dell'export hanno le date in
|
||||||
|
// UTC ("2024-03-14T23:00:00Z" per il 15/03), e così tornano giuste.
|
||||||
|
// Una data senza orario e senza fuso resta com'è.
|
||||||
try {
|
try {
|
||||||
return Carbon::parse($raw)->toDateString();
|
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return $nullable ? null : null;
|
return $nullable ? null : null;
|
||||||
}
|
}
|
||||||
@@ -923,7 +1232,6 @@ class XmlExchange extends Component
|
|||||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||||
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
|
|
||||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||||
$rolesNode = $node->addChild('roles');
|
$rolesNode = $node->addChild('roles');
|
||||||
@@ -1012,6 +1320,8 @@ class XmlExchange extends Component
|
|||||||
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
|
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
|
||||||
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
||||||
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
|
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
|
||||||
|
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
|
||||||
|
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
|
||||||
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
||||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||||
$this->addTimestampNodes($node, $assegnazione);
|
$this->addTimestampNodes($node, $assegnazione);
|
||||||
@@ -1022,6 +1332,13 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
|
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
|
||||||
{
|
{
|
||||||
|
// XML 1.0 non ammette i caratteri di controllo (tranne tab e a capo)
|
||||||
|
// né l'UTF-8 non valido: uno solo, magari incollato da Word in una
|
||||||
|
// nota, rende l'intero file illeggibile al reimport — e quel file è
|
||||||
|
// anche il data.xml dei backup.
|
||||||
|
$value = mb_scrub($value, 'UTF-8');
|
||||||
|
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
|
||||||
|
|
||||||
$child = $parent->addChild($name);
|
$child = $parent->addChild($name);
|
||||||
$dom = dom_import_simplexml($child);
|
$dom = dom_import_simplexml($child);
|
||||||
$dom->textContent = $value;
|
$dom->textContent = $value;
|
||||||
@@ -1062,7 +1379,7 @@ class XmlExchange extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return Carbon::parse($raw)->toDateTimeString();
|
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||||
|
if ($operationId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->confermaReset = '';
|
$this->confermaReset = '';
|
||||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
$this->operationId = $operationId;
|
||||||
|
|
||||||
CreateBackupArchive::dispatch($this->operationId, true);
|
CreateBackupArchive::dispatch($this->operationId, true);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
|
|
||||||
class ZoneIndex extends Component
|
class ZoneIndex extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
public string $nuovaZona = '';
|
public string $nuovaZona = '';
|
||||||
public ?int $editingId = null;
|
public ?int $editingId = null;
|
||||||
public string $editingNome = '';
|
public string $editingNome = '';
|
||||||
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
|
|||||||
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
|
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
|
||||||
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
|
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
|
||||||
$this->nuovaZona = '';
|
$this->nuovaZona = '';
|
||||||
session()->flash('success', 'Zona aggiunta.');
|
$this->avvisa('Zona aggiunta.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function startEdit(int $id)
|
public function startEdit(int $id)
|
||||||
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
|
|||||||
$zona->update(['nome' => $this->editingNome]);
|
$zona->update(['nome' => $this->editingNome]);
|
||||||
$this->editingId = null;
|
$this->editingId = null;
|
||||||
$this->editingNome = '';
|
$this->editingNome = '';
|
||||||
session()->flash('success', 'Zona aggiornata.');
|
$this->avvisa('Zona aggiornata.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancelEdit()
|
public function cancelEdit()
|
||||||
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
|
|||||||
{
|
{
|
||||||
$zona = Zona::findOrFail($id);
|
$zona = Zona::findOrFail($id);
|
||||||
if ($zona->territori()->exists()) {
|
if ($zona->territori()->exists()) {
|
||||||
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
|
$this->avvisaErrore("Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$zona->delete();
|
$zona->delete();
|
||||||
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
|
$this->avvisa("Zona '{$zona->nome}' eliminata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Territori;
|
namespace App\Livewire\Territori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class TerritorioCestino extends Component
|
class TerritorioCestino extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
public function restore(int $id)
|
public function restore(int $id)
|
||||||
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
|
|||||||
activity()->causedBy(auth()->user())
|
activity()->causedBy(auth()->user())
|
||||||
->performedOn($territorio)
|
->performedOn($territorio)
|
||||||
->log('restored');
|
->log('restored');
|
||||||
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
|
$this->avvisa("Territorio {$territorio->numero} ripristinato.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function forceDelete(int $id)
|
public function forceDelete(int $id)
|
||||||
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
|
|||||||
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
||||||
|
|
||||||
if ($territorio->assegnazioni()->exists()) {
|
if ($territorio->assegnazioni()->exists()) {
|
||||||
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
|
$this->avvisaErrore('Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
|
|||||||
$this->rimuoviFile($territorio);
|
$this->rimuoviFile($territorio);
|
||||||
|
|
||||||
$territorio->forceDelete();
|
$territorio->forceDelete();
|
||||||
session()->flash('success', 'Territorio eliminato definitivamente.');
|
$this->avvisa('Territorio eliminato definitivamente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Territori;
|
namespace App\Livewire\Territori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -11,6 +12,8 @@ use App\Services\TerritorioThumbnailService;
|
|||||||
|
|
||||||
class TerritorioCreate extends Component
|
class TerritorioCreate extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public string $numero = '';
|
public string $numero = '';
|
||||||
@@ -64,7 +67,7 @@ class TerritorioCreate extends Component
|
|||||||
->log('created');
|
->log('created');
|
||||||
|
|
||||||
session()->flash('success', "Territorio {$territorio->numero} creato.");
|
session()->flash('success', "Territorio {$territorio->numero} creato.");
|
||||||
return redirect()->route('territori.index');
|
return redirect()->to($this->destinazione(route('territori.index')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Territori;
|
namespace App\Livewire\Territori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class TerritorioEdit extends Component
|
class TerritorioEdit extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public Territorio $territorio;
|
public Territorio $territorio;
|
||||||
@@ -64,14 +67,17 @@ class TerritorioEdit extends Component
|
|||||||
|
|
||||||
if ($this->pdf) {
|
if ($this->pdf) {
|
||||||
$thumbService = app(TerritorioThumbnailService::class);
|
$thumbService = app(TerritorioThumbnailService::class);
|
||||||
// Remove old PDF and thumbnail
|
// Prima il nuovo, poi il vecchio nello storico (ripristinabile
|
||||||
if ($this->territorio->pdf_path) {
|
// dalla pagina Piantine). Prima il vecchio si cancellava subito:
|
||||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
// un caricamento sbagliato non si poteva disfare, e con il
|
||||||
}
|
// salvataggio fallito il territorio restava senza PDF.
|
||||||
if ($this->territorio->thumbnail_path) {
|
|
||||||
$thumbService->delete($this->territorio->thumbnail_path);
|
|
||||||
}
|
|
||||||
$pdfPath = $this->pdf->store('territori-pdf', 'public');
|
$pdfPath = $this->pdf->store('territori-pdf', 'public');
|
||||||
|
if (! $pdfPath) {
|
||||||
|
$this->addError('pdf', 'Non è stato possibile salvare il PDF: quello in uso non è stato toccato.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app(\App\Services\Piantine\PiantinaPdfService::class)->archiviaCorrente($this->territorio, 'manuale');
|
||||||
$data['pdf_path'] = $pdfPath;
|
$data['pdf_path'] = $pdfPath;
|
||||||
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
|
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
|
||||||
}
|
}
|
||||||
@@ -79,7 +85,7 @@ class TerritorioEdit extends Component
|
|||||||
$this->territorio->update($data);
|
$this->territorio->update($data);
|
||||||
|
|
||||||
session()->flash('success', "Territorio {$this->territorio->numero} aggiornato.");
|
session()->flash('success', "Territorio {$this->territorio->numero} aggiornato.");
|
||||||
return redirect()->route('territori.show', $this->territorio);
|
return redirect()->to($this->destinazione(route('territori.show', $this->territorio)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removePdf()
|
public function removePdf()
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Territori;
|
namespace App\Livewire\Territori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -11,16 +13,28 @@ use App\Models\Tipologia;
|
|||||||
|
|
||||||
class TerritorioIndex extends Component
|
class TerritorioIndex extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
|
// Ricerca, filtri e ordinamento stanno nell'indirizzo (#[Url]): tornando
|
||||||
|
// da una scheda o da un'operazione l'elenco si ritrova com'era.
|
||||||
|
#[Url(except: '')]
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filterZona = '';
|
public string $filterZona = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filterTipologia = '';
|
public string $filterTipologia = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filterStato = '';
|
public string $filterStato = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filterPriorita = '';
|
public string $filterPriorita = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filterPdf = '';
|
public string $filterPdf = '';
|
||||||
|
#[Url(except: '')]
|
||||||
public string $filterContenuti = '';
|
public string $filterContenuti = '';
|
||||||
|
#[Url(except: 'numero')]
|
||||||
public string $sortField = 'numero';
|
public string $sortField = 'numero';
|
||||||
|
#[Url(except: 'asc')]
|
||||||
public string $sortDirection = 'asc';
|
public string $sortDirection = 'asc';
|
||||||
|
|
||||||
protected $queryString = [
|
protected $queryString = [
|
||||||
@@ -127,7 +141,7 @@ class TerritorioIndex extends Component
|
|||||||
->performedOn($territorio)
|
->performedOn($territorio)
|
||||||
->log('soft_deleted');
|
->log('soft_deleted');
|
||||||
$territorio->delete();
|
$territorio->delete();
|
||||||
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
|
$this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Territori;
|
namespace App\Livewire\Territori;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
|
|
||||||
class TerritorioShow extends Component
|
class TerritorioShow extends Component
|
||||||
{
|
{
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
public Territorio $territorio;
|
public Territorio $territorio;
|
||||||
|
|
||||||
public function mount(Territorio $territorio)
|
public function mount(Territorio $territorio)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class Assegnazione extends Model
|
|||||||
'campaign_id',
|
'campaign_id',
|
||||||
'pdf_access_code',
|
'pdf_access_code',
|
||||||
'link_sent',
|
'link_sent',
|
||||||
|
'link_da_inviare',
|
||||||
|
'link_canale',
|
||||||
'note',
|
'note',
|
||||||
'created_by',
|
'created_by',
|
||||||
'returned_by',
|
'returned_by',
|
||||||
@@ -32,6 +34,7 @@ class Assegnazione extends Model
|
|||||||
'returned_at' => 'date',
|
'returned_at' => 'date',
|
||||||
'counted_in_campaign' => 'boolean',
|
'counted_in_campaign' => 'boolean',
|
||||||
'link_sent' => 'boolean',
|
'link_sent' => 'boolean',
|
||||||
|
'link_da_inviare' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +58,44 @@ class Assegnazione extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'assegnazione dello stesso territorio il cui periodo si sovrappone a
|
||||||
|
* quello indicato, oppure null. Un'assegnazione aperta dura fino a oggi e
|
||||||
|
* oltre; rientro e nuova uscita nello stesso giorno non si sovrappongono.
|
||||||
|
*
|
||||||
|
* Copre in un colpo solo la doppia assegnazione aperta, l'uscita
|
||||||
|
* retrodatata a prima dell'ultimo rientro e le correzioni a mano nel
|
||||||
|
* Registro che accavallano due periodi. Il confronto è fatto in PHP sulle
|
||||||
|
* poche assegnazioni del territorio: in SQL dipenderebbe da come il
|
||||||
|
* driver memorizza le DATE (MariaDB "Y-m-d", SQLite "Y-m-d H:i:s").
|
||||||
|
*/
|
||||||
|
public static function sovrappostaA(
|
||||||
|
int $territorioId,
|
||||||
|
Carbon $uscita,
|
||||||
|
?Carbon $rientro = null,
|
||||||
|
?int $esclusaId = null
|
||||||
|
): ?self {
|
||||||
|
$uscita = $uscita->copy()->startOfDay();
|
||||||
|
$rientro = $rientro?->copy()->startOfDay();
|
||||||
|
|
||||||
|
return static::with('proclamatore')
|
||||||
|
->where('territorio_id', $territorioId)
|
||||||
|
->when($esclusaId, fn ($q) => $q->whereKeyNot($esclusaId))
|
||||||
|
->orderBy('assigned_at')
|
||||||
|
->get()
|
||||||
|
->first(fn (self $altra) => ($rientro === null || $altra->assigned_at->lt($rientro))
|
||||||
|
&& ($altra->returned_at === null || $uscita->lt($altra->returned_at)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Descrizione del periodo per i messaggi d'errore: "a Rossi Mario dal 01/09/2025 al 15/10/2025". */
|
||||||
|
public function descrizionePeriodo(): string
|
||||||
|
{
|
||||||
|
$chi = $this->proclamatore?->nome_completo ?? 'un proclamatore';
|
||||||
|
$al = $this->returned_at ? ' al ' . $this->returned_at->format('d/m/Y') : ', non ancora rientrato';
|
||||||
|
|
||||||
|
return "a {$chi} dal {$this->assigned_at->format('d/m/Y')}{$al}";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Relationships ─────────────────────────────────────────
|
// ─── Relationships ─────────────────────────────────────────
|
||||||
|
|
||||||
public function territorio()
|
public function territorio()
|
||||||
@@ -95,7 +136,8 @@ class Assegnazione extends Model
|
|||||||
public function getGiorniAttribute(): int
|
public function getGiorniAttribute(): int
|
||||||
{
|
{
|
||||||
$end = $this->returned_at ?? now();
|
$end = $this->returned_at ?? now();
|
||||||
return Carbon::parse($this->assigned_at)->diffInDays($end);
|
// Carbon 3 restituisce un float: i giorni interi trascorsi.
|
||||||
|
return (int) Carbon::parse($this->assigned_at)->diffInDays($end);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIsApertaAttribute(): bool
|
public function getIsApertaAttribute(): bool
|
||||||
@@ -173,9 +215,56 @@ class Assegnazione extends Model
|
|||||||
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
|
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markLinkSent(): void
|
public function markLinkSent(?string $canale = null): void
|
||||||
{
|
{
|
||||||
$this->forceFill(['link_sent' => true])->saveQuietly();
|
$this->registraInvioLink(true, $canale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canali con cui si manda il link al proclamatore. Dal 19 ottobre 2026 se
|
||||||
|
* ne usano due: copia link e Condividi (il menu di condivisione del
|
||||||
|
* telefono); il destinatario lo sceglie chi usa l'app. "whatsapp" ed
|
||||||
|
* "email" restano solo perché le assegnazioni già inviate così lo
|
||||||
|
* mostrino e l'XML lo accetti.
|
||||||
|
*/
|
||||||
|
public const CANALI_LINK = ['copia' => 'Copia link', 'condividi' => 'Condividi', 'whatsapp' => 'WhatsApp', 'email' => 'Email'];
|
||||||
|
|
||||||
|
/** I canali che si possono scegliere oggi nella finestra di invio. */
|
||||||
|
public const CANALI_ATTIVI = ['copia', 'condividi'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esito dell'invio del link: inviato (con il canale usato) oppure no. Un
|
||||||
|
* link non inviato resta "da inviare" e lo si segnala sull'assegnazione,
|
||||||
|
* a meno che non ci sia proprio un link da mandare (territorio senza PDF).
|
||||||
|
*/
|
||||||
|
public function registraInvioLink(bool $inviato, ?string $canale = null): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'link_sent' => $inviato,
|
||||||
|
'link_da_inviare' => ! $inviato && $this->shortPdfUrl() !== null,
|
||||||
|
'link_canale' => $inviato ? $canale : $this->link_canale,
|
||||||
|
])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il link va ancora mandato: l'avviso da mostrare sull'assegnazione. */
|
||||||
|
public function getLinkInSospesoAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->link_da_inviare && ! $this->link_sent && $this->is_aperta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il messaggio con il link, per "Condividi": il menu di condivisione del
|
||||||
|
* telefono, da cui chi usa l'app sceglie app e contatto. L'app non vede
|
||||||
|
* quei contatti.
|
||||||
|
*/
|
||||||
|
public function messaggioLink(?string $link = null): ?string
|
||||||
|
{
|
||||||
|
$link ??= $this->shortPdfUrl();
|
||||||
|
if ($link === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Ciao {$this->proclamatore?->nome}, ti è stato assegnato il territorio N° {$this->territorio?->numero}. Ecco la piantina: {$link}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scopes ─────────────────────────────────────────────────
|
// ─── Scopes ─────────────────────────────────────────────────
|
||||||
@@ -197,16 +286,69 @@ class Assegnazione extends Model
|
|||||||
|
|
||||||
// ─── Business Logic ─────────────────────────────────────────
|
// ─── Business Logic ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra nell'audit log la scelta dell'operatore sul conteggio in
|
||||||
|
* campagna: evento dedicato "campagna_percorso", leggibile senza dover
|
||||||
|
* interpretare le proprietà dell'evento "returned".
|
||||||
|
*/
|
||||||
|
public function logCampagnaPercorso(?Campagna $campagna, bool $percorso, string $origine): void
|
||||||
|
{
|
||||||
|
$this->loadMissing('territorio');
|
||||||
|
|
||||||
|
$giorni = $this->giorniPercorrenza();
|
||||||
|
$soglia = static::sogliaPercorrenzaElevata();
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())
|
||||||
|
->performedOn($this)
|
||||||
|
->withProperties([
|
||||||
|
'territorio' => $this->territorio?->numero,
|
||||||
|
'proclamatore_id' => $this->proclamatore_id, // vedi Assegna: niente nomi in chiaro
|
||||||
|
'campagna' => $campagna?->descrizione,
|
||||||
|
'percorso_in_campagna' => $percorso ? 'Sì' : 'No',
|
||||||
|
'giorni_percorrenza' => $giorni,
|
||||||
|
'avviso_tempo_elevato' => $giorni > $soglia ? "Sì (soglia {$soglia} giorni)" : 'No',
|
||||||
|
'origine' => $origine,
|
||||||
|
])
|
||||||
|
->log('campagna_percorso');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Giorni tra assegnazione e rientro (o la data indicata, o oggi).
|
||||||
|
*/
|
||||||
|
public function giorniPercorrenza(?Carbon $returnDate = null): int
|
||||||
|
{
|
||||||
|
$end = $returnDate ?? $this->returned_at ?? now();
|
||||||
|
|
||||||
|
return (int) Carbon::parse($this->assigned_at)->startOfDay()->diffInDays(Carbon::parse($end)->startOfDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oltre questa soglia il tempo di percorrenza è considerato molto alto:
|
||||||
|
* è la stessa soglia "giorni_per_smarrito" dello stato "da_rientrare".
|
||||||
|
*/
|
||||||
|
public static function sogliaPercorrenzaElevata(): int
|
||||||
|
{
|
||||||
|
return (int) Setting::getValue('giorni_per_smarrito', 120);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a campaign prompt should be shown when returning this assignment.
|
* Check if a campaign prompt should be shown when returning this assignment.
|
||||||
* Returns the matching campaign or null.
|
* Returns the matching campaign or null.
|
||||||
*/
|
*/
|
||||||
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna
|
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna
|
||||||
{
|
{
|
||||||
$returnDate = $returnDate ?? now();
|
$returnDate = ($returnDate ?? now())->toDateString();
|
||||||
|
$assignedAt = $this->assigned_at->toDateString();
|
||||||
|
|
||||||
|
// Il territorio può essere conteggiato per una campagna se è rimasto
|
||||||
|
// in mano al proclamatore almeno in parte del periodo: assegnato
|
||||||
|
// durante la campagna, rientrato durante la campagna, oppure fuori per
|
||||||
|
// tutta la sua durata (assegnato prima e rientrato dopo). Se sia stato
|
||||||
|
// davvero percorso lo decide l'operatore con la spunta.
|
||||||
|
// Se più campagne corrispondono si propone la più recente.
|
||||||
return Campagna::where('start_date', '<=', $returnDate)
|
return Campagna::where('start_date', '<=', $returnDate)
|
||||||
->where('end_date', '>=', $this->assigned_at)
|
->where('end_date', '>=', $assignedAt)
|
||||||
|
->orderByDesc('start_date')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ class Campagna extends Model
|
|||||||
*/
|
*/
|
||||||
public static function attiva(): ?static
|
public static function attiva(): ?static
|
||||||
{
|
{
|
||||||
return static::where('start_date', '<=', now())
|
// Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
|
||||||
->where('end_date', '>=', now())
|
// suo giorno, e contro now() la campagna spariva dalla home proprio
|
||||||
|
// nel suo ultimo giorno.
|
||||||
|
$oggi = now()->toDateString();
|
||||||
|
|
||||||
|
return static::whereDate('start_date', '<=', $oggi)
|
||||||
|
->whereDate('end_date', '>=', $oggi)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +65,16 @@ class Campagna extends Model
|
|||||||
->where('counted_in_campaign', true);
|
->where('counted_in_campaign', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assignments made during this campaign and not yet returned.
|
||||||
|
*/
|
||||||
|
public function assegnazioniInCorso()
|
||||||
|
{
|
||||||
|
return Assegnazione::whereNull('returned_at')
|
||||||
|
->where('assigned_at', '>=', $this->start_date)
|
||||||
|
->where('assigned_at', '<=', $this->end_date);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All assignments with assigned_at in this campaign's range.
|
* All assignments with assigned_at in this campaign's range.
|
||||||
*/
|
*/
|
||||||
@@ -70,9 +85,11 @@ class Campagna extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Campaign coverage percentage.
|
* Quota dei territori attivi percorsi durante la campagna.
|
||||||
* Numerator: assignments counted for campaign
|
*
|
||||||
* Denominator: total active territories
|
* Si contano i territori, non le assegnazioni: un territorio percorso due
|
||||||
|
* volte valeva doppio, e insieme ai territori poi disattivati (fuori dal
|
||||||
|
* denominatore ma ancora nel numeratore) la percentuale superava il 100%.
|
||||||
*/
|
*/
|
||||||
public function getPercentualePercorrenzaAttribute(): float
|
public function getPercentualePercorrenzaAttribute(): float
|
||||||
{
|
{
|
||||||
@@ -82,13 +99,17 @@ class Campagna extends Model
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$conteggiate = $this->assegnazioniConteggiate()->count();
|
$percorsi = $this->assegnazioniConteggiate()
|
||||||
|
->whereHas('territorio', fn ($q) => $q->where('attivo', true))
|
||||||
|
->distinct()
|
||||||
|
->count('territorio_id');
|
||||||
|
|
||||||
return round(($conteggiate / $totaleAttivi) * 100, 1);
|
return round(($percorsi / $totaleAttivi) * 100, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeCompletate($query)
|
public function scopeCompletate($query)
|
||||||
{
|
{
|
||||||
return $query->where('end_date', '<', now())->orderByDesc('end_date');
|
// Una campagna che finisce oggi è ancora in corso.
|
||||||
|
return $query->whereDate('end_date', '<', now()->toDateString())->orderByDesc('end_date');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class Proclamatore extends Model
|
|||||||
|
|
||||||
protected $table = 'proclamatori';
|
protected $table = 'proclamatori';
|
||||||
|
|
||||||
|
// Niente email né telefono (tolti il 19 ottobre 2026): il link della
|
||||||
|
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
|
||||||
|
// destinatario lo sceglie dai propri contatti.
|
||||||
protected $fillable = ['nome', 'cognome', 'attivo'];
|
protected $fillable = ['nome', 'cognome', 'attivo'];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -40,6 +43,34 @@ class Proclamatore extends Model
|
|||||||
return trim($this->cognome . ' ' . $this->nome);
|
return trim($this->cognome . ' ' . $this->nome);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
|
||||||
|
* cognome e nome. Nome e cognome sono cifrati nel database: la ricerca
|
||||||
|
* avviene sui valori già decifrati, nella pagina, mai in SQL.
|
||||||
|
*/
|
||||||
|
public function opzioneScelta(?string $motivo = null): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'valore' => (string) $this->id,
|
||||||
|
'etichetta' => $this->nome_completo,
|
||||||
|
'chiavi' => [(string) $this->cognome, (string) $this->nome],
|
||||||
|
'motivo' => $motivo,
|
||||||
|
], fn ($v) => $v !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
|
||||||
|
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati.
|
||||||
|
*/
|
||||||
|
public static function opzioniScelta(iterable $proclamatori): array
|
||||||
|
{
|
||||||
|
return collect($proclamatori)
|
||||||
|
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
|
||||||
|
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
public function assegnazioni()
|
public function assegnazioni()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
||||||
|
|||||||
@@ -20,8 +20,31 @@ class Setting extends Model
|
|||||||
'pdf_viewer_show_download',
|
'pdf_viewer_show_download',
|
||||||
'audit_retention_days',
|
'audit_retention_days',
|
||||||
'setup_completed',
|
'setup_completed',
|
||||||
|
'mail_host',
|
||||||
|
'mail_port',
|
||||||
|
'mail_cifratura',
|
||||||
|
'mail_username',
|
||||||
|
'mail_password',
|
||||||
|
'mail_from_address',
|
||||||
|
'mail_from_name',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il server di posta: configurazione di questa installazione, non dati.
|
||||||
|
* Non si esporta e un import non la tocca (vedi XmlExchange).
|
||||||
|
*/
|
||||||
|
public const CAMPI_POSTA = [
|
||||||
|
'mail_host',
|
||||||
|
'mail_port',
|
||||||
|
'mail_cifratura',
|
||||||
|
'mail_username',
|
||||||
|
'mail_password',
|
||||||
|
'mail_from_address',
|
||||||
|
'mail_from_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = ['mail_password'];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -33,6 +56,8 @@ class Setting extends Model
|
|||||||
'home_limit_list' => 'integer',
|
'home_limit_list' => 'integer',
|
||||||
'assignment_link_ttl_hours' => 'integer',
|
'assignment_link_ttl_hours' => 'integer',
|
||||||
'audit_retention_days' => 'integer',
|
'audit_retention_days' => 'integer',
|
||||||
|
'mail_port' => 'integer',
|
||||||
|
'mail_password' => 'encrypted',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,13 @@ class Territorio extends Model
|
|||||||
|
|
||||||
public function assegnazioneCorrente()
|
public function assegnazioneCorrente()
|
||||||
{
|
{
|
||||||
|
// Il filtro sulle aperte va nella closure di ofMany: scritto fuori con
|
||||||
|
// whereNull() vale solo per la query esterna, mentre il MAX(assigned_at)
|
||||||
|
// del sottoquery si calcola su tutte le assegnazioni. Bastava una
|
||||||
|
// chiusa più recente dell'aperta per far risultare il territorio
|
||||||
|
// libero mentre era fuori, e quindi assegnabile una seconda volta.
|
||||||
return $this->hasOne(Assegnazione::class, 'territorio_id')
|
return $this->hasOne(Assegnazione::class, 'territorio_id')
|
||||||
->whereNull('returned_at')
|
->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
|
||||||
->latestOfMany('assigned_at');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ultimaAssegnazione()
|
public function ultimaAssegnazione()
|
||||||
@@ -141,6 +145,24 @@ class Territorio extends Model
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
|
||||||
|
* numero, zona e tipologia. Con $motivo compare cercando ma non si può
|
||||||
|
* scegliere ("già assegnato a …").
|
||||||
|
*/
|
||||||
|
public function opzioneScelta(?string $motivo = null, ?string $nota = null): array
|
||||||
|
{
|
||||||
|
$dettaglio = trim(($this->zona?->nome ?? '') . ($this->tipologia?->nome ? " ({$this->tipologia->nome})" : ''));
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'valore' => (string) $this->id,
|
||||||
|
'etichetta' => "N° {$this->numero}" . ($nota ? " ({$nota})" : ''),
|
||||||
|
'dettaglio' => $dettaglio !== '' ? $dettaglio : null,
|
||||||
|
'chiavi' => [(string) $this->numero, (string) $this->zona?->nome, (string) $this->tipologia?->nome],
|
||||||
|
'motivo' => $motivo,
|
||||||
|
], fn ($v) => $v !== null);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Scopes ─────────────────────────────────────────────────
|
// ─── Scopes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function scopeAttivi($query)
|
public function scopeAttivi($query)
|
||||||
@@ -216,4 +238,24 @@ class Territorio extends Model
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Indirizzi dei file (serviti da TerritorioFileController) ──────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ?v= cambia con il file: l'indirizzo resta per territorio, e senza la
|
||||||
|
* cache del browser mostrerebbe il PDF di prima dopo una sostituzione.
|
||||||
|
*/
|
||||||
|
public function pdfUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->pdf_path
|
||||||
|
? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function miniaturaUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->thumbnail_path
|
||||||
|
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,30 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
|
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
|
||||||
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
||||||
* territorio, che e' il riferimento con cui lo chiamano tutti.
|
* 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
|
class TerritorioMappa extends Model
|
||||||
{
|
{
|
||||||
|
public const STATO_BOZZA = 'bozza';
|
||||||
|
|
||||||
|
public const STATO_PUBBLICATA = 'pubblicata';
|
||||||
|
|
||||||
|
public const STATO_MODIFICATA = 'modificata';
|
||||||
|
|
||||||
protected $table = 'territorio_mappe';
|
protected $table = 'territorio_mappe';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'territorio_id',
|
'territorio_id',
|
||||||
'nome',
|
'nome',
|
||||||
'geojson',
|
'geojson',
|
||||||
|
'geojson_pubblicato',
|
||||||
|
'impronta_pubblicata',
|
||||||
|
'pdf_pubblicato',
|
||||||
|
'pubblicata_at',
|
||||||
'image_path',
|
'image_path',
|
||||||
'rendered_at',
|
'rendered_at',
|
||||||
];
|
];
|
||||||
@@ -28,12 +43,19 @@ class TerritorioMappa extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'rendered_at' => 'datetime',
|
'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()
|
public function territorio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Territorio::class, 'territorio_id');
|
return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,15 +64,22 @@ class TerritorioMappa extends Model
|
|||||||
*/
|
*/
|
||||||
public function geometria(): ?array
|
public function geometria(): ?array
|
||||||
{
|
{
|
||||||
$decoded = json_decode($this->geojson, true);
|
$decoded = json_decode((string) $this->geojson, true);
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : null;
|
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
|
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 +89,7 @@ class TerritorioMappa extends Model
|
|||||||
*/
|
*/
|
||||||
public function etichetta(): string
|
public function etichetta(): string
|
||||||
{
|
{
|
||||||
if ($this->collegata()) {
|
if ($this->territorio !== null) {
|
||||||
return 'Terr. ' . $this->territorio->numero;
|
return 'Terr. ' . $this->territorio->numero;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,4 +97,93 @@ class TerritorioMappa extends Model
|
|||||||
|
|
||||||
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
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;
|
||||||
|
|
||||||
|
// Il territorio entra con il numero, non con l'id: il numero è unico e
|
||||||
|
// il ripristino di un backup ricrea i territori con id nuovi, che
|
||||||
|
// facevano risultare da pubblicare ogni piantina appena ripristinata.
|
||||||
|
return hash('sha256', json_encode([
|
||||||
|
$this->geometria(),
|
||||||
|
$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 è ancora quello prodotto dall'ultima
|
||||||
|
* pubblicazione. 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. Si confronta il percorso, che il backup conserva: prima si
|
||||||
|
* cercava l'id del territorio nel nome del file, e dopo un ripristino
|
||||||
|
* (id nuovi) non tornava più.
|
||||||
|
*/
|
||||||
|
public function pdfInUsoDallaPiantina(): bool
|
||||||
|
{
|
||||||
|
return $this->pdf_pubblicato !== null
|
||||||
|
&& $this->territorio !== null
|
||||||
|
&& $this->territorio->pdf_path === $this->pdf_pubblicato;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
@@ -9,7 +10,12 @@ use Spatie\Permission\Traits\HasRoles;
|
|||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class User extends Authenticatable
|
/**
|
||||||
|
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
|
||||||
|
* (middleware `verified`, VerificaEmailController). Chi cambia email — dal
|
||||||
|
* profilo o perché la cambia l'amministratore — torna da confermare.
|
||||||
|
*/
|
||||||
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||||
|
|
||||||
@@ -41,4 +47,32 @@ class User extends Authenticatable
|
|||||||
->logOnlyDirty()
|
->logOnlyDirty()
|
||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
||||||
|
static::updating(function (self $utente) {
|
||||||
|
if ($utente->isDirty('email') && ! $utente->isDirty('email_verified_at')) {
|
||||||
|
$utente->email_verified_at = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manda l'email di conferma; false se il server di posta non risponde o
|
||||||
|
* rifiuta. Chi chiama decide cosa dire: l'utente è comunque salvato, e
|
||||||
|
* l'email si può richiedere di nuovo.
|
||||||
|
*/
|
||||||
|
public function inviaConfermaEmail(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->sendEmailVerificationNotification();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\ConfigurazionePosta;
|
||||||
|
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
|
||||||
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -17,6 +23,48 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Setting tiene le impostazioni in una variabile statica, svuotata
|
||||||
|
// solo quando le salva lo stesso processo. Il worker resta vivo fino a
|
||||||
|
// un'ora: senza questo, ogni job usava le impostazioni lette dal primo,
|
||||||
|
// anche dopo che l'amministratore le aveva cambiate dal browser o un
|
||||||
|
// ripristino le aveva sostituite.
|
||||||
|
Queue::before(function () {
|
||||||
|
Setting::flushCache();
|
||||||
|
|
||||||
|
// Anche il server di posta: cambiato dal browser, il worker
|
||||||
|
// continuava a usare quello del primo invio.
|
||||||
|
if ($this->app->resolved('mail.manager')) {
|
||||||
|
app(ConfigurazionePosta::class)->applica();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'email di conferma in italiano. Il link resta quello di Laravel
|
||||||
|
// (firmato, scade dopo auth.verification.expire minuti) e porta a
|
||||||
|
// VerificaEmailController::conferma, che funziona anche senza login.
|
||||||
|
VerifyEmail::toMailUsing(function ($utente, string $link) {
|
||||||
|
$minuti = (int) config('auth.verification.expire', 60);
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Conferma il tuo indirizzo email — TerManager2')
|
||||||
|
->greeting('Ciao ' . $utente->name . ',')
|
||||||
|
->line('per usare TerManager2 conferma che questo indirizzo email è tuo.')
|
||||||
|
->action('Conferma l\'indirizzo', $link)
|
||||||
|
->line("Il link vale {$minuti} minuti. Se è scaduto, entra in TerManager2 e chiedine uno nuovo.")
|
||||||
|
->line('Se non sai di cosa si tratta, ignora questa email.')
|
||||||
|
->salutation('TerManager2');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le richieste di Livewire non ripassano dal gruppo di rotte della
|
||||||
|
// pagina: senza, una scheda aperta prima del blocco continuerebbe a
|
||||||
|
// lavorare con un'email non confermata.
|
||||||
|
Livewire::addPersistentMiddleware([EnsureEmailIsVerified::class]);
|
||||||
|
|
||||||
|
// Il server di posta configurato in Impostazioni prende il posto di
|
||||||
|
// quello del .env, appena Laravel crea il sistema di posta.
|
||||||
|
$this->app->afterResolving('mail.manager', function ($manager) {
|
||||||
|
app(ConfigurazionePosta::class)->applica($manager);
|
||||||
|
});
|
||||||
|
|
||||||
// Pin generated URLs to APP_URL.
|
// Pin generated URLs to APP_URL.
|
||||||
//
|
//
|
||||||
// Behind a trusted proxy, Symfony derives the port from
|
// Behind a trusted proxy, Symfony derives the port from
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ use ZipArchive;
|
|||||||
*
|
*
|
||||||
* manifest.json formato, data, conteggi (letto per validare il file)
|
* manifest.json formato, data, conteggi (letto per validare il file)
|
||||||
* data.xml lo stesso XML dell'export "Esporta XML"
|
* data.xml lo stesso XML dell'export "Esporta XML"
|
||||||
|
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
|
||||||
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
||||||
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
||||||
|
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
|
||||||
|
* che sta accanto al disco `public`, non dentro
|
||||||
|
*
|
||||||
|
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
|
||||||
|
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
|
||||||
|
* KML in uso non si tocca (vedi scambiaFile()).
|
||||||
*/
|
*/
|
||||||
class BackupArchive
|
class BackupArchive
|
||||||
{
|
{
|
||||||
@@ -29,11 +36,23 @@ class BackupArchive
|
|||||||
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
||||||
* quello che sa leggere invece di ripristinarli a meta'.
|
* quello che sa leggere invece di ripristinarli a meta'.
|
||||||
*/
|
*/
|
||||||
public const FORMAT_VERSION = 1;
|
public const FORMAT_VERSION = 2;
|
||||||
|
|
||||||
private const ENTRY_MANIFEST = 'manifest.json';
|
private const ENTRY_MANIFEST = 'manifest.json';
|
||||||
private const ENTRY_DATA = 'data.xml';
|
private const ENTRY_DATA = 'data.xml';
|
||||||
|
private const ENTRY_PIANTINE = 'piantine.json';
|
||||||
private const PREFIX_STORAGE = 'storage/';
|
private const PREFIX_STORAGE = 'storage/';
|
||||||
|
private const PREFIX_PIANTINE = 'piantine/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sottocartelle dello staging, una per ogni cartella che il ripristino
|
||||||
|
* sostituisce (vedi radiciDaSostituire()).
|
||||||
|
*/
|
||||||
|
private const STAGING_PUBBLICO = 'pubblico';
|
||||||
|
private const STAGING_PIANTINE = 'piantine';
|
||||||
|
|
||||||
|
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
|
||||||
|
private const CARTELLA_PIANTINE = 'piantine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
|
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
|
||||||
@@ -65,12 +84,32 @@ class BackupArchive
|
|||||||
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
||||||
*
|
*
|
||||||
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
||||||
|
* @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
|
||||||
|
* Obbligatorio e non facoltativo di proposito: un
|
||||||
|
* chiamante che se ne dimentica produrrebbe archivi
|
||||||
|
* che al ripristino svuotano le piantine.
|
||||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||||
*/
|
*/
|
||||||
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
|
public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
|
||||||
{
|
{
|
||||||
$disk = Storage::disk('public');
|
$disk = Storage::disk('public');
|
||||||
$files = $disk->allFiles();
|
$local = Storage::disk('local');
|
||||||
|
|
||||||
|
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
|
||||||
|
// (per i messaggi), percorso sul disco, nome dell'entry.
|
||||||
|
$files = [];
|
||||||
|
foreach ($disk->allFiles() as $relativePath) {
|
||||||
|
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
|
||||||
|
}
|
||||||
|
$filePubblici = count($files);
|
||||||
|
|
||||||
|
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
|
||||||
|
// percorso relativo al disco coincide con il nome dell'entry.
|
||||||
|
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
|
||||||
|
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
|
||||||
|
}
|
||||||
|
$filePiantine = count($files) - $filePubblici;
|
||||||
|
|
||||||
$totale = count($files);
|
$totale = count($files);
|
||||||
|
|
||||||
$cartella = dirname($destinazione);
|
$cartella = dirname($destinazione);
|
||||||
@@ -84,12 +123,14 @@ class BackupArchive
|
|||||||
}
|
}
|
||||||
|
|
||||||
$zip->addFromString(self::ENTRY_DATA, $xml);
|
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||||
|
$zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||||
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||||
'format_version' => self::FORMAT_VERSION,
|
'format_version' => self::FORMAT_VERSION,
|
||||||
'application' => 'TerManager2',
|
'application' => 'TerManager2',
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => now()->toIso8601String(),
|
||||||
'app_url' => (string) config('app.url'),
|
'app_url' => (string) config('app.url'),
|
||||||
'files_count' => $totale,
|
'files_count' => $filePubblici,
|
||||||
|
'piantine_files_count' => $filePiantine,
|
||||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -110,12 +151,12 @@ class BackupArchive
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
||||||
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
$files, $totale, $perLotto, $onProgress, $destinazione
|
||||||
) {
|
) {
|
||||||
$nelLotto = 0;
|
$nelLotto = 0;
|
||||||
$fatti = 0;
|
$fatti = 0;
|
||||||
|
|
||||||
foreach ($files as $relativePath) {
|
foreach ($files as [$relativePath, $sorgente, $entry]) {
|
||||||
// addFile() legge dal disco durante close(): i PDF non
|
// addFile() legge dal disco durante close(): i PDF non
|
||||||
// passano mai interi per la memoria del processo.
|
// passano mai interi per la memoria del processo.
|
||||||
//
|
//
|
||||||
@@ -133,7 +174,7 @@ class BackupArchive
|
|||||||
// raggiunto mai, e l'amministratore leggerebbe la stringa
|
// raggiunto mai, e l'amministratore leggerebbe la stringa
|
||||||
// grezza di PHP. Stesso problema, e stessa scelta, del
|
// grezza di PHP. Stesso problema, e stessa scelta, del
|
||||||
// close() avvolto in chiudi().
|
// close() avvolto in chiudi().
|
||||||
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
|
if (! @$zip->addFile($sorgente, $entry)) {
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
||||||
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
||||||
@@ -157,7 +198,7 @@ class BackupArchive
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->verificaArchivio($destinazione, $totale);
|
$this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@unlink($destinazione);
|
@unlink($destinazione);
|
||||||
|
|
||||||
@@ -178,7 +219,7 @@ class BackupArchive
|
|||||||
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
||||||
* troncamenti, chiusure a meta' e lotti persi.
|
* troncamenti, chiusure a meta' e lotti persi.
|
||||||
*/
|
*/
|
||||||
private function verificaArchivio(string $path, int $fileAttesi): void
|
private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void
|
||||||
{
|
{
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
|
|
||||||
@@ -187,27 +228,31 @@ class BackupArchive
|
|||||||
}
|
}
|
||||||
|
|
||||||
$trovati = 0;
|
$trovati = 0;
|
||||||
|
$piantineTrovati = 0;
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
$nome = $zip->getNameIndex($i);
|
$nome = $zip->getNameIndex($i);
|
||||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
||||||
$trovati++;
|
$trovati++;
|
||||||
|
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
|
||||||
|
$piantineTrovati++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
||||||
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
||||||
|
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
if (! $haDati || ! $haManifest) {
|
if (! $haDati || ! $haManifest || ! $haPiantine) {
|
||||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
|
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($trovati !== $fileAttesi) {
|
if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
|
||||||
throw new RuntimeException(sprintf(
|
throw new RuntimeException(sprintf(
|
||||||
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
||||||
$trovati,
|
$trovati + $piantineTrovati,
|
||||||
$fileAttesi
|
$fileAttesi + $piantineAttesi
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,8 +339,15 @@ class BackupArchive
|
|||||||
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||||
* dati correnti sono ancora intatti.
|
* dati correnti sono ancora intatti.
|
||||||
*
|
*
|
||||||
|
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
|
||||||
|
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
|
||||||
|
* file troncato, un'entry corrotta o il disco che si riempie durante
|
||||||
|
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
|
||||||
|
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
|
||||||
|
* anche per lo scambio, che e' solo una serie di rename().
|
||||||
|
*
|
||||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||||
* @return array{xml: string, staging: string, files: int}
|
* @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int}
|
||||||
*/
|
*/
|
||||||
public function extract(string $zipPath, ?callable $onProgress = null): array
|
public function extract(string $zipPath, ?callable $onProgress = null): array
|
||||||
{
|
{
|
||||||
@@ -334,12 +386,35 @@ class BackupArchive
|
|||||||
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
|
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
|
||||||
|
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
|
||||||
|
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
|
||||||
|
// Null resta riservato al formato 1, che le piantine non le aveva.
|
||||||
|
$piantine = null;
|
||||||
|
if ($formatVersion >= 2) {
|
||||||
|
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
|
||||||
|
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
|
||||||
|
|
||||||
|
if (! is_array($piantine)) {
|
||||||
|
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le entry che si estraggono, prefisso => sottocartella dello
|
||||||
|
// staging. L'area KML solo dal formato 2: un archivio vecchio non
|
||||||
|
// la porta, e non deve poterla portare per caso.
|
||||||
|
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
|
||||||
|
if ($formatVersion >= 2) {
|
||||||
|
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
|
||||||
|
}
|
||||||
|
|
||||||
if ($zip->numFiles > self::MAX_ENTRIES) {
|
if ($zip->numFiles > self::MAX_ENTRIES) {
|
||||||
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalBytes = 0;
|
$totalBytes = 0;
|
||||||
$extracted = 0;
|
$extracted = 0;
|
||||||
|
$estrattiPiantine = 0;
|
||||||
|
|
||||||
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
||||||
// due entry di servizio": un archivio con una entry in piu' (una
|
// due entry di servizio": un archivio con una entry in piu' (una
|
||||||
@@ -348,7 +423,7 @@ class BackupArchive
|
|||||||
$daEstrarre = 0;
|
$daEstrarre = 0;
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
$nome = $zip->getNameIndex($i);
|
$nome = $zip->getNameIndex($i);
|
||||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
|
if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
|
||||||
$daEstrarre++;
|
$daEstrarre++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,11 +436,12 @@ class BackupArchive
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
|
$prefisso = $this->prefissoDi($entryName, $prefissi);
|
||||||
|
if ($prefisso === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
|
$relativePath = substr($entryName, strlen($prefisso));
|
||||||
$safePath = $this->safeRelativePath($relativePath);
|
$safePath = $this->safeRelativePath($relativePath);
|
||||||
|
|
||||||
if ($safePath === null) {
|
if ($safePath === null) {
|
||||||
@@ -375,7 +451,8 @@ class BackupArchive
|
|||||||
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
||||||
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
||||||
// volta decompressa, riempirebbe il disco.
|
// volta decompressa, riempirebbe il disco.
|
||||||
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
|
$stat = $zip->statIndex($index);
|
||||||
|
$size = (int) ($stat['size'] ?? 0);
|
||||||
|
|
||||||
if ($size > self::MAX_ENTRY_BYTES) {
|
if ($size > self::MAX_ENTRY_BYTES) {
|
||||||
throw new RuntimeException(sprintf(
|
throw new RuntimeException(sprintf(
|
||||||
@@ -390,7 +467,7 @@ class BackupArchive
|
|||||||
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$destination = $staging . '/' . $safePath;
|
$destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
|
||||||
$directory = dirname($destination);
|
$directory = dirname($destination);
|
||||||
|
|
||||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||||
@@ -409,12 +486,40 @@ class BackupArchive
|
|||||||
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
|
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
|
||||||
}
|
}
|
||||||
|
|
||||||
stream_copy_to_stream($stream, $target);
|
// Il risultato della copia si controlla, ed e' qui che il
|
||||||
|
// ripristino diventa sicuro: prima un disco pieno o un'entry
|
||||||
|
// corrotta lasciavano nello staging un file troncato senza
|
||||||
|
// nessun errore, e quel file finiva al posto del PDF buono.
|
||||||
|
// La @ per lo stesso motivo di addFile(): il warning di PHP
|
||||||
|
// ("CRC error") arriverebbe all'amministratore al posto del
|
||||||
|
// messaggio che dice quale file.
|
||||||
|
$copiati = @stream_copy_to_stream($stream, $target);
|
||||||
fclose($stream);
|
fclose($stream);
|
||||||
fclose($target);
|
$chiuso = fclose($target);
|
||||||
|
|
||||||
|
if ($copiati === false || $copiati !== $size || ! $chiuso) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
|
||||||
|
$safePath,
|
||||||
|
$copiati === false ? '0' : (string) $copiati,
|
||||||
|
$size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
|
||||||
|
// lettura arriva in fondo e come warning: ricalcolarlo sul file
|
||||||
|
// scritto costa una rilettura e non dipende da come libzip
|
||||||
|
// segnala l'errore.
|
||||||
|
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
|
||||||
|
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
|
||||||
|
$estrattiPiantine++;
|
||||||
|
} else {
|
||||||
$extracted++;
|
$extracted++;
|
||||||
$onProgress && $onProgress($extracted, $daEstrarre);
|
}
|
||||||
|
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@@ -425,63 +530,180 @@ class BackupArchive
|
|||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
|
return [
|
||||||
|
'xml' => $xml,
|
||||||
|
'piantine' => $piantine,
|
||||||
|
'formato' => $formatVersion,
|
||||||
|
'staging' => $staging,
|
||||||
|
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
|
||||||
|
'files' => $extracted,
|
||||||
|
'file_piantine' => $estrattiPiantine,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
|
||||||
|
private function prefissoDi(string $entryName, array $prefissi): ?string
|
||||||
|
{
|
||||||
|
foreach (array_keys($prefissi) as $prefisso) {
|
||||||
|
if (str_starts_with($entryName, $prefisso)) {
|
||||||
|
return $prefisso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sostituisce il contenuto del disco `public` con quello estratto.
|
* Mette i file estratti al posto di quelli in uso, tenendo da parte i
|
||||||
|
* vecchi finche' il ripristino non e' confermato.
|
||||||
*
|
*
|
||||||
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
|
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
|
||||||
* righe del database, che il ripristino ha appena riscritto per intero.
|
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||||
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||||
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
||||||
*
|
*
|
||||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
* Prima si svuotava il disco e poi si ricopiava dallo staging file per
|
||||||
|
* file: un errore a meta' (disco pieno, permessi) lasciava il database
|
||||||
|
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
|
||||||
|
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
|
||||||
|
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
|
||||||
|
* di spazio, e ognuno annotato in un registro che annullaScambio()
|
||||||
|
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
|
||||||
|
* e si solleva: il chiamante annulla la transazione e l'installazione
|
||||||
|
* resta com'era.
|
||||||
|
*
|
||||||
|
* Si spostano le voci e non la cartella radice: a quella del disco
|
||||||
|
* `public` punta il symlink public/storage.
|
||||||
|
*
|
||||||
|
* @param array $estratto Il risultato di extract().
|
||||||
|
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
|
||||||
|
* Da passare a confermaScambio() a database confermato, o ad
|
||||||
|
* annullaScambio() se qualcosa fallisce dopo.
|
||||||
*/
|
*/
|
||||||
public function restoreFiles(string $staging, ?callable $onProgress = null): int
|
public function scambiaFile(array $estratto): array
|
||||||
{
|
{
|
||||||
$disk = Storage::disk('public');
|
// Fuori da backup-restore/ di proposito: quella cartella la ripulisce
|
||||||
|
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
|
||||||
|
// stesso fallisse, resterebbero gli unici file buoni.
|
||||||
|
$scambio = [
|
||||||
|
'file' => $estratto['files'] + $estratto['file_piantine'],
|
||||||
|
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
|
||||||
|
'spostamenti' => [],
|
||||||
|
];
|
||||||
|
|
||||||
$this->svuotaDiscoPubblico();
|
try {
|
||||||
|
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
|
||||||
|
$this->preparaCartella($radice);
|
||||||
|
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
|
||||||
|
|
||||||
if (! is_dir($staging)) {
|
foreach ($this->vociDi($radice) as $voce) {
|
||||||
return 0;
|
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
|
||||||
}
|
}
|
||||||
|
|
||||||
$restored = 0;
|
// Una sottocartella assente nello staging vuol dire "nessun
|
||||||
$totale = iterator_count(new \RecursiveIteratorIterator(
|
// file": la radice resta vuota, come nell'installazione
|
||||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
// salvata.
|
||||||
));
|
$nuovi = $estratto['staging'] . '/' . $nome;
|
||||||
$onProgress && $onProgress(0, $totale);
|
foreach ($this->vociDi($nuovi) as $voce) {
|
||||||
|
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->annullaScambio($scambio, $e);
|
||||||
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
throw $e;
|
||||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
}
|
||||||
|
|
||||||
|
return $scambio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
|
||||||
|
public function confermaScambio(array $scambio): void
|
||||||
|
{
|
||||||
|
$this->deleteDirectory($scambio['accantonati']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
|
||||||
|
* spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
|
||||||
|
* posto.
|
||||||
|
*
|
||||||
|
* Se anche questo fallisce non si cancella niente: i file precedenti
|
||||||
|
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
|
||||||
|
* si possono rimettere a mano invece di darli per persi.
|
||||||
|
*/
|
||||||
|
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
|
||||||
|
if (! @rename($a, $da)) {
|
||||||
|
throw new RuntimeException('impossibile rimettere "' . $da . '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
|
||||||
|
. 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
|
||||||
|
. 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
|
||||||
|
0,
|
||||||
|
$causa ?? $e
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ($iterator as $file) {
|
|
||||||
/** @var \SplFileInfo $file */
|
|
||||||
if (! $file->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
|
$this->deleteDirectory($scambio['accantonati']);
|
||||||
|
|
||||||
$handle = fopen($file->getPathname(), 'rb');
|
|
||||||
if ($handle === false) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$disk->writeStream($relativePath, $handle);
|
/**
|
||||||
|
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
|
||||||
|
* => cartella vera.
|
||||||
|
*
|
||||||
|
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
|
||||||
|
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
|
||||||
|
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
|
||||||
|
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
|
||||||
|
* riga lo cita, e' la mappa della citta'.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function radiciDaSostituire(int $formato): array
|
||||||
|
{
|
||||||
|
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
|
||||||
|
|
||||||
if (is_resource($handle)) {
|
if ($formato >= 2) {
|
||||||
fclose($handle);
|
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
|
||||||
}
|
}
|
||||||
|
|
||||||
$restored++;
|
return $radici;
|
||||||
$onProgress && $onProgress($restored, $totale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $restored;
|
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
|
||||||
|
private function vociDi(string $cartella): array
|
||||||
|
{
|
||||||
|
if (! is_dir($cartella)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preparaCartella(string $cartella): void
|
||||||
|
{
|
||||||
|
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
||||||
|
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sposta(string $da, string $a, array &$scambio): void
|
||||||
|
{
|
||||||
|
if (! @rename($da, $a)) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
|
||||||
|
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
|
||||||
|
basename($da)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$scambio['spostamenti'][] = [$da, $a];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,18 @@ class BackupOperationState
|
|||||||
public const TIPO_RESTORE = 'restore';
|
public const TIPO_RESTORE = 'restore';
|
||||||
public const TIPO_RESET = 'reset';
|
public const TIPO_RESET = 'reset';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gli import non sono operazioni di backup, ma occupano lo stesso posto:
|
||||||
|
* l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file
|
||||||
|
* e righe dei territori. Uno dei due in parallelo a un ripristino (o a un
|
||||||
|
* backup che sta copiando quegli stessi file) produce uno stato che nessuno
|
||||||
|
* ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha
|
||||||
|
* TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo
|
||||||
|
* l'occupazione del posto.
|
||||||
|
*/
|
||||||
|
public const TIPO_IMPORT_PDF = 'import-pdf';
|
||||||
|
public const TIPO_IMPORT_XML = 'import-xml';
|
||||||
|
|
||||||
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
|
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
|
||||||
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
||||||
|
|
||||||
@@ -48,6 +61,16 @@ class BackupOperationState
|
|||||||
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
||||||
private const TTL_CORRENTE = 3600;
|
private const TTL_CORRENTE = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutex attorno a "il posto e' libero? allora lo prendo".
|
||||||
|
*
|
||||||
|
* corrente() seguito da initialize() sono due passi: due richieste che
|
||||||
|
* arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono
|
||||||
|
* — la seconda sovrascrive il puntatore della prima, e i due lavori girano
|
||||||
|
* in parallelo proprio come il posto unico doveva impedire.
|
||||||
|
*/
|
||||||
|
private const CHIAVE_AVVIO = 'backup-op:avvio';
|
||||||
|
|
||||||
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
||||||
{
|
{
|
||||||
$state = [
|
$state = [
|
||||||
@@ -89,16 +112,78 @@ class BackupOperationState
|
|||||||
$stato = $this->get($id);
|
$stato = $this->get($id);
|
||||||
|
|
||||||
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
|
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
|
||||||
// e' libero, e il puntatore va tolto di mezzo.
|
// e' libero. Il puntatore pero' qui non si tocca: corrente() la
|
||||||
|
// chiamano anche le pagine che si aggiornano, fuori dal mutex di
|
||||||
|
// occupa(), e cancellarlo poteva togliere quello che un'altra
|
||||||
|
// richiesta aveva appena scritto per un'operazione nuova. Lo
|
||||||
|
// sovrascrive initialize() al prossimo avvio.
|
||||||
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
|
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
|
||||||
Cache::forget(self::CHIAVE_CORRENTE);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prende il posto unico se e' libero, in un passo solo.
|
||||||
|
*
|
||||||
|
* Restituisce null se il posto e' stato preso (e l'operazione inizializzata
|
||||||
|
* come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa.
|
||||||
|
* Tutte le porte d'ingresso passano da qui proprio per il mutex: un
|
||||||
|
* controllo con corrente() e un initialize() separati lasciano aperta la
|
||||||
|
* finestra fra i due.
|
||||||
|
*
|
||||||
|
* $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a
|
||||||
|
* meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il
|
||||||
|
* posto resterebbe occupato per l'ora intera del TTL ordinario.
|
||||||
|
*/
|
||||||
|
public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) {
|
||||||
|
$inCorso = $this->corrente();
|
||||||
|
|
||||||
|
if ($inCorso !== null) {
|
||||||
|
return $inCorso;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initialize($id, $tipo, $fase);
|
||||||
|
|
||||||
|
if ($ttlPosto !== null) {
|
||||||
|
Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
// Qualcun altro sta prendendo il posto proprio adesso: il mutex
|
||||||
|
// copre pochi millisecondi, quindi cinque secondi d'attesa vogliono
|
||||||
|
// dire che il posto non e' disponibile, non che va forzato.
|
||||||
|
return $this->corrente() ?? 'avvio-in-corso';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo
|
||||||
|
* blocca, perche' l'operazione in corso puo' essere stata avviata da un
|
||||||
|
* altro amministratore in un'altra zona della pagina.
|
||||||
|
*/
|
||||||
|
public function messaggioOccupato(string $idInCorso): string
|
||||||
|
{
|
||||||
|
$tipo = $this->get($idInCorso)['tipo'] ?? null;
|
||||||
|
|
||||||
|
$cosa = match ($tipo) {
|
||||||
|
self::TIPO_BACKUP => ' (un backup)',
|
||||||
|
self::TIPO_RESTORE => ' (un ripristino)',
|
||||||
|
self::TIPO_RESET => ' (un azzeramento)',
|
||||||
|
self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)',
|
||||||
|
self::TIPO_IMPORT_XML => ' (un import XML)',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.';
|
||||||
|
}
|
||||||
|
|
||||||
public function get(string $id): ?array
|
public function get(string $id): ?array
|
||||||
{
|
{
|
||||||
return Cache::get($this->key($id));
|
return Cache::get($this->key($id));
|
||||||
@@ -213,9 +298,18 @@ class BackupOperationState
|
|||||||
*/
|
*/
|
||||||
private function liberaCorrente(string $id): void
|
private function liberaCorrente(string $id): void
|
||||||
{
|
{
|
||||||
|
// Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi
|
||||||
|
// un'altra richiesta poteva prendere il posto, e il forget le toglieva
|
||||||
|
// il puntatore. Se il mutex non arriva si lascia stare: il puntatore a
|
||||||
|
// un'operazione chiusa vale già come posto libero (vedi corrente()).
|
||||||
|
try {
|
||||||
|
Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id) {
|
||||||
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
|
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
|
||||||
Cache::forget(self::CHIAVE_CORRENTE);
|
Cache::forget(self::CHIAVE_CORRENTE);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
121
app/Services/ConfigurazionePosta.php
Normal file
121
app/Services/ConfigurazionePosta.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Mail\MailManager;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il server di posta configurato dall'applicazione (Impostazioni → Posta).
|
||||||
|
*
|
||||||
|
* Se in Impostazioni c'è un server, vale quello; altrimenti resta quello del
|
||||||
|
* .env, come prima. La configurazione si applica quando Laravel crea il
|
||||||
|
* sistema di posta (AppServiceProvider) e prima di ogni job della coda,
|
||||||
|
* perché il worker vive a lungo e avrebbe tenuto quella del primo invio.
|
||||||
|
*/
|
||||||
|
class ConfigurazionePosta
|
||||||
|
{
|
||||||
|
public const CIFRATURA_AUTOMATICA = 'auto';
|
||||||
|
|
||||||
|
public const CIFRATURA_SSL = 'ssl';
|
||||||
|
|
||||||
|
public const CIFRATURA_NESSUNA = 'nessuna';
|
||||||
|
|
||||||
|
public const CIFRATURE = [
|
||||||
|
self::CIFRATURA_AUTOMATICA => 'Automatica (STARTTLS se il server la offre, di solito porta 587)',
|
||||||
|
self::CIFRATURA_SSL => 'SSL/TLS dall\'inizio (di solito porta 465)',
|
||||||
|
self::CIFRATURA_NESSUNA => 'Nessuna (solo per server interni)',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** C'è un server di posta configurato dall'applicazione. */
|
||||||
|
public function configurataNellApp(): bool
|
||||||
|
{
|
||||||
|
return trim((string) Setting::instance()->mail_host) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le email partono davvero: server dall'app, oppure un mailer del .env che
|
||||||
|
* non sia "log" o "array" (che le scrivono nel log o le buttano).
|
||||||
|
*/
|
||||||
|
public function funzionante(): bool
|
||||||
|
{
|
||||||
|
if ($this->configurataNellApp()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailer = (string) config('mail.default');
|
||||||
|
|
||||||
|
return ! in_array($mailer, ['log', 'array'], true)
|
||||||
|
&& ($mailer !== 'smtp' || trim((string) config('mail.mailers.smtp.host')) !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Porta la configurazione dell'app nel sistema di posta di Laravel. */
|
||||||
|
public function applica(?MailManager $manager = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$impostazioni = Setting::instance();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Database non ancora pronto (prima installazione, migrazioni in
|
||||||
|
// corso): resta il .env.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) $impostazioni->mail_host) === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$password = $impostazioni->mail_password;
|
||||||
|
} catch (DecryptException) {
|
||||||
|
// Cifrata con un'altra APP_KEY: meglio tentare senza password (e
|
||||||
|
// ricevere dal server un errore che dice "autenticazione") che
|
||||||
|
// far cadere ogni pagina che tocca la posta.
|
||||||
|
$password = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cifratura = $impostazioni->mail_cifratura ?: self::CIFRATURA_AUTOMATICA;
|
||||||
|
|
||||||
|
config([
|
||||||
|
'mail.default' => 'smtp',
|
||||||
|
'mail.mailers.smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => $cifratura === self::CIFRATURA_SSL ? 'smtps' : 'smtp',
|
||||||
|
'host' => $impostazioni->mail_host,
|
||||||
|
'port' => $impostazioni->mail_port ?: ($cifratura === self::CIFRATURA_SSL ? 465 : 587),
|
||||||
|
'username' => $impostazioni->mail_username ?: null,
|
||||||
|
'password' => $password ?: null,
|
||||||
|
'timeout' => 20,
|
||||||
|
'local_domain' => config('mail.mailers.smtp.local_domain'),
|
||||||
|
// Senza, Symfony prova STARTTLS da solo appena il server lo offre.
|
||||||
|
'auto_tls' => $cifratura !== self::CIFRATURA_NESSUNA,
|
||||||
|
],
|
||||||
|
'mail.from.address' => $impostazioni->mail_from_address ?: config('mail.from.address'),
|
||||||
|
'mail.from.name' => $impostazioni->mail_from_name ?: ($impostazioni->congregazione_nome ?: config('mail.from.name')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Un mailer già creato tiene la configurazione con cui è nato.
|
||||||
|
($manager ?? app('mail.manager'))->purge('smtp');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invia l'email di prova. Lancia RuntimeException con un messaggio
|
||||||
|
* leggibile se il server rifiuta o non risponde.
|
||||||
|
*/
|
||||||
|
public function inviaProva(string $destinatario): void
|
||||||
|
{
|
||||||
|
$this->applica();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::raw(
|
||||||
|
"Questa è un'email di prova di TerManager2.\n\nSe la stai leggendo, il server di posta è configurato correttamente: "
|
||||||
|
. 'gli utenti riceveranno le email per confermare il loro indirizzo.',
|
||||||
|
fn ($messaggio) => $messaggio->to($destinatario)->subject('TerManager2 — email di prova'),
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new RuntimeException('Il server di posta ha rifiutato l\'invio: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ($immagine === null) {
|
||||||
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
|
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.');
|
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) {
|
if ($dimensioni === false) {
|
||||||
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
|
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
|
||||||
}
|
}
|
||||||
@@ -92,7 +101,7 @@ class PiantinaPdfService
|
|||||||
($h - $imgH) / 2,
|
($h - $imgH) / 2,
|
||||||
$imgW,
|
$imgW,
|
||||||
$imgH,
|
$imgH,
|
||||||
$this->tipoImmagine($mappa->image_path)
|
$this->tipoImmagine($immagine)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $pdf->Output('S');
|
return $pdf->Output('S');
|
||||||
@@ -179,13 +188,26 @@ class PiantinaPdfService
|
|||||||
*/
|
*/
|
||||||
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
|
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');
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
$this->archiviaCorrente($territorio, $origine);
|
|
||||||
|
|
||||||
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
|
$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([
|
$territorio->update([
|
||||||
'pdf_path' => $nuovoPath,
|
'pdf_path' => $nuovoPath,
|
||||||
@@ -235,8 +257,11 @@ class PiantinaPdfService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sposta il PDF attuale del territorio (se esiste) nello storico.
|
* Sposta il PDF attuale del territorio (se esiste) nello storico.
|
||||||
|
*
|
||||||
|
* Pubblica perché la usa anche l'import dei PDF da cartella: chi
|
||||||
|
* sostituisce un PDF lo archivia sempre allo stesso modo.
|
||||||
*/
|
*/
|
||||||
private function archiviaCorrente(Territorio $territorio, string $origine): void
|
public function archiviaCorrente(Territorio $territorio, string $origine): void
|
||||||
{
|
{
|
||||||
$disk = Storage::disk('public');
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Piantine;
|
namespace App\Services\Piantine;
|
||||||
|
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -14,6 +15,11 @@ use RuntimeException;
|
|||||||
*/
|
*/
|
||||||
class PiantinaRenderer
|
class PiantinaRenderer
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Rendering e archiviazione come immagine della piantina.
|
||||||
|
*
|
||||||
|
* @return string il percorso sul disco pubblico
|
||||||
|
*/
|
||||||
public function render(TerritorioMappa $mappa): string
|
public function render(TerritorioMappa $mappa): string
|
||||||
{
|
{
|
||||||
$geometry = $mappa->geometria();
|
$geometry = $mappa->geometria();
|
||||||
@@ -22,8 +28,22 @@ class PiantinaRenderer
|
|||||||
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
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';
|
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
|
||||||
|
|
||||||
|
try {
|
||||||
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
||||||
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
||||||
'geometry' => $geometry,
|
'geometry' => $geometry,
|
||||||
@@ -35,6 +55,11 @@ class PiantinaRenderer
|
|||||||
'format' => $formato,
|
'format' => $formato,
|
||||||
'quality' => (int) config('piantine.qualita'),
|
'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()) {
|
if (! $response->successful()) {
|
||||||
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
|
$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.');
|
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
|
return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
|
||||||
// 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);
|
* 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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mappa->image_path && $mappa->image_path !== $path) {
|
||||||
|
$disco->delete($mappa->image_path);
|
||||||
|
}
|
||||||
|
|
||||||
$mappa->update([
|
$mappa->update([
|
||||||
'image_path' => $path,
|
'image_path' => $path,
|
||||||
@@ -65,11 +103,6 @@ class PiantinaRenderer
|
|||||||
return $path;
|
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. */
|
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
|
||||||
private function immagineValida(string $dati, string $formato): bool
|
private function immagineValida(string $dati, string $formato): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ use RuntimeException;
|
|||||||
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
||||||
* restano al loro posto.
|
* restano al loro posto.
|
||||||
*
|
*
|
||||||
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
|
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
|
||||||
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
|
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
|
||||||
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
|
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
|
||||||
* KML originale — che quindi conviene conservare fuori di qui.
|
* contiene, e ripristinandolo l'area in uso resta com'e'.
|
||||||
*/
|
*/
|
||||||
class PiantineArea
|
class PiantineArea
|
||||||
{
|
{
|
||||||
|
|||||||
183
app/Services/Piantine/PiantineBackup.php
Normal file
183
app/Services/Piantine/PiantineBackup.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Piantine;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le tabelle della sotto-app delle piantine dentro il backup completo:
|
||||||
|
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
|
||||||
|
* (`territorio_pdf_versioni`).
|
||||||
|
*
|
||||||
|
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
|
||||||
|
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
|
||||||
|
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
|
||||||
|
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
|
||||||
|
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
|
||||||
|
*
|
||||||
|
* Il collegamento al territorio si salva come *numero*, non solo come id:
|
||||||
|
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
|
||||||
|
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
|
||||||
|
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
|
||||||
|
* unico.
|
||||||
|
*/
|
||||||
|
class PiantineBackup
|
||||||
|
{
|
||||||
|
public const TABELLA_MAPPE = 'territorio_mappe';
|
||||||
|
|
||||||
|
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
|
||||||
|
* del territorio collegato.
|
||||||
|
*
|
||||||
|
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
|
||||||
|
* compresi, da reinserire identica — non i valori passati per i cast.
|
||||||
|
*
|
||||||
|
* @return array<string, list<array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
public function esporta(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE),
|
||||||
|
self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlla la struttura di piantine.json prima che il ripristino tocchi
|
||||||
|
* qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le
|
||||||
|
* piantine non le conteneva.
|
||||||
|
*/
|
||||||
|
public function valida(?array $dati): void
|
||||||
|
{
|
||||||
|
if ($dati === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) {
|
||||||
|
if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) {
|
||||||
|
throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($dati[$tabella] as $posizione => $riga) {
|
||||||
|
if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.',
|
||||||
|
$posizione + 1,
|
||||||
|
$tabella
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sostituisce il contenuto delle due tabelle con quello del backup.
|
||||||
|
*
|
||||||
|
* Va chiamato dentro la transazione del ripristino e *dopo* l'import XML:
|
||||||
|
* servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e
|
||||||
|
* non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe'
|
||||||
|
* chiuderebbe a meta' la transazione che deve poter annullare tutto.
|
||||||
|
*
|
||||||
|
* Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto.
|
||||||
|
* Tenere le righe dell'installazione corrente vorrebbe dire piantine che
|
||||||
|
* citano immagini del disco appena sostituito — file che non ci sono piu'
|
||||||
|
* — accanto ai dati di un'altra epoca: proprio lo stato misto che il
|
||||||
|
* ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la
|
||||||
|
* cascata sulla cancellazione dei territori.
|
||||||
|
*
|
||||||
|
* @return array{mappe: int, versioni: int, issues: list<array{entity: string, legacy_id: string, reason: string}>}
|
||||||
|
*/
|
||||||
|
public function ripristina(?array $dati): array
|
||||||
|
{
|
||||||
|
DB::table(self::TABELLA_VERSIONI)->delete();
|
||||||
|
DB::table(self::TABELLA_MAPPE)->delete();
|
||||||
|
|
||||||
|
$esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []];
|
||||||
|
|
||||||
|
if ($dati === null) {
|
||||||
|
return $esito;
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTrashed implicito: il query builder non conosce i soft delete, e
|
||||||
|
// una piantina collegata a un territorio nel cestino deve restarlo.
|
||||||
|
$territori = DB::table('territori')->pluck('id', 'numero')
|
||||||
|
->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE));
|
||||||
|
$collegati = [];
|
||||||
|
|
||||||
|
foreach ($dati[self::TABELLA_MAPPE] as $riga) {
|
||||||
|
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
|
||||||
|
$territorioId = null;
|
||||||
|
|
||||||
|
if (($riga['territorio_id'] ?? null) !== null) {
|
||||||
|
$territorioId = $territori[$numero] ?? null;
|
||||||
|
|
||||||
|
// territorio_id e' unico: due piantine sullo stesso numero
|
||||||
|
// (possibile solo se il backup aveva territori con numero
|
||||||
|
// ripetuto, che l'import scarta) non possono restare collegate
|
||||||
|
// entrambe.
|
||||||
|
if ($territorioId !== null && isset($collegati[$territorioId])) {
|
||||||
|
$territorioId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($territorioId === null) {
|
||||||
|
// Il disegno non si butta: resta scollegato, con il numero
|
||||||
|
// come etichetta per riconoscerlo.
|
||||||
|
if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') {
|
||||||
|
$riga['nome'] = 'Terr. ' . $numero;
|
||||||
|
}
|
||||||
|
$esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata');
|
||||||
|
} else {
|
||||||
|
$collegati[$territorioId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$riga['territorio_id'] = $territorioId;
|
||||||
|
DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe));
|
||||||
|
$esito['mappe']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI));
|
||||||
|
|
||||||
|
foreach ($dati[self::TABELLA_VERSIONI] as $riga) {
|
||||||
|
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
|
||||||
|
$territorioId = $territori[$numero] ?? null;
|
||||||
|
|
||||||
|
// Una versione senza territorio non e' ripristinabile da nessuna
|
||||||
|
// schermata (e la colonna non ammette NULL): si scarta, e lo si dice.
|
||||||
|
if ($territorioId === null) {
|
||||||
|
$esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$riga['territorio_id'] = $territorioId;
|
||||||
|
DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni));
|
||||||
|
$esito['versioni']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $esito;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function righe(string $tabella): array
|
||||||
|
{
|
||||||
|
return DB::table($tabella)
|
||||||
|
->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id')
|
||||||
|
->orderBy($tabella . '.id')
|
||||||
|
->get([$tabella . '.*', 'territori.numero as territorio_numero'])
|
||||||
|
->map(fn (object $riga) => (array) $riga)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */
|
||||||
|
private function problema(string $entita, array $riga, string $motivo): array
|
||||||
|
{
|
||||||
|
return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo];
|
||||||
|
}
|
||||||
|
}
|
||||||
237
app/Services/Piantine/PubblicazionePiantina.php
Normal file
237
app/Services/Piantine/PubblicazionePiantina.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?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'],
|
||||||
|
// Il PDF appena messo in vigore: finché il territorio usa questo
|
||||||
|
// file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()).
|
||||||
|
'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path,
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
329
app/Services/Registro/ModuloS13.php
Normal file
329
app/Services/Registro/ModuloS13.php
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registro;
|
||||||
|
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use setasign\Fpdi\Fpdi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del territorio")
|
||||||
|
* compilato per un anno di servizio.
|
||||||
|
*
|
||||||
|
* Il PDF è il modulo originale (resources/pdf/S-13_I.pdf) importato com'è, con
|
||||||
|
* i dati scritti sopra alle coordinate della sua griglia: il foglio stampato è
|
||||||
|
* quello ufficiale, non una sua ricostruzione. La matrice è salvata in PDF 1.4
|
||||||
|
* senza object stream, l'unico formato che il parser gratuito di FPDI legge;
|
||||||
|
* se il modulo viene aggiornato va riconvertito allo stesso modo (qpdf
|
||||||
|
* --object-streams=disable --force-version=1.4, o pikepdf).
|
||||||
|
*
|
||||||
|
* Le regole di compilazione, come sul cartaceo:
|
||||||
|
*
|
||||||
|
* - Una riga per ogni territorio attivo, anche se nell'anno non è mai uscito:
|
||||||
|
* il registro serve proprio a vedere quali territori restano fermi.
|
||||||
|
*
|
||||||
|
* - Quattro gruppi "Assegnato a" per riga. Alla quinta assegnazione la riga
|
||||||
|
* si cancella: la data di rientro della quarta passa nella colonna
|
||||||
|
* "Restituito l'ultima volta il" e la quinta va nel primo gruppo. Sul foglio
|
||||||
|
* c'è quindi solo il ciclo in corso; lo storico completo resta nel database.
|
||||||
|
*
|
||||||
|
* - Nel primo ciclo dell'anno il riporto è l'ultima restituzione avvenuta
|
||||||
|
* prima dell'inizio dell'anno, come chiede la nota a piè del modulo.
|
||||||
|
*
|
||||||
|
* - Un'assegnazione a cavallo d'anno (uscita prima dell'inizio, rientrata
|
||||||
|
* dopo o ancora fuori) compare su entrambi i fogli: su quello vecchio senza
|
||||||
|
* data di rientro, perché a fine anno il territorio era ancora fuori; su
|
||||||
|
* quello nuovo nel primo gruppo, con la data di assegnazione originale e
|
||||||
|
* quella di rientro.
|
||||||
|
*/
|
||||||
|
class ModuloS13
|
||||||
|
{
|
||||||
|
/** Blocchi-territorio per foglio, come sul modulo. */
|
||||||
|
public const RIGHE_PER_PAGINA = 20;
|
||||||
|
|
||||||
|
/** Gruppi "Assegnato a" per riga. */
|
||||||
|
public const ASSEGNAZIONI_PER_RIGA = 4;
|
||||||
|
|
||||||
|
// Geometria della griglia in mm, misurata sulla matrice. Le y sono il
|
||||||
|
// bordo superiore delle linee: quelle spesse separano i territori, quella
|
||||||
|
// sottile divide la riga dei nomi da quella delle date.
|
||||||
|
|
||||||
|
/** Linea spessa sotto l'intestazione, cioè sopra il primo territorio. */
|
||||||
|
private const Y_PRIMA_RIGA = 49.28;
|
||||||
|
|
||||||
|
/** Distanza fra due territori. */
|
||||||
|
private const PASSO_RIGA = 11.042;
|
||||||
|
|
||||||
|
/** Dalla linea spessa alla sottile che separa nomi e date. */
|
||||||
|
private const Y_META_RIGA = 5.68;
|
||||||
|
|
||||||
|
/** Spessore delle linee spesse (1,56pt) e di quelle sottili (0,48pt). */
|
||||||
|
private const LINEA_SPESSA = 0.55;
|
||||||
|
private const LINEA_SOTTILE = 0.17;
|
||||||
|
|
||||||
|
/** Colonne fisse: [x iniziale, x finale] al netto dei bordi. */
|
||||||
|
private const COL_NUMERO = [12.14, 24.06];
|
||||||
|
private const COL_RIPORTO = [24.23, 46.11];
|
||||||
|
|
||||||
|
/** Inizio di ciascun gruppo "Assegnato a", divisione interna e fine. */
|
||||||
|
private const GRUPPI = [
|
||||||
|
[46.62, 65.41, 84.03],
|
||||||
|
[84.54, 103.05, 121.74],
|
||||||
|
[122.25, 140.69, 159.38],
|
||||||
|
[159.89, 178.40, 196.74],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Riga "Anno di servizio: ____": estremi della linea e sua quota. */
|
||||||
|
private const ANNO = [49.96, 72.74, 34.38];
|
||||||
|
|
||||||
|
/** Corpo massimo e minimo dei nomi: sotto il minimo si tronca. */
|
||||||
|
private const CORPO_NOME_MAX = 8.0;
|
||||||
|
private const CORPO_NOME_MIN = 5.0;
|
||||||
|
|
||||||
|
/** Il PDF compilato, come stringa binaria. */
|
||||||
|
public function genera(AnnoTeocratico $anno): string
|
||||||
|
{
|
||||||
|
$pdf = new Fpdi();
|
||||||
|
$pdf->SetAutoPageBreak(false);
|
||||||
|
$pdf->SetMargins(0, 0, 0);
|
||||||
|
$pdf->setSourceFile(resource_path('pdf/S-13_I.pdf'));
|
||||||
|
$modulo = $pdf->importPage(1);
|
||||||
|
$pdf->SetTextColor(0, 0, 0);
|
||||||
|
|
||||||
|
foreach ($this->pagine($this->righe($anno)) as $righe) {
|
||||||
|
$pdf->AddPage('P', 'A4');
|
||||||
|
$pdf->useTemplate($modulo);
|
||||||
|
|
||||||
|
[$x0, $x1, $y] = self::ANNO;
|
||||||
|
$pdf->SetFont('Helvetica', '', 10);
|
||||||
|
$pdf->SetXY($x0, $y - 4.6);
|
||||||
|
$pdf->Cell($x1 - $x0, 4.4, $this->latin($anno->label), 0, 0, 'C');
|
||||||
|
|
||||||
|
foreach ($righe as $indice => $riga) {
|
||||||
|
$this->scriviRiga($pdf, $indice, $riga);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdf->Output('S');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Righe del modulo: una per territorio attivo, in ordine naturale di
|
||||||
|
* numero ("1", "1 A", "2", "10").
|
||||||
|
*
|
||||||
|
* Ogni riga: ['numero' => string, 'riporto' => string, 'assegnazioni' =>
|
||||||
|
* quattro celle ['nome', 'assegnato_il', 'restituito_il'], le libere vuote].
|
||||||
|
*
|
||||||
|
* @return Collection<int, array>
|
||||||
|
*/
|
||||||
|
public function righe(AnnoTeocratico $anno): Collection
|
||||||
|
{
|
||||||
|
$inizio = $anno->start_date->toDateString();
|
||||||
|
$fine = $anno->end_date->copy()->endOfDay();
|
||||||
|
|
||||||
|
$territori = Territorio::where('attivo', true)->get(['id', 'numero']);
|
||||||
|
|
||||||
|
// Le assegnazioni del foglio, scelte per data e non per
|
||||||
|
// anno_teocratico_id (che una correzione a mano o un import possono
|
||||||
|
// lasciare incoerente): quelle uscite nell'anno, più quelle uscite
|
||||||
|
// prima e ancora fuori al suo inizio. Confronti con stringhe Y-m-d e
|
||||||
|
// solo con < e >=: valgono sia sulle colonne DATE di MariaDB sia sulle
|
||||||
|
// "Y-m-d H:i:s" di SQLite, e usano l'indice (whereDate non lo userebbe).
|
||||||
|
$assegnazioni = Assegnazione::with('proclamatore')
|
||||||
|
->whereIn('territorio_id', $territori->pluck('id'))
|
||||||
|
->where('assigned_at', '<', $anno->end_date->copy()->addDay()->toDateString())
|
||||||
|
->where(fn ($q) => $q
|
||||||
|
->where('assigned_at', '>=', $inizio)
|
||||||
|
->orWhereNull('returned_at')
|
||||||
|
->orWhere('returned_at', '>=', $inizio))
|
||||||
|
->orderBy('assigned_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->groupBy('territorio_id');
|
||||||
|
|
||||||
|
// Riporto del primo ciclo: l'ultima restituzione prima dell'anno.
|
||||||
|
$rientriPrecedenti = Assegnazione::query()
|
||||||
|
->whereIn('territorio_id', $territori->pluck('id'))
|
||||||
|
->where('returned_at', '<', $inizio)
|
||||||
|
->groupBy('territorio_id')
|
||||||
|
->selectRaw('territorio_id, MAX(returned_at) as ultimo')
|
||||||
|
->pluck('ultimo', 'territorio_id');
|
||||||
|
|
||||||
|
return $territori
|
||||||
|
->map(function (Territorio $territorio) use ($assegnazioni, $rientriPrecedenti, $fine) {
|
||||||
|
$gruppo = ($assegnazioni->get($territorio->id) ?? collect())->values();
|
||||||
|
|
||||||
|
$inizioCiclo = $gruppo->isEmpty()
|
||||||
|
? 0
|
||||||
|
: intdiv($gruppo->count() - 1, self::ASSEGNAZIONI_PER_RIGA) * self::ASSEGNAZIONI_PER_RIGA;
|
||||||
|
|
||||||
|
$riporto = $inizioCiclo === 0
|
||||||
|
? $this->data($rientriPrecedenti->get($territorio->id))
|
||||||
|
: $this->rientroEntro($gruppo->get($inizioCiclo - 1), $fine);
|
||||||
|
|
||||||
|
$celle = $gruppo
|
||||||
|
->slice($inizioCiclo, self::ASSEGNAZIONI_PER_RIGA)
|
||||||
|
->map(fn (Assegnazione $a) => [
|
||||||
|
'nome' => $this->nomeBreve($a->proclamatore),
|
||||||
|
'assegnato_il' => $this->data($a->assigned_at),
|
||||||
|
'restituito_il' => $this->rientroEntro($a, $fine),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'numero' => (string) $territorio->numero,
|
||||||
|
'riporto' => $riporto,
|
||||||
|
'assegnazioni' => $this->completaCelle($celle),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(fn (array $a, array $b) => strnatcasecmp($a['numero'], $b['numero']))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fogli da venti righe, l'ultimo completato con righe vuote: sul modulo le
|
||||||
|
* righe libere restano disponibili per la compilazione a mano. Senza
|
||||||
|
* territori esce comunque un foglio, in bianco.
|
||||||
|
*
|
||||||
|
* @return Collection<int, Collection<int, array>>
|
||||||
|
*/
|
||||||
|
public function pagine(Collection $righe): Collection
|
||||||
|
{
|
||||||
|
$vuota = ['numero' => '', 'riporto' => '', 'assegnazioni' => $this->completaCelle([])];
|
||||||
|
|
||||||
|
$pagine = $righe->isEmpty()
|
||||||
|
? collect([collect()])
|
||||||
|
: $righe->chunk(self::RIGHE_PER_PAGINA)->map->values()->values();
|
||||||
|
|
||||||
|
return $pagine->map(fn (Collection $pagina) => collect(
|
||||||
|
array_pad($pagina->all(), self::RIGHE_PER_PAGINA, $vuota)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scriviRiga(Fpdi $pdf, int $indice, array $riga): void
|
||||||
|
{
|
||||||
|
$alto = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::LINEA_SPESSA;
|
||||||
|
$meta = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::Y_META_RIGA;
|
||||||
|
$basso = self::Y_PRIMA_RIGA + ($indice + 1) * self::PASSO_RIGA;
|
||||||
|
|
||||||
|
// Numero e riporto occupano il blocco intero, nomi e date mezzo.
|
||||||
|
$pdf->SetFont('Helvetica', 'B', 10);
|
||||||
|
$this->cella($pdf, self::COL_NUMERO, $alto, $basso, $riga['numero']);
|
||||||
|
|
||||||
|
$pdf->SetFont('Helvetica', '', 8);
|
||||||
|
$this->cella($pdf, self::COL_RIPORTO, $alto, $basso, $riga['riporto']);
|
||||||
|
|
||||||
|
foreach ($riga['assegnazioni'] as $g => $a) {
|
||||||
|
[$inizio, $divisione, $fine] = self::GRUPPI[$g];
|
||||||
|
|
||||||
|
$this->scriviNome($pdf, [$inizio, $fine], $alto, $meta, $a['nome']);
|
||||||
|
|
||||||
|
$pdf->SetFont('Helvetica', '', 7.5);
|
||||||
|
$this->cella($pdf, [$inizio, $divisione], $meta + self::LINEA_SOTTILE, $basso, $a['assegnato_il']);
|
||||||
|
$this->cella($pdf, [$divisione, $fine], $meta + self::LINEA_SOTTILE, $basso, $a['restituito_il']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I nomi stanno su una riga sola: il corpo scende finché il nome entra
|
||||||
|
* nella cella, e sotto il minimo leggibile si tronca con i puntini, che
|
||||||
|
* rendono visibile l'accorciamento invece di sbordare sulla griglia.
|
||||||
|
*/
|
||||||
|
private function scriviNome(Fpdi $pdf, array $colonna, float $alto, float $basso, string $nome): void
|
||||||
|
{
|
||||||
|
if ($nome === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$testo = $this->latin($nome);
|
||||||
|
$spazio = $colonna[1] - $colonna[0] - 1.2;
|
||||||
|
|
||||||
|
$pdf->SetFont('Helvetica', '', self::CORPO_NOME_MAX);
|
||||||
|
$larghezza = $pdf->GetStringWidth($testo);
|
||||||
|
|
||||||
|
if ($larghezza > $spazio) {
|
||||||
|
$corpo = max(self::CORPO_NOME_MIN, floor(self::CORPO_NOME_MAX * $spazio / $larghezza * 10) / 10);
|
||||||
|
$pdf->SetFont('Helvetica', '', $corpo);
|
||||||
|
|
||||||
|
while ($pdf->GetStringWidth($testo) > $spazio && strlen($testo) > 1) {
|
||||||
|
$testo = rtrim(substr(rtrim($testo, "\x85"), 0, -1)) . "\x85";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cella($pdf, $colonna, $alto, $basso, $testo, giaConvertito: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Testo centrato in un rettangolo della griglia. */
|
||||||
|
private function cella(Fpdi $pdf, array $colonna, float $alto, float $basso, string $testo, bool $giaConvertito = false): void
|
||||||
|
{
|
||||||
|
if ($testo === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf->SetXY($colonna[0], $alto);
|
||||||
|
$pdf->Cell(
|
||||||
|
$colonna[1] - $colonna[0],
|
||||||
|
$basso - $alto,
|
||||||
|
$giaConvertito ? $testo : $this->latin($testo),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'C'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data di rientro così come la si leggeva alla fine dell'anno: se il
|
||||||
|
* territorio è rientrato dopo, sul foglio di quell'anno risulta ancora
|
||||||
|
* fuori.
|
||||||
|
*/
|
||||||
|
private function rientroEntro(?Assegnazione $assegnazione, CarbonInterface $fine): string
|
||||||
|
{
|
||||||
|
$rientro = $assegnazione?->returned_at;
|
||||||
|
|
||||||
|
return $rientro && $rientro->lte($fine) ? $this->data($rientro) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cognome e iniziale del nome ("Rossi M."): la cella è stretta, e col nome
|
||||||
|
* per intero i nomi lunghi scendevano a un corpo che su carta non si legge.
|
||||||
|
*/
|
||||||
|
private function nomeBreve(?Proclamatore $proclamatore): string
|
||||||
|
{
|
||||||
|
if (! $proclamatore) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$nome = trim((string) $proclamatore->nome);
|
||||||
|
$iniziale = $nome !== '' ? mb_strtoupper(mb_substr($nome, 0, 1)) . '.' : '';
|
||||||
|
|
||||||
|
return trim(trim((string) $proclamatore->cognome) . ' ' . $iniziale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function data(mixed $valore): string
|
||||||
|
{
|
||||||
|
if (! $valore) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($valore instanceof CarbonInterface ? $valore : \Illuminate\Support\Carbon::parse($valore))
|
||||||
|
->format('d/m/Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completaCelle(array $celle): array
|
||||||
|
{
|
||||||
|
return array_pad($celle, self::ASSEGNAZIONI_PER_RIGA, ['nome' => '', 'assegnato_il' => '', 'restituito_il' => '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FPDF ragiona in windows-1252: gli accenti UTF-8 vanno convertiti. */
|
||||||
|
private function latin(string $testo): string
|
||||||
|
{
|
||||||
|
$convertito = @iconv('UTF-8', 'windows-1252//TRANSLIT', $testo);
|
||||||
|
|
||||||
|
return $convertito !== false ? $convertito : $testo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected TerritorioPdfImportState $stateService,
|
protected TerritorioPdfImportState $stateService,
|
||||||
|
protected BackupOperationState $postoUnico,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
|
/**
|
||||||
|
* Prende il posto unico per questo import, o solleva spiegando chi lo occupa.
|
||||||
|
*
|
||||||
|
* L'import PDF sostituisce file e righe dei territori: in parallelo a un
|
||||||
|
* ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante
|
||||||
|
* un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o
|
||||||
|
* fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto
|
||||||
|
* si libera nel job, a fine lavoro o in failed().
|
||||||
|
*/
|
||||||
|
public function occupaPosto(string $importId): void
|
||||||
|
{
|
||||||
|
$inCorso = $this->postoUnico->occupa($importId, BackupOperationState::TIPO_IMPORT_PDF, 'In coda');
|
||||||
|
|
||||||
|
if ($inCorso !== null) {
|
||||||
|
throw new RuntimeException($this->postoUnico->messaggioOccupato($inCorso));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libera il posto preso da un import che non e' arrivato alla coda. */
|
||||||
|
public function liberaPosto(string $importId, string $motivo): void
|
||||||
|
{
|
||||||
|
$this->postoUnico->markFailed($importId, $motivo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vero per i file che non vanno nemmeno considerati: quelli che non sono
|
||||||
|
* PDF e quelli di sistema o nascosti.
|
||||||
|
*
|
||||||
|
* Una cartella vera non contiene solo PDF: Windows ci lascia Thumbs.db e
|
||||||
|
* desktop.ini, macOS .DS_Store e — dentro gli ZIP — una copia "._nome.pdf"
|
||||||
|
* di ogni file sotto __MACOSX/. Prima uno solo di questi faceva fallire la
|
||||||
|
* validazione dell'intero caricamento; peggio, "._1.pdf" ha l'estensione
|
||||||
|
* giusta e sarebbe finito come mappa del territorio 1.
|
||||||
|
*/
|
||||||
|
public static function daIgnorare(string $percorso): bool
|
||||||
|
{
|
||||||
|
$segmenti = array_values(array_filter(preg_split('#[/\\\\]+#', $percorso) ?: [], fn ($s) => $s !== ''));
|
||||||
|
|
||||||
|
if ($segmenti === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($segmenti as $segmento) {
|
||||||
|
if (str_starts_with($segmento, '.') || strcasecmp($segmento, '__MACOSX') === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "~$" e' il prefisso dei file temporanei di Office.
|
||||||
|
$nome = end($segmenti);
|
||||||
|
|
||||||
|
return str_starts_with($nome, '~$')
|
||||||
|
|| strtolower(pathinfo($nome, PATHINFO_EXTENSION)) !== 'pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mette in coda l'import. Il posto unico deve essere gia' stato preso con
|
||||||
|
* occupaPosto(): se l'invio alla coda fallisce lo si libera qui.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
|
||||||
|
* @param array<int, array{file: string, message: string}> $scartati PDF rifiutati dalla validazione
|
||||||
|
*/
|
||||||
|
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog, array $ignorati = [], array $scartati = []): array
|
||||||
{
|
{
|
||||||
$state = $this->stateService->initialize($importId, count($storedFiles));
|
$state = $this->stateService->initialize($importId, count($storedFiles));
|
||||||
$this->stateService->appendLog($importId, $initialLog);
|
$this->stateService->appendLog($importId, $initialLog);
|
||||||
|
|
||||||
|
if ($ignorati !== []) {
|
||||||
|
$elenco = array_slice($ignorati, 0, 10);
|
||||||
|
$altri = count($ignorati) - count($elenco);
|
||||||
|
$this->stateService->appendLog(
|
||||||
|
$importId,
|
||||||
|
'Ignorati ' . count($ignorati) . ' file che non sono PDF da importare (file di sistema, nascosti o di altro tipo): '
|
||||||
|
. implode(', ', $elenco) . ($altri > 0 ? ' e altri ' . $altri : '') . '.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($scartati as $scartato) {
|
||||||
|
$this->stateService->appendLog($importId, '[SKIP] ' . $scartato['file'] . ' - ' . $scartato['message']);
|
||||||
|
$this->stateService->addIssue($importId, [
|
||||||
|
'file' => $scartato['file'],
|
||||||
|
'type' => 'invalid-file',
|
||||||
|
'message' => $scartato['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
// Il job non partira' mai, quindi non sara' lui a liberare il posto.
|
||||||
|
$this->stateService->markFailed($importId);
|
||||||
|
$this->liberaPosto($importId, 'Import PDF non messo in coda: ' . $exception->getMessage());
|
||||||
|
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->stateService->get($importId) ?? $state;
|
return $this->stateService->get($importId) ?? $state;
|
||||||
}
|
}
|
||||||
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
|
|||||||
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
|
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
|
||||||
{
|
{
|
||||||
$importId = (string) Str::uuid();
|
$importId = (string) Str::uuid();
|
||||||
|
|
||||||
|
// Il posto si prende prima di salvare ed estrarre: e' la parte lenta, e
|
||||||
|
// farla per poi sentirsi rispondere "c'e' un ripristino in corso" sarebbe
|
||||||
|
// lavoro buttato.
|
||||||
|
$this->occupaPosto($importId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->estraiEAccoda($zipFile, $importId, $actorId);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
// Se il job e' partito il posto e' suo e lo libera lui; altrimenti
|
||||||
|
// resterebbe occupato da un import che non esiste.
|
||||||
|
if ($this->stateService->get($importId) === null) {
|
||||||
|
$this->liberaPosto($importId, 'Import PDF non avviato: ' . $exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function estraiEAccoda(UploadedFile $zipFile, string $importId, ?int $actorId): string
|
||||||
|
{
|
||||||
$zipStoredPath = $zipFile->storeAs(
|
$zipStoredPath = $zipFile->storeAs(
|
||||||
'bulk-territori-imports/' . $importId,
|
'bulk-territori-imports/' . $importId,
|
||||||
'archivio-' . $importId . '.zip',
|
'archivio-' . $importId . '.zip',
|
||||||
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
|
|||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
|
|
||||||
if ($zip->open($zipAbsolutePath) !== true) {
|
if ($zip->open($zipAbsolutePath) !== true) {
|
||||||
|
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||||
|
|
||||||
throw new RuntimeException('Impossibile aprire il file ZIP.');
|
throw new RuntimeException('Impossibile aprire il file ZIP.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$storedFiles = [];
|
$storedFiles = [];
|
||||||
|
$ignorati = [];
|
||||||
$entryIndex = 0;
|
$entryIndex = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
|
if (self::daIgnorare($entryName)) {
|
||||||
|
$ignorati[] = basename($entryName);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($storedFiles === []) {
|
if ($storedFiles === []) {
|
||||||
|
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||||
|
|
||||||
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
|
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
|
|||||||
$importId,
|
$importId,
|
||||||
$storedFiles,
|
$storedFiles,
|
||||||
$actorId,
|
$actorId,
|
||||||
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
|
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
|
||||||
|
$ignorati
|
||||||
);
|
);
|
||||||
|
|
||||||
return $importId;
|
return $importId;
|
||||||
|
|||||||
49
app/Support/Ritorno.php
Normal file
49
app/Support/Ritorno.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La pagina da cui è partito un percorso, per tornarci dopo un'operazione.
|
||||||
|
*
|
||||||
|
* I link che aprono una scheda o un'operazione portano `?da=<percorso>`
|
||||||
|
* (lo aggiunge resources/js/app.js ai link con data-ritorno): il percorso
|
||||||
|
* della pagina di partenza con i suoi filtri, o il `da` che quella pagina
|
||||||
|
* aveva già ricevuto, così si risale sempre all'origine. Elenco → scheda →
|
||||||
|
* Rientra → conferma riporta all'elenco filtrato.
|
||||||
|
*
|
||||||
|
* Si accettano solo percorsi interni ("/territori?zona=3"): un indirizzo
|
||||||
|
* completo o "//altro.sito" farebbe del parametro un rimando verso l'esterno.
|
||||||
|
*/
|
||||||
|
final class Ritorno
|
||||||
|
{
|
||||||
|
public const PARAM = 'da';
|
||||||
|
|
||||||
|
/** L'URL a cui tornare, o null se il valore non è un percorso interno valido. */
|
||||||
|
public static function valida(mixed $percorso): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($percorso) || $percorso === '' || strlen($percorso) > 2000) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with($percorso, '/')
|
||||||
|
|| str_starts_with($percorso, '//')
|
||||||
|
|| str_contains($percorso, '\\')
|
||||||
|
|| preg_match('/[\x00-\x1F\x7F]/', $percorso)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le richieste interne di Livewire non sono pagine.
|
||||||
|
$path = (string) parse_url($percorso, PHP_URL_PATH);
|
||||||
|
if ($path === '' || str_starts_with($path, '/livewire')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url($percorso);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il ritorno indicato dalla richiesta corrente (?da=…), se valido. */
|
||||||
|
public static function daRichiesta(): ?string
|
||||||
|
{
|
||||||
|
return self::valida(request()->query(self::PARAM));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
// '*' is deliberate. The container is only reachable through Traefik
|
||||||
|
// (no published port in Dokploy), and nginx's real_ip module has
|
||||||
|
// already rewritten REMOTE_ADDR to the client's address. Restricting
|
||||||
|
// this to private ranges would therefore stop Laravel from honouring
|
||||||
|
// the X-Forwarded-Proto that nginx sets server-side, and every signed
|
||||||
|
// URL and secure cookie would break behind HTTPS.
|
||||||
|
//
|
||||||
// X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
|
// X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
|
||||||
// set an arbitrary Host and have route()/url() emit links pointing at
|
// set an arbitrary Host and have route()/url() emit links pointing at
|
||||||
// an attacker's domain — including the public assignment PDF links,
|
// an attacker's domain — including the public assignment PDF links,
|
||||||
@@ -39,7 +46,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
// Dopo il ripristino di un backup ogni account resta bloccato finche'
|
// Dopo il ripristino di un backup ogni account resta bloccato finche'
|
||||||
// non imposta una password nuova. In coda al gruppo web, quindi la
|
// non imposta una password nuova. In coda al gruppo web, quindi la
|
||||||
// sessione e' gia' avviata e l'utente risolto quando gira.
|
// sessione e' gia' avviata e l'utente risolto quando gira.
|
||||||
|
//
|
||||||
|
// AuthenticateSession prima: ogni sessione ricorda l'impronta della
|
||||||
|
// password, e quando la password cambia (profilo, cambio obbligatorio,
|
||||||
|
// l'amministratore che la reimposta) le sessioni aperte altrove si
|
||||||
|
// chiudono alla richiesta successiva. Quella che ha fatto il cambio
|
||||||
|
// resta aperta: il middleware ci registra l'impronta nuova. Senza,
|
||||||
|
// session()->regenerate() cambiava solo l'id della sessione corrente e
|
||||||
|
// le altre restavano valide su Redis.
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
\App\Http\Middleware\RequirePasswordChange::class,
|
\App\Http\Middleware\RequirePasswordChange::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
26
config/queue.php
Normal file
26
config/queue.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Solo la connessione redis: il resto resta quello del framework (Laravel 11
|
||||||
|
* unisce queue.connections con i propri valori predefiniti).
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
// Più lungo del job più lungo (import PDF: 900 s). Con i 90 s del
|
||||||
|
// framework un backup, un ripristino o un import ancora in corso
|
||||||
|
// tornava disponibile in coda e poteva partire una seconda volta
|
||||||
|
// sugli stessi dati — per esempio dopo un riavvio del worker.
|
||||||
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 960),
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bozza e pubblicazione delle piantine.
|
||||||
|
*
|
||||||
|
* `geojson` diventa la bozza, su cui l'editor salva da solo a ogni modifica.
|
||||||
|
* Accanto si tiene quello che è stato pubblicato: il confine (per poter
|
||||||
|
* scartare la bozza), l'impronta del contenuto finito nel PDF (per sapere se
|
||||||
|
* da allora qualcosa è cambiato) e il momento della pubblicazione.
|
||||||
|
*
|
||||||
|
* Le piantine esistenti nascono "pubblicate" solo se il PDF in vigore del
|
||||||
|
* loro territorio è uno di quelli composti dalle piantine
|
||||||
|
* (territori-pdf/piantina-{territorio}-…): è l'unico caso in cui si sa che quel
|
||||||
|
* PDF viene da questo disegno. Tutte le altre partono come bozza.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||||
|
$table->longText('geojson_pubblicato')->nullable()->after('geojson');
|
||||||
|
$table->string('impronta_pubblicata', 64)->nullable()->after('geojson_pubblicato');
|
||||||
|
$table->timestamp('pubblicata_at')->nullable()->after('impronta_pubblicata');
|
||||||
|
});
|
||||||
|
|
||||||
|
TerritorioMappa::with('territorio.zona')
|
||||||
|
->whereNotNull('territorio_id')
|
||||||
|
->whereNotNull('image_path')
|
||||||
|
->get()
|
||||||
|
->each(function (TerritorioMappa $mappa) {
|
||||||
|
$pdf = (string) $mappa->territorio?->pdf_path;
|
||||||
|
|
||||||
|
if (! str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappa->forceFill([
|
||||||
|
'geojson_pubblicato' => $mappa->geojson,
|
||||||
|
'impronta_pubblicata' => $mappa->impronta(),
|
||||||
|
'pubblicata_at' => $mappa->rendered_at ?? now(),
|
||||||
|
])->saveQuietly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['geojson_pubblicato', 'impronta_pubblicata', 'pubblicata_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toglie dal registro attività i nomi dei proclamatori scritti in chiaro.
|
||||||
|
*
|
||||||
|
* Assegnazioni e rientri registravano "proclamatore" => nome e cognome:
|
||||||
|
* dati che nel database sono cifrati, e che lì restavano leggibili anche
|
||||||
|
* dopo l'anonimizzazione. Al loro posto va l'id del proclamatore, preso
|
||||||
|
* dall'assegnazione a cui la voce si riferisce; il registro mostra il nome
|
||||||
|
* attuale. Se l'assegnazione non c'è più il nome si toglie e basta.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$tabella = config('activitylog.table_name', 'activity_log');
|
||||||
|
|
||||||
|
DB::table($tabella)
|
||||||
|
->whereIn('description', ['assigned', 'returned'])
|
||||||
|
->where('properties', 'like', '%"proclamatore"%')
|
||||||
|
->orderBy('id')
|
||||||
|
->each(function ($voce) use ($tabella) {
|
||||||
|
$proprieta = json_decode((string) $voce->properties, true);
|
||||||
|
|
||||||
|
if (! is_array($proprieta) || ! array_key_exists('proclamatore', $proprieta)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($proprieta['proclamatore']);
|
||||||
|
|
||||||
|
$idProclamatore = $voce->subject_id
|
||||||
|
? DB::table('assegnazioni')->where('id', $voce->subject_id)->value('proclamatore_id')
|
||||||
|
: null;
|
||||||
|
if ($idProclamatore) {
|
||||||
|
$proprieta['proclamatore_id'] = (int) $idProclamatore;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table($tabella)->where('id', $voce->id)->update([
|
||||||
|
'properties' => json_encode($proprieta, JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// I nomi tolti non si rimettono: era proprio il punto.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La piantina ricorda il percorso del PDF che ha pubblicato.
|
||||||
|
*
|
||||||
|
* Prima "Pubblicata" si riconosceva dall'id del territorio, sia
|
||||||
|
* nell'impronta sia nel nome del file (territori-pdf/piantina-{id}-…): il
|
||||||
|
* ripristino di un backup ricrea i territori con id nuovi, e ogni piantina
|
||||||
|
* pubblicata tornava "da pubblicare". Ora l'impronta usa il numero e lo stato
|
||||||
|
* confronta il percorso del PDF in vigore con quello registrato qui; entrambi
|
||||||
|
* attraversano il ripristino intatti.
|
||||||
|
*
|
||||||
|
* Le piantine già pubblicate restano tali solo se lo erano davvero: impronta
|
||||||
|
* corrispondente (con la formula di prima, o con la nuova se la migrazione
|
||||||
|
* precedente gira adesso su un'installazione nuova) e PDF in vigore composto
|
||||||
|
* da questa piantina.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||||
|
$table->string('pdf_pubblicato')->nullable()->after('impronta_pubblicata');
|
||||||
|
});
|
||||||
|
|
||||||
|
TerritorioMappa::with('territorio.zona')
|
||||||
|
->whereNotNull('pubblicata_at')
|
||||||
|
->whereNotNull('territorio_id')
|
||||||
|
->get()
|
||||||
|
->each(function (TerritorioMappa $mappa) {
|
||||||
|
$territorio = $mappa->territorio;
|
||||||
|
$pdf = (string) $territorio?->pdf_path;
|
||||||
|
|
||||||
|
$improntaVecchia = hash('sha256', json_encode([
|
||||||
|
$mappa->geometria(),
|
||||||
|
$mappa->territorio_id,
|
||||||
|
$territorio ? trim((string) $territorio->numero) : null,
|
||||||
|
$territorio?->zona?->nome,
|
||||||
|
$territorio ? trim((string) $territorio->confini) : null,
|
||||||
|
]));
|
||||||
|
$improntaNuova = $mappa->impronta();
|
||||||
|
|
||||||
|
$eraPubblicata = in_array($mappa->impronta_pubblicata, [$improntaVecchia, $improntaNuova], true)
|
||||||
|
&& str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-');
|
||||||
|
|
||||||
|
if (! $eraPubblicata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappa->forceFill([
|
||||||
|
'impronta_pubblicata' => $improntaNuova,
|
||||||
|
'pdf_pubblicato' => $pdf,
|
||||||
|
])->saveQuietly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('pdf_pubblicato');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tre cose che vanno insieme, perché la terza ha bisogno della prima.
|
||||||
|
*
|
||||||
|
* 1. Il server di posta si configura dall'applicazione (Impostazioni → Posta)
|
||||||
|
* e non più solo dal .env: servono le email di conferma degli utenti.
|
||||||
|
* La password è cifrata con APP_KEY (cast `encrypted` sul modello).
|
||||||
|
* 2. I proclamatori possono avere email e telefono, facoltativi. Cifrati come
|
||||||
|
* nome e cognome: sono dati personali, e il database non deve mostrarli
|
||||||
|
* in chiaro a chi ne legge una copia.
|
||||||
|
* 3. Gli utenti devono confermare la loro email. Decisione dell'utente
|
||||||
|
* (13/10/2026): anche gli account già esistenti, amministratore compreso.
|
||||||
|
* La pagina della posta resta raggiungibile da un amministratore non
|
||||||
|
* ancora confermato, e c'è `php artisan utenti:conferma-email` per i casi
|
||||||
|
* in cui la posta non funziona affatto.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('settings', function (Blueprint $table) {
|
||||||
|
$table->string('mail_host')->nullable();
|
||||||
|
$table->unsignedInteger('mail_port')->nullable();
|
||||||
|
$table->string('mail_cifratura', 10)->nullable();
|
||||||
|
$table->string('mail_username')->nullable();
|
||||||
|
$table->text('mail_password')->nullable();
|
||||||
|
$table->string('mail_from_address')->nullable();
|
||||||
|
$table->string('mail_from_name')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('proclamatori', function (Blueprint $table) {
|
||||||
|
// text: il valore cifrato è molto più lungo dell'indirizzo.
|
||||||
|
$table->text('email')->nullable();
|
||||||
|
$table->text('telefono')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('users')->update(['email_verified_at' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('settings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'mail_host', 'mail_port', 'mail_cifratura', 'mail_username',
|
||||||
|
'mail_password', 'mail_from_address', 'mail_from_name',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('proclamatori', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['email', 'telefono']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invio del link dopo l'assegnazione.
|
||||||
|
*
|
||||||
|
* - link_da_inviare: l'assegnazione aspetta che il link venga mandato al
|
||||||
|
* proclamatore. Nasce vero con l'assegnazione fatta dalla pagina Assegna e
|
||||||
|
* torna falso quando il link risulta inviato. Le assegnazioni già esistenti
|
||||||
|
* restano false: non si segnalano a posteriori centinaia di link di cui non
|
||||||
|
* si sa niente.
|
||||||
|
* - link_canale: come è stato mandato l'ultima volta (copia, whatsapp, email).
|
||||||
|
* Non è un dato personale: email e telefono restano solo, cifrati, nel
|
||||||
|
* proclamatore.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||||
|
$table->boolean('link_da_inviare')->default(false)->after('link_sent');
|
||||||
|
$table->string('link_canale', 16)->nullable()->after('link_da_inviare');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['link_da_inviare', 'link_canale']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I proclamatori non hanno più email né telefono.
|
||||||
|
*
|
||||||
|
* Servivano solo a mandare il link della piantina; ora lo manda chi assegna
|
||||||
|
* il territorio, scegliendo il destinatario dai propri contatti (WhatsApp
|
||||||
|
* senza numero, oppure il menu "Condividi" del telefono). Meno dati personali
|
||||||
|
* da conservare. In produzione, al momento della decisione (19 ottobre
|
||||||
|
* 2026), nessun proclamatore li aveva compilati.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$colonne = array_values(array_filter(['email', 'telefono'], fn ($c) => Schema::hasColumn('proclamatori', $c)));
|
||||||
|
|
||||||
|
if ($colonne !== []) {
|
||||||
|
Schema::table('proclamatori', fn (Blueprint $table) => $table->dropColumn($colonne));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Le colonne tornano vuote: i valori non si recuperano.
|
||||||
|
Schema::table('proclamatori', function (Blueprint $table) {
|
||||||
|
$table->text('email')->nullable()->after('cognome');
|
||||||
|
$table->text('telefono')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,6 +18,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
||||||
|
# imposta solo per provare in locale una versione diversa.
|
||||||
|
args:
|
||||||
|
- PHP_VERSION
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
@@ -46,6 +50,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
||||||
|
# imposta solo per provare in locale una versione diversa.
|
||||||
|
args:
|
||||||
|
- PHP_VERSION
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: www-data
|
user: www-data
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -71,6 +79,11 @@ services:
|
|||||||
- --tries=3
|
- --tries=3
|
||||||
- --timeout=600
|
- --timeout=600
|
||||||
- --max-time=3600
|
- --max-time=3600
|
||||||
|
# Al riavvio (ogni deploy) il worker riceve SIGTERM e finisce il job in
|
||||||
|
# corso prima di uscire; Docker pero' di default aspetta 10 secondi e poi
|
||||||
|
# lo uccide. Un ripristino o un import interrotto cosi' lascia i dati a
|
||||||
|
# meta'. 16 minuti coprono il job piu' lungo (900 s).
|
||||||
|
stop_grace_period: 16m
|
||||||
# Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'.
|
# Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"]
|
test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"]
|
||||||
@@ -84,6 +97,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
||||||
|
# imposta solo per provare in locale una versione diversa.
|
||||||
|
args:
|
||||||
|
- PHP_VERSION
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: www-data
|
user: www-data
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -166,9 +183,11 @@ services:
|
|||||||
- ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env}
|
- ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env}
|
||||||
- --maxmemory
|
- --maxmemory
|
||||||
- 256mb
|
- 256mb
|
||||||
# Qui vivono sessioni e cache: meglio sfrattare chiavi che esaurire memoria.
|
# Qui vivono sessioni, cache e la coda. volatile-lru sfratta solo le
|
||||||
|
# chiavi con scadenza (sessioni, cache): con allkeys-lru, a memoria
|
||||||
|
# piena, potevano sparire anche i job in coda, senza errori.
|
||||||
- --maxmemory-policy
|
- --maxmemory-policy
|
||||||
- allkeys-lru
|
- volatile-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -200,6 +219,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
||||||
|
# imposta solo per provare in locale una versione diversa.
|
||||||
|
args:
|
||||||
|
- PHP_VERSION
|
||||||
# Senza questo si costruirebbe l'immagine di produzione, che essendo
|
# Senza questo si costruirebbe l'immagine di produzione, che essendo
|
||||||
# installata senza dipendenze di sviluppo non contiene phpunit.
|
# installata senza dipendenze di sviluppo non contiene phpunit.
|
||||||
target: test
|
target: test
|
||||||
|
|||||||
@@ -38,8 +38,16 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public).
|
# Territory files (PDFs, archived versions, thumbnails, map images) are NOT
|
||||||
# Served directly by nginx rather than round-tripping through PHP.
|
# public: anyone could download /storage/territori-pdf/territorio-12-34.pdf
|
||||||
|
# without logging in, the names are guessable, and the public cache header
|
||||||
|
# below let Cloudflare keep them for a week. Logged-in users get them from
|
||||||
|
# TerritorioFileController, publishers from the expiring assignment link.
|
||||||
|
location ^~ /storage/territori- {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# What is left on the public disk (the congregation logo).
|
||||||
location /storage/ {
|
location /storage/ {
|
||||||
expires 7d;
|
expires 7d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Versione di PHP, fissata alla patch. Va dichiarata prima del primo FROM per
|
||||||
|
# poterla usare nel FROM dello stage `base`.
|
||||||
|
#
|
||||||
|
# Fissata e non "8.3": quel tag si sposta, ma Docker tiene in cache la base già
|
||||||
|
# scaricata, e una ricostruzione senza --pull (anche su Dokploy) poteva uscire
|
||||||
|
# con un PHP vecchio senza che nessuno se ne accorgesse. Con la patch scritta
|
||||||
|
# qui, una build o prende questa versione o fallisce.
|
||||||
|
#
|
||||||
|
# 8.3.36: corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di codice da
|
||||||
|
# remoto nel core di PHP, CVSS 9.2, rilascio del 21/10/2026). Non scendere
|
||||||
|
# sotto. Aggiornala a ogni rilascio di sicurezza del ramo 8.3.
|
||||||
|
#
|
||||||
|
# Solo per provare in locale prima che l'immagine corretta sia su Docker Hub:
|
||||||
|
# PHP_VERSION=8.3.35 docker compose ... (mai per un deploy).
|
||||||
|
ARG PHP_VERSION=8.3.36
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 1 — PHP dependencies (no dev, production autoloader)
|
# Stage 1 — PHP dependencies (no dev, production autoloader)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -65,7 +81,7 @@ RUN npm run build
|
|||||||
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
||||||
# non puo' passare grazie a un ambiente diverso da quello vero.
|
# non puo' passare grazie a un ambiente diverso da quello vero.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM php:8.3-fpm AS base
|
FROM php:${PHP_VERSION}-fpm AS base
|
||||||
|
|
||||||
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
|
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
|
||||||
# would need its own copy of public/ plus the storage volume just to serve
|
# would need its own copy of public/ plus the storage volume just to serve
|
||||||
@@ -76,6 +92,11 @@ FROM php:8.3-fpm AS base
|
|||||||
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
|
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
|
||||||
# purging means pinning package names that break whenever the php base image
|
# purging means pinning package names that break whenever the php base image
|
||||||
# moves to a new Debian. A few tens of MB is worth not having that breakage.
|
# moves to a new Debian. A few tens of MB is worth not having that breakage.
|
||||||
|
#
|
||||||
|
# phpredis si scarica da GitHub, versione fissata, e non con `pecl install`:
|
||||||
|
# il 21/10/2026, giorno della patch di sicurezza di PHP, pecl.php.net
|
||||||
|
# rispondeva 504 e la build falliva proprio quando bisognava aggiornare.
|
||||||
|
ARG PHPREDIS_VERSION=6.3.0
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
@@ -101,8 +122,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
intl \
|
intl \
|
||||||
zip \
|
zip \
|
||||||
opcache \
|
opcache \
|
||||||
&& pecl install redis \
|
&& mkdir -p /usr/src/php/ext/redis \
|
||||||
&& docker-php-ext-enable redis \
|
&& curl -fsSL --retry 5 --retry-delay 5 \
|
||||||
|
"https://github.com/phpredis/phpredis/archive/refs/tags/${PHPREDIS_VERSION}.tar.gz" \
|
||||||
|
| tar xz --strip-components=1 -C /usr/src/php/ext/redis \
|
||||||
|
&& echo redis >> /usr/src/php-available-exts \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" redis \
|
||||||
|
&& docker-php-source delete \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ ok "Storage directories ready."
|
|||||||
KEY_FILE="/var/www/html/storage/app/.app_key"
|
KEY_FILE="/var/www/html/storage/app/.app_key"
|
||||||
|
|
||||||
if [ -n "${APP_KEY:-}" ]; then
|
if [ -n "${APP_KEY:-}" ]; then
|
||||||
|
# The persisted copy is the backup of the key the data was encrypted
|
||||||
|
# with. If the environment now carries a different one (a typo in
|
||||||
|
# Dokploy, a value pasted from another install), overwriting the file
|
||||||
|
# would destroy the only copy of the right key: refuse to start instead.
|
||||||
|
# A deliberate rotation keeps the old key aside and needs an explicit flag.
|
||||||
|
if [ -s "$KEY_FILE" ] && [ "$(cat "$KEY_FILE")" != "$APP_KEY" ]; then
|
||||||
|
if [ "${APP_KEY_ROTATE:-}" = "1" ]; then
|
||||||
|
BACKUP_FILE="${KEY_FILE}.prev-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$KEY_FILE" "$BACKUP_FILE"
|
||||||
|
warn "APP_KEY_ROTATE=1: the previous key was saved to $BACKUP_FILE."
|
||||||
|
warn "Data encrypted with it stays unreadable until re-encrypted."
|
||||||
|
else
|
||||||
|
die "APP_KEY in the environment differs from the key persisted in $KEY_FILE. Starting would make the encrypted personal data unreadable. Restore the previous APP_KEY in the environment, or set APP_KEY_ROTATE=1 if the change is deliberate (the old key is kept aside)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
ok "APP_KEY provided via environment."
|
ok "APP_KEY provided via environment."
|
||||||
printf '%s' "$APP_KEY" > "$KEY_FILE"
|
printf '%s' "$APP_KEY" > "$KEY_FILE"
|
||||||
elif [ -s "$KEY_FILE" ]; then
|
elif [ -s "$KEY_FILE" ]; then
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ memory_limit = 256M
|
|||||||
max_execution_time = 120
|
max_execution_time = 120
|
||||||
max_input_vars = 3000
|
max_input_vars = 3000
|
||||||
|
|
||||||
|
[Session]
|
||||||
|
; Mitigazione di CVE-2026-109618/109619 (RCE nel core di PHP, 21/10/2026),
|
||||||
|
; come indicato da Sansec in attesa della patch, e da tenere anche dopo:
|
||||||
|
; l'avanzamento degli upload nelle sessioni native di PHP non serve
|
||||||
|
; all'applicazione (Laravel ha le sue sessioni, in Redis, e Livewire non lo
|
||||||
|
; usa) ed è il punto da cui passano questi attacchi. Lo strict mode vale solo
|
||||||
|
; per le sessioni native, che qui non si usano: costa niente.
|
||||||
|
session.upload_progress.enabled = 0
|
||||||
|
session.use_strict_mode = 1
|
||||||
|
|
||||||
[opcache]
|
[opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.memory_consumption = 128
|
opcache.memory_consumption = 128
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build"
|
"build": "vite build",
|
||||||
|
"test:js": "node --test tests/js/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5",
|
"@tailwindcss/forms": "^0.5",
|
||||||
|
|||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4a45a8"/><text x="16" y="21.5" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">T2</text></svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
@@ -557,6 +557,123 @@ html { scroll-behavior: smooth; }
|
|||||||
background: var(--tm-superficie-alt);
|
background: var(--tm-superficie-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Assegnazione appena fatta: il link da mandare ──────────────────────── */
|
||||||
|
|
||||||
|
.tm-da-inviare {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 48rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 2px solid var(--tm-accento);
|
||||||
|
border-radius: var(--tm-raggio);
|
||||||
|
background: var(--tm-accento-tenue);
|
||||||
|
box-shadow: var(--tm-ombra-2);
|
||||||
|
}
|
||||||
|
/* Lo stesso riquadro come finestra sopra la pagina (invio del link dopo
|
||||||
|
l'assegnazione): sfondo pieno, perché sotto c'è il velo scuro. */
|
||||||
|
.tm-da-inviare-finestra {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 34rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: var(--tm-superficie, #fff);
|
||||||
|
}
|
||||||
|
.tm-da-inviare-titolo {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tm-accento-scuro);
|
||||||
|
}
|
||||||
|
.tm-da-inviare-link {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tm-da-inviare-link code {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tm-testo);
|
||||||
|
word-break: break-all;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.tm-da-inviare-azioni { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
|
.tm-da-inviare-scelta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-da-inviare-scelta p { margin: 0; font-size: 14px; font-weight: 600; color: var(--tm-testo); }
|
||||||
|
.tm-da-inviare-scelta > div { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
|
||||||
|
/* ─── Ricerca e filtri sopra un elenco ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.tm-filtri {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.tm-filtri-cerca {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid var(--tm-bordo-forte);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--tm-testo);
|
||||||
|
}
|
||||||
|
.tm-filtri-cerca:focus {
|
||||||
|
outline: 2px solid var(--tm-accento);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.tm-filtri-schede {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda:hover { border-color: var(--tm-bordo-forte); }
|
||||||
|
.tm-filtri-scheda:focus-visible {
|
||||||
|
outline: 2px solid var(--tm-accento);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda-attiva {
|
||||||
|
border-color: var(--tm-accento);
|
||||||
|
background: var(--tm-accento-tenue);
|
||||||
|
color: var(--tm-accento-scuro);
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda .tm-conteggio {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Etichette di stato ──────────────────────────────────────────────────── */
|
/* ─── Etichette di stato ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.tm-tag {
|
.tm-tag {
|
||||||
@@ -989,9 +1106,9 @@ html { scroll-behavior: smooth; }
|
|||||||
.tm-editor-barra {
|
.tm-editor-barra {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 9px 14px;
|
padding: 8px 14px;
|
||||||
background: var(--tm-superficie);
|
background: var(--tm-superficie);
|
||||||
border-bottom: 1px solid var(--tm-bordo);
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
box-shadow: var(--tm-ombra-1);
|
box-shadow: var(--tm-ombra-1);
|
||||||
@@ -1026,6 +1143,8 @@ html { scroll-behavior: smooth; }
|
|||||||
gap: 9px;
|
gap: 9px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tm-editor-titolo h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1034,6 +1153,11 @@ html { scroll-behavior: smooth; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.tm-editor-salvato {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.tm-editor-azioni {
|
.tm-editor-azioni {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1042,10 +1166,26 @@ html { scroll-behavior: smooth; }
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tm-editor-mappa {
|
/* Le tre zone: elenco, mappa, pannello. Elenco e pannello galleggiano sopra
|
||||||
|
la mappa invece di restringerla: se togliessero spazio, aprirli per cambiare
|
||||||
|
un nome sposterebbe il disegno sotto il puntatore. */
|
||||||
|
.tm-editor-corpo {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
/* Lo spazio che elenco e pannello aperti tolgono ai lati: barra degli
|
||||||
|
strumenti e controlli della mappa stanno nella parte libera, invece di
|
||||||
|
finire sotto (o sopra) un pannello. */
|
||||||
|
--libero-sx: 12px;
|
||||||
|
--libero-dx: 12px;
|
||||||
|
}
|
||||||
|
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(270px, 100vw - 24px) + 24px); }
|
||||||
|
.tm-editor-corpo.con-pannello { --libero-dx: calc(min(370px, 100vw - 24px) + 24px); }
|
||||||
|
.tm-mappa .leaflet-left { left: calc(var(--libero-sx) - 12px); }
|
||||||
|
.tm-mappa .leaflet-right { right: calc(var(--libero-dx) - 12px); }
|
||||||
|
.tm-editor-mappa {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
}
|
}
|
||||||
.tm-mappa {
|
.tm-mappa {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1056,7 +1196,7 @@ html { scroll-behavior: smooth; }
|
|||||||
.tm-editor-nota {
|
.tm-editor-nota {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 14px;
|
bottom: 18px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
max-width: min(92vw, 460px);
|
max-width: min(92vw, 460px);
|
||||||
@@ -1072,16 +1212,132 @@ html { scroll-behavior: smooth; }
|
|||||||
}
|
}
|
||||||
.tm-editor-nota a { color: inherit; font-weight: 600; }
|
.tm-editor-nota a { color: inherit; font-weight: 600; }
|
||||||
|
|
||||||
/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse
|
/* ── Barra degli strumenti di disegno ── */
|
||||||
spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il
|
.tm-strumenti {
|
||||||
puntatore. */
|
|
||||||
.tm-editor-pannello {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
left: var(--libero-sx);
|
||||||
|
right: var(--libero-dx);
|
||||||
|
z-index: 650;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tm-strumenti:empty { display: none; }
|
||||||
|
.tm-strumento {
|
||||||
|
flex: none;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
.tm-strumento:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); }
|
||||||
|
.tm-strumento:focus-visible { outline: 2px solid var(--tm-accento); outline-offset: 1px; }
|
||||||
|
.tm-strumento-principale { background: var(--tm-accento); color: #fff; }
|
||||||
|
.tm-strumento-principale:hover { background: var(--tm-accento-scuro); color: #fff; }
|
||||||
|
.tm-strumenti-modo {
|
||||||
|
flex: none;
|
||||||
|
padding: 0 8px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tm-pericolo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Elenco ── */
|
||||||
|
.tm-editor-elenco {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
z-index: 600;
|
z-index: 600;
|
||||||
width: min(360px, calc(100vw - 24px));
|
width: min(270px, calc(100vw - 24px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: var(--tm-raggio);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-cerca {
|
||||||
|
flex: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-cerca input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--tm-bordo-forte);
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-righe {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-voce {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--tm-testo);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-voce:hover { background: var(--tm-superficie-alt); }
|
||||||
|
.tm-editor-elenco-voce.is-selezionata { background: var(--tm-accento-tenue); color: var(--tm-accento-scuro); font-weight: 600; }
|
||||||
|
.tm-editor-elenco-stato {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
}
|
||||||
|
.tm-stato-bozza, .tm-stato-modificata { color: var(--tm-attesa); }
|
||||||
|
.tm-stato-pubblicata { color: var(--tm-ok); }
|
||||||
|
.tm-editor-elenco-vuoto {
|
||||||
|
padding: 14px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pannello della piantina ── */
|
||||||
|
.tm-editor-pannello {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 600;
|
||||||
|
width: min(370px, calc(100vw - 24px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid var(--tm-bordo);
|
border: 1px solid var(--tm-bordo);
|
||||||
@@ -1090,7 +1346,6 @@ html { scroll-behavior: smooth; }
|
|||||||
box-shadow: var(--tm-ombra-3);
|
box-shadow: var(--tm-ombra-3);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.tm-editor-pannello[hidden] { display: none; }
|
|
||||||
|
|
||||||
.tm-editor-pannello-testata {
|
.tm-editor-pannello-testata {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1098,7 +1353,7 @@ html { scroll-behavior: smooth; }
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 13px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--tm-bordo);
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
}
|
}
|
||||||
.tm-editor-pannello-testata h2 {
|
.tm-editor-pannello-testata h2 {
|
||||||
@@ -1130,7 +1385,7 @@ html { scroll-behavior: smooth; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--tm-bordo);
|
border-top: 1px solid var(--tm-bordo);
|
||||||
@@ -1141,14 +1396,164 @@ html { scroll-behavior: smooth; }
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--tm-testo-tenue);
|
color: var(--tm-testo-tenue);
|
||||||
}
|
}
|
||||||
|
.tm-stato-piantina p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--tm-testo-medio); }
|
||||||
|
.tm-azioni-riga {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.tm-azioni-riga:empty { display: none; }
|
||||||
|
.tm-passi {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
}
|
||||||
|
.tm-collegamento-piccolo {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-solo-stretto { display: none; }
|
||||||
|
|
||||||
|
/* Tablet: l'elenco si apre a richiesta e copre la mappa per il tempo della
|
||||||
|
scelta. */
|
||||||
|
@media (max-width: 1099px) {
|
||||||
|
.tm-editor-elenco { width: min(300px, calc(100vw - 24px)); }
|
||||||
|
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(300px, 100vw - 24px) + 24px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Telefono e tablet in verticale: il pannello sale dal basso. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.tm-editor-barra { flex-wrap: wrap; }
|
.tm-editor-barra { flex-wrap: wrap; }
|
||||||
.tm-editor-titolo { order: 3; flex-basis: 100%; }
|
.tm-editor-titolo { order: 3; flex-basis: 100%; }
|
||||||
.tm-editor-pannello { top: auto; height: min(70dvh, 460px); }
|
.tm-editor-azioni .tm-btn span { display: none; }
|
||||||
|
.tm-solo-stretto { display: inline-flex; }
|
||||||
|
.tm-editor-pannello {
|
||||||
|
top: auto;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: auto;
|
||||||
|
height: min(62dvh, 520px);
|
||||||
|
border-radius: var(--tm-raggio) var(--tm-raggio) 0 0;
|
||||||
|
}
|
||||||
|
/* Elenco e pannello qui coprono la mappa per intero o dal basso: la
|
||||||
|
barra resta in alto, sopra il foglio del pannello. */
|
||||||
|
.tm-editor-corpo,
|
||||||
|
.tm-editor-corpo.con-elenco,
|
||||||
|
.tm-editor-corpo.con-pannello { --libero-sx: 12px; --libero-dx: 12px; }
|
||||||
|
/* A destra dei pulsanti dello zoom, e su più righe invece di uscire
|
||||||
|
dallo schermo. */
|
||||||
|
.tm-strumenti { left: 58px; flex-wrap: wrap; }
|
||||||
|
.tm-mappa .leaflet-left { left: 0; }
|
||||||
|
/* Il foglio copre crediti e scala della mappa finché è aperto, invece di
|
||||||
|
averli stampati sopra i campi. */
|
||||||
|
.tm-editor-pannello { z-index: 1001; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Avvisi galleggianti (solo telaio a schermo intero) ───────────────────── */
|
/* Dita, non puntatore: vertici e maniglie di leaflet-draw più grandi, così
|
||||||
|
si prendono senza zoomare al massimo. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.leaflet-editing-icon,
|
||||||
|
.leaflet-touch-icon {
|
||||||
|
width: 22px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
margin-left: -11px !important;
|
||||||
|
margin-top: -11px !important;
|
||||||
|
}
|
||||||
|
.tm-strumento { min-height: 42px; padding: 8px 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Finestra di pubblicazione ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tm-modale-sfondo {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(28, 26, 23, .45);
|
||||||
|
}
|
||||||
|
.tm-modale {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: calc(100dvh - 32px);
|
||||||
|
border-radius: var(--tm-raggio);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tm-modale-larga { max-width: 820px; }
|
||||||
|
.tm-modale-testata {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-modale-testata h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||||
|
.tm-modale-corpo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tm-modale-piede {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-anteprima-pdf {
|
||||||
|
width: 100%;
|
||||||
|
height: min(62dvh, 640px);
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie-alt);
|
||||||
|
}
|
||||||
|
.tm-attesa {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 4px;
|
||||||
|
}
|
||||||
|
.tm-attesa-titolo { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
|
||||||
|
.tm-attesa-giro {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 3px solid var(--tm-accento-tenue);
|
||||||
|
border-top-color: var(--tm-accento);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tm-giro .9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes tm-giro { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tm-attesa-giro { animation-duration: 3s; } }
|
||||||
|
.tm-nota-errore {
|
||||||
|
border-color: #edc9c6;
|
||||||
|
background: var(--tm-pericolo-tenue);
|
||||||
|
color: var(--tm-pericolo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Avvisi galleggianti (componente <x-avvisi>) ───────────────────── */
|
||||||
|
|
||||||
.tm-avvisi {
|
.tm-avvisi {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1254,3 +1659,165 @@ html { scroll-behavior: smooth; }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Campo di scelta con ricerca (<x-scelta>) ──────────────────────────────
|
||||||
|
Al posto delle tendine di territori e proclamatori. Pensato anche per il
|
||||||
|
telefono: testo a 16px (sotto, iOS ingrandisce la pagina al tocco), voci
|
||||||
|
alte almeno 44px, elenco che scorre dentro mezzo schermo. I selettori
|
||||||
|
battono `.tm-campo input[type="text"]` dell'editor delle piantine. */
|
||||||
|
.tm-scelta {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tm-scelta-campo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.tm-scelta input.tm-scelta-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 76px 8px 12px;
|
||||||
|
border: 1px solid var(--tm-bordo-forte);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
color: var(--tm-testo);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: border-color .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.tm-scelta input.tm-scelta-input { min-height: 40px; font-size: 14px; }
|
||||||
|
}
|
||||||
|
.tm-scelta input.tm-scelta-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--tm-accento);
|
||||||
|
box-shadow: 0 0 0 3px var(--tm-accento-tenue);
|
||||||
|
}
|
||||||
|
.tm-scelta input.tm-scelta-input:disabled {
|
||||||
|
padding-right: 12px;
|
||||||
|
background: var(--tm-superficie-alt);
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
}
|
||||||
|
.tm-scelta input.tm-scelta-input::placeholder { color: var(--tm-testo-tenue); }
|
||||||
|
|
||||||
|
.tm-scelta-pulisci,
|
||||||
|
.tm-scelta-apri {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-top: -18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-scelta-pulisci { right: 38px; }
|
||||||
|
.tm-scelta-apri { right: 2px; }
|
||||||
|
.tm-scelta-pulisci:hover,
|
||||||
|
.tm-scelta-apri:hover { color: var(--tm-testo); background: var(--tm-superficie-alt); }
|
||||||
|
.tm-scelta-pulisci svg,
|
||||||
|
.tm-scelta-apri svg { width: 18px; height: 18px; transition: transform .15s ease; }
|
||||||
|
.tm-scelta-apri svg.tm-scelta-giu { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.tm-scelta-elenco {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: min(50vh, 20rem);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
}
|
||||||
|
.tm-scelta-voce {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 2px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--tm-testo);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.tm-scelta-voce { min-height: 36px; padding: 7px 10px; font-size: 14px; }
|
||||||
|
}
|
||||||
|
.tm-scelta-voce-attiva { background: var(--tm-accento-tenue); }
|
||||||
|
.tm-scelta-voce-scelta .tm-scelta-etichetta { font-weight: 700; color: var(--tm-accento-scuro); }
|
||||||
|
.tm-scelta-voce-speciale { color: var(--tm-testo-medio); }
|
||||||
|
.tm-scelta-voce-spenta { cursor: not-allowed; color: var(--tm-testo-tenue); }
|
||||||
|
.tm-scelta-voce-spenta.tm-scelta-voce-attiva { background: var(--tm-superficie-alt); }
|
||||||
|
.tm-scelta-dettaglio { font-size: 13px; color: var(--tm-testo-tenue); }
|
||||||
|
.tm-scelta-motivo { flex-basis: 100%; font-size: 12px; color: var(--tm-attesa); }
|
||||||
|
.tm-scelta-forse {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--tm-attesa-tenue);
|
||||||
|
color: var(--tm-attesa);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tm-scelta-vuoto {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Elenco dei territori assegnati (pagina Assegna) ───────────────────────
|
||||||
|
Classi brevi al posto delle catene di utility: le righe sono ~80 e ogni
|
||||||
|
byte si moltiplica (ElencoAssegnati, AssegnaPrestazioniTest). */
|
||||||
|
.tm-assegnato { padding: 16px 20px; }
|
||||||
|
.tm-assegnato svg { width: 16px; height: 16px; fill: none; stroke: currentColor; flex: none; }
|
||||||
|
.tm-assegnato-testa {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 8px 24px;
|
||||||
|
}
|
||||||
|
.tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; }
|
||||||
|
.tm-assegnato-numero:hover { text-decoration: underline; }
|
||||||
|
.tm-assegnato-zona {
|
||||||
|
margin-left: 6px; padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: #f3f4f6; color: #9ca3af; font-size: 12px;
|
||||||
|
}
|
||||||
|
.tm-assegnato-info { color: #6b7280; font-size: 12px; }
|
||||||
|
.tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; }
|
||||||
|
.tm-assegnato-giorni {
|
||||||
|
padding: 2px 8px; border-radius: 999px;
|
||||||
|
background: #f3f4f6; color: #6b7280; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tm-assegnato-giorni.tm-g-presto { background: #fffbeb; color: #d97706; }
|
||||||
|
.tm-assegnato-giorni.tm-g-tardi { background: #fef2f2; color: #dc2626; }
|
||||||
|
.tm-assegnato-sospeso {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||||
|
margin-top: 12px; padding: 6px 12px; border: 1px solid #fde68a; border-radius: 6px;
|
||||||
|
background: #fffbeb; color: #92400e; font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tm-assegnato-link { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||||
|
.tm-assegnato-link > * { flex: none; }
|
||||||
|
.tm-assegnato-link code {
|
||||||
|
flex: 1 1 0; min-width: 0; padding: 8px 12px;
|
||||||
|
border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb;
|
||||||
|
color: #4b5563; font-size: 12px; word-break: break-all; user-select: all; cursor: text;
|
||||||
|
}
|
||||||
|
.tm-assegnato-senza { margin-top: 8px; color: #d1d5db; font-size: 12px; }
|
||||||
|
/* Pulsante copia: la spunta al posto dell'icona per due secondi. */
|
||||||
|
.tm-copia svg + svg, .tm-copia.tm-copiato svg:first-child { display: none; }
|
||||||
|
.tm-copia.tm-copiato svg + svg { display: block; color: #16a34a; }
|
||||||
|
|||||||
@@ -1 +1,181 @@
|
|||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
|
import { proponi, normalizza } from './scelta';
|
||||||
|
|
||||||
|
// ─── Campo di scelta con ricerca (<x-scelta>) ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Al posto della tendina: si scrive e sotto compaiono le proposte più vicine
|
||||||
|
// (logica in ./scelta.js). Campo vuoto o appena aperto: la tendina completa.
|
||||||
|
// Il valore scelto arriva a Livewire con x-modelable + wire:model.
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Elenco filtrato nel browser: ogni riga ha data-cerca (testo già senza
|
||||||
|
// accenti, minuscolo); valgono tutte le parole scritte, in qualsiasi
|
||||||
|
// ordine. Nessuna richiesta al server mentre si scrive.
|
||||||
|
window.Alpine.data('elencoCercabile', () => ({
|
||||||
|
q: '',
|
||||||
|
condivisibile: !! navigator.share,
|
||||||
|
// Condividi il link della riga (data-messaggio): il menu del telefono
|
||||||
|
// fa scegliere app e contatto. A condivisione fatta il link risulta
|
||||||
|
// inviato; se la si annulla non cambia niente.
|
||||||
|
condividi(pulsante) {
|
||||||
|
const riga = pulsante.closest('[data-link]');
|
||||||
|
if (!riga || !navigator.share) return;
|
||||||
|
navigator.share({ title: riga.dataset.titolo, text: riga.dataset.messaggio })
|
||||||
|
.then(() => this.$wire.segnaCondiviso(Number(riga.dataset.id)))
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
corrisponde(testo) {
|
||||||
|
const parole = normalizza(this.q).split(/\s+/).filter(Boolean);
|
||||||
|
return parole.every((p) => (testo ?? '').includes(p));
|
||||||
|
},
|
||||||
|
get nessuno() {
|
||||||
|
if (normalizza(this.q) === '') return false;
|
||||||
|
return ![...this.$root.querySelectorAll('[data-cerca]')].some((riga) => this.corrisponde(riga.dataset.cerca));
|
||||||
|
},
|
||||||
|
// Copia il link della riga (il <code> accanto al pulsante); il pulsante
|
||||||
|
// mostra la spunta per due secondi (classe tm-copiato). Un metodo solo
|
||||||
|
// invece dello stesso codice ripetuto in ogni riga.
|
||||||
|
copia(pulsante) {
|
||||||
|
const testo = pulsante.closest('[data-link]')?.querySelector('code')?.textContent.trim() ?? '';
|
||||||
|
navigator.clipboard?.writeText(testo).then(() => {
|
||||||
|
pulsante.classList.add('tm-copiato');
|
||||||
|
setTimeout(() => pulsante.classList.remove('tm-copiato'), 2000);
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// vuoto: il valore di "nessuna scelta" per la proprietà Livewire collegata
|
||||||
|
// ('' per le stringhe, null per i ?int: '' su un ?int sarebbe un errore).
|
||||||
|
window.Alpine.data('sceltaRicerca', (opzioni, vuoto = '') => ({
|
||||||
|
opzioni,
|
||||||
|
vuoto,
|
||||||
|
valore: null,
|
||||||
|
testo: '',
|
||||||
|
aperto: false,
|
||||||
|
evidenziato: 0,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.testo = this.etichettaDi(this.valore);
|
||||||
|
// Valore cambiato da Livewire (reset del form, scelta da codice).
|
||||||
|
this.$watch('valore', (v) => { if (!this.aperto) this.testo = this.etichettaDi(v); });
|
||||||
|
},
|
||||||
|
|
||||||
|
scelta() {
|
||||||
|
return this.opzioni.find((o) => String(o.valore) === String(this.valore ?? '')) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
etichettaDi(v) {
|
||||||
|
if (v === null || v === undefined || v === '') {
|
||||||
|
const vuota = this.opzioni.find((o) => o.speciale && o.valore === '');
|
||||||
|
return vuota ? vuota.etichetta : '';
|
||||||
|
}
|
||||||
|
const o = this.opzioni.find((x) => String(x.valore) === String(v));
|
||||||
|
return o ? o.etichetta : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Finché nel campo c'è l'etichetta della scelta attuale, si vede la
|
||||||
|
// tendina completa: si cerca solo da quando si comincia a scrivere.
|
||||||
|
get ricerca() {
|
||||||
|
return this.testo === this.etichettaDi(this.valore) ? '' : this.testo;
|
||||||
|
},
|
||||||
|
|
||||||
|
get risultati() {
|
||||||
|
return proponi(this.opzioni, this.ricerca);
|
||||||
|
},
|
||||||
|
|
||||||
|
apri() {
|
||||||
|
if (this.aperto) return;
|
||||||
|
this.aperto = true;
|
||||||
|
const i = this.risultati.findIndex((o) => String(o.valore) === String(this.valore ?? ''));
|
||||||
|
this.evidenziato = Math.max(0, i);
|
||||||
|
this.$nextTick(() => this.mostraEvidenziato());
|
||||||
|
},
|
||||||
|
|
||||||
|
chiudi() {
|
||||||
|
this.aperto = false;
|
||||||
|
this.testo = this.etichettaDi(this.valore);
|
||||||
|
},
|
||||||
|
|
||||||
|
alterna() {
|
||||||
|
if (this.aperto) { this.chiudi(); return; }
|
||||||
|
this.$refs.campo.focus();
|
||||||
|
this.apri();
|
||||||
|
},
|
||||||
|
|
||||||
|
scrivendo() {
|
||||||
|
this.aperto = true;
|
||||||
|
this.evidenziato = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
sposta(passo) {
|
||||||
|
if (!this.aperto) { this.apri(); return; }
|
||||||
|
const n = this.risultati.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
this.evidenziato = (this.evidenziato + passo + n) % n;
|
||||||
|
this.$nextTick(() => this.mostraEvidenziato());
|
||||||
|
},
|
||||||
|
|
||||||
|
mostraEvidenziato() {
|
||||||
|
this.$refs.elenco?.querySelector('[data-evidenziato="1"]')?.scrollIntoView({ block: 'nearest' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// daTocco: scelta col dito o col mouse. Si toglie il focus, così sul
|
||||||
|
// telefono si chiude la tastiera; con frecce e Invio il focus resta.
|
||||||
|
scegli(opzione, daTocco = false) {
|
||||||
|
if (!opzione || opzione.motivo) return;
|
||||||
|
this.valore = opzione.valore;
|
||||||
|
this.aperto = false;
|
||||||
|
this.testo = opzione.etichetta;
|
||||||
|
// Per chi vuole reagire alla scelta senza passare dal server
|
||||||
|
// (l'anteprima della miniatura in Assegna).
|
||||||
|
this.$dispatch('scelta', { valore: opzione.valore, opzione });
|
||||||
|
if (daTocco) this.$refs.campo.blur();
|
||||||
|
},
|
||||||
|
|
||||||
|
scegliEvidenziato() {
|
||||||
|
if (this.aperto) this.scegli(this.risultati[this.evidenziato]);
|
||||||
|
},
|
||||||
|
|
||||||
|
svuota() {
|
||||||
|
this.valore = this.vuoto;
|
||||||
|
this.testo = '';
|
||||||
|
this.$dispatch('scelta', { valore: this.vuoto, opzione: null });
|
||||||
|
this.$refs.campo.focus();
|
||||||
|
this.aperto = true;
|
||||||
|
this.evidenziato = 0;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Ritorno alla pagina di partenza ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// I link con data-ritorno (schede e operazioni) portano con sé ?da=<percorso>:
|
||||||
|
// la pagina di partenza con i suoi filtri, oppure il `da` che la pagina
|
||||||
|
// corrente aveva già ricevuto, così si risale sempre all'origine del percorso
|
||||||
|
// (elenco → scheda → Rientra → torna all'elenco). Il server lo valida e lo usa
|
||||||
|
// dopo conferma o annulla: App\Support\Ritorno, trait RicordaRitorno.
|
||||||
|
//
|
||||||
|
// Si calcola al momento dell'uso e non al caricamento: filtri e pagina degli
|
||||||
|
// elenchi cambiano l'indirizzo (#[Url] di Livewire) senza ricaricare.
|
||||||
|
|
||||||
|
function ritornoCorrente() {
|
||||||
|
const qui = new URL(window.location.href);
|
||||||
|
return qui.searchParams.get('da') || (qui.pathname + qui.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggiornaLink(link) {
|
||||||
|
const destinazione = new URL(link.getAttribute('href'), window.location.origin);
|
||||||
|
if (destinazione.origin !== window.location.origin) return;
|
||||||
|
destinazione.searchParams.set('da', ritornoCorrente());
|
||||||
|
link.setAttribute('href', destinazione.pathname + destinazione.search + destinazione.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fase di cattura: si arriva prima di wire:navigate, che legge l'href al
|
||||||
|
// pointerdown (precaricamento) e al click. contextmenu e focusin coprono
|
||||||
|
// "apri in una nuova scheda" e la tastiera.
|
||||||
|
['pointerdown', 'click', 'contextmenu', 'focusin'].forEach((evento) => {
|
||||||
|
document.addEventListener(evento, (e) => {
|
||||||
|
const link = e.target instanceof Element ? e.target.closest('a[data-ritorno][href]') : null;
|
||||||
|
if (link) aggiornaLink(link);
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
|
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
|
||||||
//
|
//
|
||||||
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine
|
// Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più
|
||||||
// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il
|
// l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i
|
||||||
// componente Livewire attraverso eventi:
|
// gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti
|
||||||
// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa'
|
// grandi e con il testo, che si usano anche al tablet.
|
||||||
// Livewire → JS: 'piantine-dati-aggiornati'
|
//
|
||||||
|
// Modalità, sempre esplicite:
|
||||||
|
// vista si guarda, si sceglie una piantina (clic sulla mappa o in elenco)
|
||||||
|
// disegno si traccia una piantina NUOVA
|
||||||
|
// ridisegno si traccia da capo il confine di quella aperta (con conferma)
|
||||||
|
// vertici si trascinano i vertici di quella aperta
|
||||||
|
//
|
||||||
|
// Con il componente Livewire si parla per eventi:
|
||||||
|
// JS → Livewire: 'piantina-selezionata-mappa', 'geometria-modificata'
|
||||||
|
// Livewire → JS: 'piantina-aggiornata', 'piantina-rimossa',
|
||||||
|
// 'piantina-selezionata', 'editor-disegna', 'editor-indirizzo'
|
||||||
|
// I dati di tutte le piantine si chiedono una volta sola ($wire.datiMappa());
|
||||||
|
// dopo arriva solo la piantina cambiata.
|
||||||
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
@@ -12,12 +24,13 @@ import 'leaflet-draw';
|
|||||||
import 'leaflet-draw/dist/leaflet.draw.css';
|
import 'leaflet-draw/dist/leaflet.draw.css';
|
||||||
|
|
||||||
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
|
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
|
||||||
// Una piantina collegata a un territorio è "a posto"; una scollegata è un
|
const STILI = {
|
||||||
// lavoro ancora aperto, e si vede senza dover aprire il pannello.
|
pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 },
|
||||||
const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 };
|
modificata: { color: '#a06a22', weight: 2, fillColor: '#c98b3a', fillOpacity: 0.1 },
|
||||||
const STILE_SCOLLEGATA = { color: '#b4762a', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 };
|
bozza: { color: '#a06a22', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 },
|
||||||
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, fillColor: '#605cb2', fillOpacity: 0.18 };
|
};
|
||||||
const STILE_IN_ATTESA = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
|
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 };
|
||||||
|
const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
|
||||||
|
|
||||||
function initPiantine() {
|
function initPiantine() {
|
||||||
const contenitore = document.getElementById('piantine-map');
|
const contenitore = document.getElementById('piantine-map');
|
||||||
@@ -26,143 +39,297 @@ function initPiantine() {
|
|||||||
}
|
}
|
||||||
contenitore.dataset.inizializzato = '1';
|
contenitore.dataset.inizializzato = '1';
|
||||||
|
|
||||||
let dati = { area: null, piantine: [], selezionata: null };
|
const radice = contenitore.closest('[wire\\:id]');
|
||||||
try {
|
const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
|
||||||
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
|
|
||||||
} catch {
|
|
||||||
// si parte comunque con la mappa vuota
|
|
||||||
}
|
|
||||||
|
|
||||||
// Etichette in italiano per la barra di disegno.
|
|
||||||
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il confine di una piantina';
|
|
||||||
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
|
|
||||||
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
|
|
||||||
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
|
|
||||||
L.drawLocal.draw.handlers.polygon.tooltip = {
|
L.drawLocal.draw.handlers.polygon.tooltip = {
|
||||||
start: 'Clicca sulla mappa per iniziare il confine.',
|
start: 'Tocca o clicca sulla mappa per il primo punto del confine.',
|
||||||
cont: 'Continua a cliccare lungo il confine.',
|
cont: 'Continua lungo il confine.',
|
||||||
end: 'Clicca sul primo punto per chiudere il poligono.',
|
end: 'Chiudi sul primo punto, o premi «Concludi».',
|
||||||
};
|
};
|
||||||
|
L.drawLocal.edit.handlers.edit.tooltip = { text: 'Trascina i vertici per correggere il confine.', subtext: '' };
|
||||||
|
|
||||||
const mappa = L.map(contenitore, { zoomControl: true });
|
const mappa = L.map(contenitore, { zoomControl: true });
|
||||||
|
|
||||||
L.control.scale({ imperial: false }).addTo(mappa);
|
L.control.scale({ imperial: false }).addTo(mappa);
|
||||||
|
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
}).addTo(mappa);
|
}).addTo(mappa);
|
||||||
|
|
||||||
|
const gruppo = L.featureGroup().addTo(mappa);
|
||||||
|
const layers = new Map(); // mappaId → { dati, layer }
|
||||||
let layerArea = null;
|
let layerArea = null;
|
||||||
const layerSalvate = L.featureGroup().addTo(mappa);
|
let selezionata = null;
|
||||||
let layerInAttesa = null;
|
let modo = 'vista';
|
||||||
|
let gestoreDisegno = null;
|
||||||
|
let poligonoInModifica = null;
|
||||||
|
|
||||||
mappa.addControl(new L.Control.Draw({
|
// ── Disegno delle piantine ──────────────────────────────────────────────
|
||||||
draw: {
|
|
||||||
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA },
|
function stileDi(dati) {
|
||||||
polyline: false,
|
if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
|
||||||
rectangle: false,
|
return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
|
||||||
circle: false,
|
}
|
||||||
circlemarker: false,
|
|
||||||
marker: false,
|
function disegna(dati) {
|
||||||
},
|
rimuovi(dati.mappaId, false);
|
||||||
edit: false,
|
if (!dati.geometry) return;
|
||||||
|
|
||||||
|
let layer;
|
||||||
|
try {
|
||||||
|
layer = L.geoJSON({ type: 'Feature', properties: {}, geometry: dati.geometry }, { style: stileDi(dati) });
|
||||||
|
} catch {
|
||||||
|
return; // una geometria rovinata non deve fermare le altre
|
||||||
|
}
|
||||||
|
layer.bindTooltip(`${dati.etichetta} · ${dati.statoEtichetta}`, { sticky: true });
|
||||||
|
layer.on('click', () => {
|
||||||
|
if (modo !== 'vista') return;
|
||||||
|
componente().dispatch('piantina-selezionata-mappa', { mappaId: dati.mappaId });
|
||||||
|
});
|
||||||
|
gruppo.addLayer(layer);
|
||||||
|
layers.set(dati.mappaId, { dati, layer });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rimuovi(mappaId, aggiornaElenco = true) {
|
||||||
|
const voce = layers.get(mappaId);
|
||||||
|
if (voce) {
|
||||||
|
gruppo.removeLayer(voce.layer);
|
||||||
|
layers.delete(mappaId);
|
||||||
|
}
|
||||||
|
if (aggiornaElenco) disegnaElenco();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ristila() {
|
||||||
|
for (const { dati, layer } of layers.values()) {
|
||||||
|
layer.setStyle(stileDi(dati));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inquadra(mappaId) {
|
||||||
|
const voce = layers.get(mappaId);
|
||||||
|
if (voce) mappa.fitBounds(voce.layer.getBounds().pad(0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elenco con ricerca ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const elenco = document.getElementById('piantine-elenco');
|
||||||
|
const cerca = document.getElementById('piantine-cerca');
|
||||||
|
|
||||||
|
function disegnaElenco() {
|
||||||
|
if (!elenco) return;
|
||||||
|
const filtro = (cerca?.value ?? '').trim().toLowerCase();
|
||||||
|
const voci = [...layers.values()].map((v) => v.dati)
|
||||||
|
.filter((d) => !filtro || d.etichetta.toLowerCase().includes(filtro))
|
||||||
|
.sort((a, b) => a.etichetta.localeCompare(b.etichetta, 'it', { numeric: true }));
|
||||||
|
|
||||||
|
elenco.replaceChildren(...voci.map((d) => {
|
||||||
|
const riga = document.createElement('li');
|
||||||
|
const pulsante = document.createElement('button');
|
||||||
|
pulsante.type = 'button';
|
||||||
|
pulsante.className = 'tm-editor-elenco-voce' + (d.mappaId === selezionata ? ' is-selezionata' : '');
|
||||||
|
pulsante.setAttribute('aria-current', d.mappaId === selezionata ? 'true' : 'false');
|
||||||
|
const nome = document.createElement('span');
|
||||||
|
nome.textContent = d.etichetta;
|
||||||
|
const stato = document.createElement('span');
|
||||||
|
stato.className = 'tm-editor-elenco-stato tm-stato-' + d.stato;
|
||||||
|
stato.textContent = d.statoEtichetta;
|
||||||
|
pulsante.append(nome, stato);
|
||||||
|
pulsante.addEventListener('click', () => {
|
||||||
|
if (modo !== 'vista') return;
|
||||||
|
componente().dispatch('piantina-selezionata-mappa', { mappaId: d.mappaId });
|
||||||
|
});
|
||||||
|
riga.append(pulsante);
|
||||||
|
return riga;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function stileDi(piantina, selezionata) {
|
if (!voci.length) {
|
||||||
if (selezionata) return STILE_SELEZIONATA;
|
const vuoto = document.createElement('li');
|
||||||
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA;
|
vuoto.className = 'tm-editor-elenco-vuoto';
|
||||||
|
vuoto.textContent = filtro ? 'Nessuna piantina trovata.' : 'Nessuna piantina ancora.';
|
||||||
|
elenco.append(vuoto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ridisegna(nuoviDati, adattaVista) {
|
cerca?.addEventListener('input', disegnaElenco);
|
||||||
dati = nuoviDati;
|
|
||||||
|
|
||||||
if (layerArea) {
|
// ── Barra degli strumenti ───────────────────────────────────────────────
|
||||||
mappa.removeLayer(layerArea);
|
|
||||||
layerArea = null;
|
const barra = document.getElementById('piantine-strumenti');
|
||||||
|
|
||||||
|
function pulsante(testo, azione, principale = false) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'tm-strumento' + (principale ? ' tm-strumento-principale' : '');
|
||||||
|
b.textContent = testo;
|
||||||
|
b.addEventListener('click', azione);
|
||||||
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aggiornaBarra() {
|
||||||
|
if (!barra) return;
|
||||||
|
const comandi = [];
|
||||||
|
|
||||||
|
if (modo === 'vista') {
|
||||||
|
comandi.push(pulsante('Disegna nuova piantina', () => avviaDisegno(false), selezionata === null));
|
||||||
|
if (selezionata !== null && layers.has(selezionata)) {
|
||||||
|
comandi.push(pulsante('Modifica vertici', avviaVertici));
|
||||||
|
comandi.push(pulsante('Ridisegna confine', () => {
|
||||||
|
if (window.confirm('Ridisegnare da capo il confine di questa piantina? Quello attuale viene sostituito.')) {
|
||||||
|
avviaDisegno(true);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (modo === 'disegno' || modo === 'ridisegno') {
|
||||||
|
const titolo = document.createElement('span');
|
||||||
|
titolo.className = 'tm-strumenti-modo';
|
||||||
|
titolo.textContent = modo === 'disegno' ? 'Nuova piantina' : 'Nuovo confine';
|
||||||
|
comandi.push(titolo);
|
||||||
|
comandi.push(pulsante('Togli ultimo punto', () => gestoreDisegno?.deleteLastVertex()));
|
||||||
|
comandi.push(pulsante('Concludi', () => gestoreDisegno?.completeShape(), true));
|
||||||
|
comandi.push(pulsante('Annulla', fermaDisegno));
|
||||||
|
} else if (modo === 'vertici') {
|
||||||
|
const titolo = document.createElement('span');
|
||||||
|
titolo.className = 'tm-strumenti-modo';
|
||||||
|
titolo.textContent = 'Trascina i vertici';
|
||||||
|
comandi.push(titolo);
|
||||||
|
comandi.push(pulsante('Fine modifica', fermaVertici, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
barra.replaceChildren(...comandi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disegno ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let ridisegno = false;
|
||||||
|
|
||||||
|
function avviaDisegno(diQuellaAperta) {
|
||||||
|
fermaVertici(false);
|
||||||
|
ridisegno = diQuellaAperta;
|
||||||
|
modo = diQuellaAperta ? 'ridisegno' : 'disegno';
|
||||||
|
gestoreDisegno = new L.Draw.Polygon(mappa, { allowIntersection: false, showArea: false, shapeOptions: STILE_DISEGNO });
|
||||||
|
gestoreDisegno.enable();
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermaDisegno() {
|
||||||
|
gestoreDisegno?.disable();
|
||||||
|
gestoreDisegno = null;
|
||||||
|
modo = 'vista';
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
|
||||||
|
mappa.on(L.Draw.Event.CREATED, (evento) => {
|
||||||
|
const geometry = evento.layer.toGeoJSON().geometry;
|
||||||
|
const nuova = !ridisegno;
|
||||||
|
fermaDisegno();
|
||||||
|
componente().dispatch('geometria-modificata', { geometry, nuova });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il gestore si spegne da solo anche con Esc: la barra deve seguirlo.
|
||||||
|
mappa.on(L.Draw.Event.DRAWSTOP, () => {
|
||||||
|
if (modo === 'disegno' || modo === 'ridisegno') {
|
||||||
|
gestoreDisegno = null;
|
||||||
|
modo = 'vista';
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Modifica dei vertici ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function avviaVertici() {
|
||||||
|
const voce = layers.get(selezionata);
|
||||||
|
if (!voce) return;
|
||||||
|
poligonoInModifica = voce.layer.getLayers()[0];
|
||||||
|
if (!poligonoInModifica?.editing) return;
|
||||||
|
poligonoInModifica.editing.enable();
|
||||||
|
modo = 'vertici';
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermaVertici(salva = true) {
|
||||||
|
if (!poligonoInModifica) {
|
||||||
|
if (modo === 'vertici') { modo = 'vista'; aggiornaBarra(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const poligono = poligonoInModifica;
|
||||||
|
poligonoInModifica = null;
|
||||||
|
const modificato = poligono.edited;
|
||||||
|
poligono.editing.disable();
|
||||||
|
modo = 'vista';
|
||||||
|
aggiornaBarra();
|
||||||
|
|
||||||
|
if (salva && modificato) {
|
||||||
|
componente().dispatch('geometria-modificata', { geometry: poligono.toGeoJSON().geometry, nuova: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eventi dal server ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function seleziona(mappaId, centra) {
|
||||||
|
if (modo === 'vertici') fermaVertici();
|
||||||
|
selezionata = mappaId ?? null;
|
||||||
|
ristila();
|
||||||
|
disegnaElenco();
|
||||||
|
aggiornaBarra();
|
||||||
|
if (centra && selezionata !== null) inquadra(selezionata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registra() {
|
||||||
|
window.Livewire.on('piantina-aggiornata', ({ piantina }) => {
|
||||||
|
disegna(piantina);
|
||||||
|
disegnaElenco();
|
||||||
|
aggiornaBarra();
|
||||||
|
});
|
||||||
|
window.Livewire.on('piantina-rimossa', ({ mappaId }) => {
|
||||||
|
if (selezionata === mappaId) selezionata = null;
|
||||||
|
rimuovi(mappaId);
|
||||||
|
aggiornaBarra();
|
||||||
|
});
|
||||||
|
window.Livewire.on('piantina-selezionata', ({ mappaId, inquadra: centra }) => seleziona(mappaId, centra));
|
||||||
|
window.Livewire.on('editor-disegna', () => avviaDisegno(false));
|
||||||
|
window.Livewire.on('editor-indirizzo', ({ url }) => {
|
||||||
|
if (window.location.href !== url) window.history.replaceState(window.history.state, '', url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carica() {
|
||||||
|
const dati = await componente().datiMappa();
|
||||||
|
|
||||||
if (dati.area) {
|
if (dati.area) {
|
||||||
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
|
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
|
||||||
}
|
}
|
||||||
|
selezionata = dati.selezionata ?? null;
|
||||||
|
dati.piantine.forEach(disegna);
|
||||||
|
disegnaElenco();
|
||||||
|
aggiornaBarra();
|
||||||
|
|
||||||
layerSalvate.clearLayers();
|
if (selezionata !== null && layers.has(selezionata)) {
|
||||||
let layerSelezionata = null;
|
inquadra(selezionata);
|
||||||
|
} else if (layerArea) {
|
||||||
for (const piantina of dati.piantine) {
|
mappa.fitBounds(layerArea.getBounds().pad(0.08));
|
||||||
const selezionata = dati.selezionata === piantina.mappaId;
|
} else if (layers.size) {
|
||||||
const layer = L.geoJSON(
|
mappa.fitBounds(gruppo.getBounds().pad(0.08));
|
||||||
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
|
|
||||||
{ style: stileDi(piantina, selezionata) }
|
|
||||||
);
|
|
||||||
layer.bindTooltip(piantina.etichetta, { sticky: true });
|
|
||||||
layer.on('click', () => {
|
|
||||||
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
|
|
||||||
});
|
|
||||||
layerSalvate.addLayer(layer);
|
|
||||||
|
|
||||||
if (selezionata) {
|
|
||||||
layerSelezionata = layer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aprire una piantina dall'elenco deve portarci sopra; un salvataggio
|
|
||||||
// no, altrimenti la vista salta via mentre si sta ancora lavorando.
|
|
||||||
if (adattaVista && layerSelezionata) {
|
|
||||||
mappa.fitBounds(layerSelezionata.getBounds().pad(0.25));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adattaVista) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
|
|
||||||
if (riferimento) {
|
|
||||||
mappa.fitBounds(riferimento.getBounds().pad(0.08));
|
|
||||||
} else {
|
} else {
|
||||||
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
|
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arrivati da "Nuovo territorio": si comincia subito a disegnare.
|
||||||
|
if (componente().creaTerritorio) avviaDisegno(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
ridisegna(dati, true);
|
mappa.setView([41.9, 12.5], 6);
|
||||||
|
|
||||||
mappa.on(L.Draw.Event.CREATED, (evento) => {
|
const avvia = () => { registra(); carica(); };
|
||||||
if (layerInAttesa) {
|
if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) {
|
||||||
mappa.removeLayer(layerInAttesa);
|
avvia();
|
||||||
}
|
} else {
|
||||||
layerInAttesa = evento.layer;
|
document.addEventListener('livewire:initialized', avvia, { once: true });
|
||||||
layerInAttesa.setStyle(STILE_IN_ATTESA);
|
|
||||||
layerInAttesa.addTo(mappa);
|
|
||||||
|
|
||||||
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
|
|
||||||
});
|
|
||||||
|
|
||||||
let registrato = false;
|
|
||||||
document.addEventListener('livewire:init', registraEventi);
|
|
||||||
if (window.Livewire) {
|
|
||||||
registraEventi();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registraEventi() {
|
// Pannelli che si aprono e chiudono cambiano le misure del contenitore:
|
||||||
if (registrato) {
|
// senza, Leaflet resta convinto di quelle di partenza e le tessere si
|
||||||
return;
|
// disallineano dal puntatore.
|
||||||
}
|
new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore);
|
||||||
registrato = true;
|
|
||||||
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi, inquadra }) => {
|
|
||||||
// Dopo un salvataggio il poligono provvisorio è diventato
|
|
||||||
// definitivo (o è stato scartato): via il tracciato rosso.
|
|
||||||
if (layerInAttesa) {
|
|
||||||
mappa.removeLayer(layerInAttesa);
|
|
||||||
layerInAttesa = null;
|
|
||||||
}
|
|
||||||
ridisegna(nuovi, Boolean(inquadra));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// La mappa nasce dentro un contenitore che il pannello laterale può
|
|
||||||
// coprire: senza questo, Leaflet resta convinto delle misure di partenza e
|
|
||||||
// le tessere si disallineano dal puntatore.
|
|
||||||
window.addEventListener('resize', () => mappa.invalidateSize());
|
|
||||||
setTimeout(() => mappa.invalidateSize(), 60);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
101
resources/js/scelta.js
Normal file
101
resources/js/scelta.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// ─── Scelta con ricerca: la logica, senza DOM ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Le proposte per quello che si scrive in un campo di scelta (territorio,
|
||||||
|
// proclamatore). Funzioni pure, provate da tests/js/scelta.test.mjs.
|
||||||
|
//
|
||||||
|
// Un'opzione è { valore, etichetta, dettaglio?, chiavi: [stringhe], motivo?,
|
||||||
|
// speciale? }:
|
||||||
|
// chiavi i campi in cui cercare, in ordine di importanza (cognome prima
|
||||||
|
// del nome, numero prima della zona)
|
||||||
|
// motivo l'opzione c'è ma non si può scegliere ("già assegnato a …"): si
|
||||||
|
// mostra solo cercando, mai nella tendina completa
|
||||||
|
// speciale sempre in cima e mai filtrata ("— nessun territorio —")
|
||||||
|
//
|
||||||
|
// Punteggio di ogni parola scritta, più basso = più vicino:
|
||||||
|
// 0 uguale · 1 inizia con · 2 contiene · 3 a una lettera di distanza
|
||||||
|
// ("forse cercavi"), più 0.1 per ogni posizione della chiave.
|
||||||
|
// Devono trovare posto tutte le parole scritte, in qualsiasi ordine
|
||||||
|
// ("mario ros" trova Rossi Mario).
|
||||||
|
|
||||||
|
export function normalizza(testo) {
|
||||||
|
return (testo ?? '')
|
||||||
|
.toString()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distanza di modifica tra due parole, fermandosi appena supera `limite`. */
|
||||||
|
export function distanza(a, b, limite = 2) {
|
||||||
|
if (Math.abs(a.length - b.length) > limite) return limite + 1;
|
||||||
|
let precedente = Array.from({ length: b.length + 1 }, (_, j) => j);
|
||||||
|
for (let i = 1; i <= a.length; i++) {
|
||||||
|
const riga = [i];
|
||||||
|
let minimo = i;
|
||||||
|
for (let j = 1; j <= b.length; j++) {
|
||||||
|
const costo = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
riga[j] = Math.min(precedente[j] + 1, riga[j - 1] + 1, precedente[j - 1] + costo);
|
||||||
|
minimo = Math.min(minimo, riga[j]);
|
||||||
|
}
|
||||||
|
if (minimo > limite) return limite + 1;
|
||||||
|
precedente = riga;
|
||||||
|
}
|
||||||
|
return precedente[b.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERFETTO = 0, INIZIA = 1, CONTIENE = 2, VICINO = 3;
|
||||||
|
|
||||||
|
function punteggioParola(parola, chiavi) {
|
||||||
|
let migliore = Infinity;
|
||||||
|
chiavi.forEach((chiave, posizione) => {
|
||||||
|
if (chiave === '') return;
|
||||||
|
let p = Infinity;
|
||||||
|
if (chiave === parola) p = PERFETTO;
|
||||||
|
else if (chiave.startsWith(parola) || chiave.split(/\s+/).some((w) => w.startsWith(parola))) p = INIZIA;
|
||||||
|
else if (chiave.includes(parola)) p = CONTIENE;
|
||||||
|
else if (parola.length >= 4) {
|
||||||
|
// Errori di battitura: una lettera (due dalle 7 lettere in su), su
|
||||||
|
// parole intere. Mai sui numeri brevi: "35" non deve trovare "36".
|
||||||
|
const tolleranza = parola.length >= 7 ? 2 : 1;
|
||||||
|
if (chiave.split(/\s+/).some((w) => distanza(w, parola, tolleranza) <= tolleranza)) p = VICINO;
|
||||||
|
}
|
||||||
|
if (p !== Infinity) migliore = Math.min(migliore, p + posizione * 0.1);
|
||||||
|
});
|
||||||
|
return migliore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le opzioni da proporre per `testo`.
|
||||||
|
* Campo vuoto: la tendina completa (speciali e sceglibili, nell'ordine dato).
|
||||||
|
* Altrimenti: le opzioni in cui trovano posto tutte le parole, dalla più
|
||||||
|
* vicina; `forse` segna quelle trovate solo per un errore di battitura.
|
||||||
|
*/
|
||||||
|
export function proponi(opzioni, testo, massimo = 50) {
|
||||||
|
const speciali = opzioni.filter((o) => o.speciale);
|
||||||
|
const normali = opzioni.filter((o) => !o.speciale);
|
||||||
|
const parole = normalizza(testo).split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
if (parole.length === 0) {
|
||||||
|
return [...speciali, ...normali.filter((o) => !o.motivo)].map((o) => ({ ...o, forse: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const trovate = [];
|
||||||
|
normali.forEach((opzione, ordine) => {
|
||||||
|
const chiavi = (opzione.chiavi ?? [opzione.etichetta]).map(normalizza);
|
||||||
|
let totale = 0;
|
||||||
|
let forse = false;
|
||||||
|
for (const parola of parole) {
|
||||||
|
const p = punteggioParola(parola, chiavi);
|
||||||
|
if (p === Infinity) return;
|
||||||
|
if (p >= VICINO) forse = true;
|
||||||
|
totale += p;
|
||||||
|
}
|
||||||
|
trovate.push({ opzione, totale, ordine, forse });
|
||||||
|
});
|
||||||
|
|
||||||
|
trovate.sort((a, b) => a.totale - b.totale || a.ordine - b.ordine);
|
||||||
|
|
||||||
|
return [...speciali.map((o) => ({ ...o, forse: false })),
|
||||||
|
...trovate.slice(0, massimo).map((t) => ({ ...t.opzione, forse: t.forse }))];
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Link scaduto — TerManager2</title>
|
<title>Link scaduto — TerManager2</title>
|
||||||
|
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
|
||||||
<title>Territorio {{ $assignment->territorio?->numero }}</title>
|
<title>Territorio {{ $assignment->territorio?->numero }}</title>
|
||||||
|
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|||||||
@@ -48,9 +48,19 @@
|
|||||||
sanno a quale account associare la credenziale che salvano. --}}
|
sanno a quale account associare la credenziale che salvano. --}}
|
||||||
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
|
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
|
||||||
|
|
||||||
|
@unless($forced)
|
||||||
|
{{-- Dopo un ripristino non si chiede: la password attuale e'
|
||||||
|
quella del backup, che si puo' non ricordare. --}}
|
||||||
|
<div>
|
||||||
|
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
|
||||||
|
<input type="password" name="current_password" id="current_password" required autofocus autocomplete="current-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||||
|
</div>
|
||||||
|
@endunless
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
|
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
|
||||||
<input type="password" name="password" id="password" required autofocus autocomplete="new-password"
|
<input type="password" name="password" id="password" required @if($forced) autofocus @endif autocomplete="new-password"
|
||||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||||
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
|
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
resources/views/auth/verifica-email.blade.php
Normal file
50
resources/views/auth/verifica-email.blade.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<x-layouts.guest title="Conferma il tuo indirizzo email">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">Conferma il tuo indirizzo email</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="rounded-lg bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-800">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Per usare TerManager2 devi confermare che questo indirizzo è tuo: apri il link nell'email che ti
|
||||||
|
abbiamo mandato. Se non l'hai ricevuta, o il link è scaduto, chiedine una nuova.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@unless($postaFunzionante)
|
||||||
|
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Il server di posta non è ancora configurato: l'email non può partire.
|
||||||
|
@can('settings.manage')
|
||||||
|
<a href="{{ route('posta.edit') }}" class="font-semibold underline">Configuralo qui</a>.
|
||||||
|
@else
|
||||||
|
Avvisa un amministratore.
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
@endunless
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('verification.send') }}">
|
||||||
|
@csrf
|
||||||
|
<x-btn variante="primary" type="submit" class="w-full">Inviami l'email di conferma</x-btn>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 pt-1 border-t border-gray-100">
|
||||||
|
<span>L'indirizzo è sbagliato? <a href="{{ route('profile') }}" class="text-indigo-700 underline">Correggilo nel profilo</a>.</span>
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
<x-btn variante="ghost" dimensione="sm" type="submit">Esci</x-btn>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-layouts.guest>
|
||||||
50
resources/views/components/avvisi.blade.php
Normal file
50
resources/views/components/avvisi.blade.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{{--
|
||||||
|
Avvisi galleggianti: raccolgono l'evento "avviso" che i componenti
|
||||||
|
Livewire lanciano con il trait Avvisa, e che arriva anche dopo un'azione
|
||||||
|
che ridisegna solo il componente.
|
||||||
|
|
||||||
|
sessione: true per mostrare anche i messaggi flash della richiesta (il
|
||||||
|
telaio a schermo intero, che non ha una fascia messaggi sua). Il telaio
|
||||||
|
normale li mostra già in testa alla pagina, quindi lì resta false.
|
||||||
|
|
||||||
|
Gli avvisi di conferma si chiudono da soli; quelli di errore restano
|
||||||
|
finché non li si chiude, perché vanno letti.
|
||||||
|
--}}
|
||||||
|
@props(['sessione' => false])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$iniziali = [];
|
||||||
|
if ($sessione && session()->has('success')) {
|
||||||
|
$iniziali[] = ['tipo' => 'ok', 'testo' => session('success')];
|
||||||
|
}
|
||||||
|
if ($sessione && session()->has('error')) {
|
||||||
|
$iniziali[] = ['tipo' => 'errore', 'testo' => session('error')];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="tm-avvisi" role="status" aria-live="polite"
|
||||||
|
x-data="{
|
||||||
|
avvisi: [],
|
||||||
|
prossimo: 0,
|
||||||
|
aggiungi(tipo, testo) {
|
||||||
|
const id = ++this.prossimo;
|
||||||
|
this.avvisi.push({ id, tipo, testo });
|
||||||
|
if (tipo !== 'errore') setTimeout(() => this.togli(id), 4500);
|
||||||
|
},
|
||||||
|
togli(id) { this.avvisi = this.avvisi.filter(a => a.id !== id); },
|
||||||
|
}"
|
||||||
|
x-init="@js($iniziali).forEach(a => aggiungi(a.tipo, a.testo))"
|
||||||
|
@avviso.window="aggiungi($event.detail.tipo, $event.detail.testo)">
|
||||||
|
<template x-for="avviso in avvisi" :key="avviso.id">
|
||||||
|
<div class="tm-avviso" :class="avviso.tipo === 'errore' ? 'tm-avviso-errore' : 'tm-avviso-ok'"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-end="opacity-0">
|
||||||
|
<svg x-show="avviso.tipo !== 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
<svg x-show="avviso.tipo === 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<span x-text="avviso.testo"></span>
|
||||||
|
<button type="button" @click="togli(avviso.id)" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<title>{{ $title ?? 'TerManager2' }}</title>
|
<title>{{ $title ?? 'TerManager2' }}</title>
|
||||||
|
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
@@ -17,13 +18,16 @@
|
|||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 items-center justify-between">
|
<div class="flex h-16 items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<x-btn tono="velato" icona class="lg:hidden"
|
{{-- Nascosto dal contenitore: vedi il profilo qui sotto. --}}
|
||||||
|
<div class="lg:hidden">
|
||||||
|
<x-btn tono="velato" icona
|
||||||
x-data x-on:click="$dispatch('toggle-sidebar')"
|
x-data x-on:click="$dispatch('toggle-sidebar')"
|
||||||
aria-label="Apri il menu di navigazione">
|
aria-label="Apri il menu di navigazione">
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
</svg>
|
</svg>
|
||||||
</x-btn>
|
</x-btn>
|
||||||
|
</div>
|
||||||
@php $settings = \App\Models\Setting::first(); @endphp
|
@php $settings = \App\Models\Setting::first(); @endphp
|
||||||
@if($settings && $settings->logo_path)
|
@if($settings && $settings->logo_path)
|
||||||
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
|
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
|
||||||
@@ -35,14 +39,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
{{-- Il contenitore si nasconde, non il pulsante: .tm-btn sta
|
||||||
|
fuori dai layer di Tailwind e il suo display vince
|
||||||
|
su "hidden", così su telefono nome ed email
|
||||||
|
spingevano "Esci" fuori dallo schermo. Il profilo
|
||||||
|
resta raggiungibile dal menu. --}}
|
||||||
|
<div class="hidden sm:block">
|
||||||
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
|
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
|
||||||
class="hidden sm:flex {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
|
class="{{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||||
<span class="flex flex-col leading-tight text-left">
|
<span class="flex flex-col leading-tight text-left">
|
||||||
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
|
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
|
||||||
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
|
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</x-btn>
|
</x-btn>
|
||||||
|
</div>
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<x-btn tono="velato" dimensione="sm" type="submit">
|
<x-btn tono="velato" dimensione="sm" type="submit">
|
||||||
@@ -212,6 +223,11 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della
|
||||||
|
richiesta restano nella fascia in testa al contenuto, qui sopra. --}}
|
||||||
|
<x-avvisi />
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user