Compare commits
72 Commits
9c37c1ad7b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635982bf90 | ||
|
|
726af6d5c3 | ||
|
|
d198ad0b54 | ||
|
|
2e1da51080 | ||
|
|
5b4903d1cb | ||
|
|
1da31ce600 | ||
|
|
7904c96db8 | ||
|
|
5e40f0fe74 | ||
|
|
18fe914da5 | ||
|
|
2fb9838a0e | ||
|
|
9e473cd7fd | ||
|
|
8b6bc0796a | ||
|
|
8ce3d6d10d | ||
| db5772d7f5 | |||
|
|
5fb42b05c7 | ||
|
|
7d479a2c34 | ||
|
|
f1f340803a | ||
|
|
6f88c65d73 | ||
|
|
ab7d26ae41 | ||
|
|
cd3569a327 | ||
|
|
eda3c3f989 | ||
|
|
699e2ef173 | ||
|
|
0ce5bd8c89 | ||
|
|
ce98165667 | ||
|
|
d2d6b1bdce | ||
|
|
ec2e7bd7e9 | ||
|
|
69250ecb8c | ||
|
|
aaffeb7de7 | ||
|
|
a93539242a | ||
|
|
7a54c15852 | ||
|
|
4d9e445cba | ||
|
|
9999a3b031 | ||
|
|
93bc9cf2fe | ||
|
|
6489fb627d | ||
|
|
f48d0e96c0 | ||
|
|
96084499f5 | ||
|
|
5d0ae06eca | ||
|
|
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 |
@@ -30,6 +30,8 @@ public/build
|
||||
public/storage
|
||||
public/hot
|
||||
|
||||
_ARCHIVE
|
||||
|
||||
db_data
|
||||
redis_data
|
||||
|
||||
|
||||
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
|
||||
# perdi non sono piu' leggibili nemmeno avendo il backup del database.
|
||||
# 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=
|
||||
|
||||
LOG_CHANNEL=stderr
|
||||
@@ -122,15 +126,24 @@ SESSION_SECURE_COOKIE=false
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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 locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025
|
||||
# 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_HOST=
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_SCHEME=tls
|
||||
MAIL_SCHEME=
|
||||
MAIL_FROM_ADDRESS=noreply@example.org
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@
|
||||
docker-compose.override.yml
|
||||
db_data/
|
||||
redis_data/
|
||||
.claude
|
||||
939
HANDOFF.md
939
HANDOFF.md
File diff suppressed because it is too large
Load Diff
336
README.md
336
README.md
@@ -1,8 +1,11 @@
|
||||
# TerManager2
|
||||
|
||||
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
|
||||
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali
|
||||
e registro storico completo.
|
||||
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
|
||||
gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
|
||||
territori), un modulo pubblico con cui i proclamatori chiedono un territorio o
|
||||
ne annunciano la riconsegna, l'elenco delle cartoline cartacee fuori, il
|
||||
modulo S-13 per email ogni mese e registro storico completo.
|
||||
|
||||
I dati personali dei proclamatori sono **cifrati nel database**, e ogni
|
||||
operazione è tracciata in un **audit log** consultabile.
|
||||
@@ -49,7 +52,7 @@ eseguito né `composer install` né `npm build` all'avvio.
|
||||
|----------------|------------------------------------------------|---------|
|
||||
| `app` | nginx + php-fpm — l'applicazione | via Traefik |
|
||||
| `queue-worker` | Job in coda (import PDF massivi) | no |
|
||||
| `scheduler` | Task pianificati (pulizia audit log) | no |
|
||||
| `scheduler` | Task pianificati (pulizie, S-13 mensile) | no |
|
||||
| `mariadb` | Database | no |
|
||||
| `redis` | Cache, sessioni, code | no |
|
||||
|
||||
@@ -338,6 +341,16 @@ Cosa serve sapere:
|
||||
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
|
||||
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
|
||||
@@ -401,23 +414,26 @@ Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
|
||||
non da variabili d'ambiente:
|
||||
|
||||
| Impostazione | Descrizione |
|
||||
|--------------------------------|------------------------------------------------------|
|
||||
| Nome congregazione | Mostrato nell'intestazione e nei PDF |
|
||||
| Logo | Immagine nell'intestazione |
|
||||
| URL pubblico base | Base dei link PDF condivisibili |
|
||||
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare |
|
||||
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario |
|
||||
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare |
|
||||
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide |
|
||||
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
|
||||
| Download PDF visibile | Mostra o nasconde il pulsante di download |
|
||||
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati |
|
||||
|------------------------------------|------------------------------------------------------|
|
||||
| Nome congregazione | Mostrato nell'intestazione, nei PDF e nell'informativa |
|
||||
| Soglia da assegnare (giorni) | Dopo quanti giorni dal rientro un territorio è "da assegnare" |
|
||||
| Soglia priorità (giorni) | Dopo quanti giorni dal rientro un territorio diventa prioritario |
|
||||
| Soglia rientro (giorni) | Oltre questa un'assegnazione è "da rientrare"; è anche la durata del link alla piantina |
|
||||
| Limite liste Home | Quanti territori mostrare nelle due liste della Home |
|
||||
| "Scarica PDF" nel viewer | Mostra o nasconde il pulsante di download a chi apre il link |
|
||||
| Conservazione audit (giorni) | Dopo quanti giorni le voci del registro delle attività si cancellano |
|
||||
|
||||
Il server di posta ha una pagina sua (*Impostazioni → Server di posta*). Il
|
||||
logo nell'intestazione, se c'è, arriva da un backup: la pagina non lo carica.
|
||||
|
||||
---
|
||||
|
||||
## Aggiornamenti
|
||||
|
||||
Push su `main` → **Deploy** su Dokploy. Ad ogni avvio il container:
|
||||
Push su `main` → **Deploy** su Dokploy. Se l'autodeploy non parte entro un
|
||||
paio di minuti (succede: vedi HANDOFF, *Dove gira davvero*), il deploy si
|
||||
avvia a mano dall'interfaccia o con `compose.deploy`. Ad ogni avvio il
|
||||
container:
|
||||
|
||||
1. ricrea le directory di storage e risolve `APP_KEY`
|
||||
2. attende il database
|
||||
@@ -616,12 +632,21 @@ docker exec -it <app> php artisan audit:cleanup
|
||||
docker exec -it <app> php artisan backup:cleanup
|
||||
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
|
||||
|
||||
# Pulizia manuale delle richieste dal modulo pubblico gestite da più di 90 giorni
|
||||
docker exec -it <app> php artisan richieste:pulizia
|
||||
|
||||
# Mandare subito il modulo S-13 agli amministratori (anno in corso, o un altro)
|
||||
docker exec -it <app> php artisan registro:invia-s13
|
||||
docker exec -it <app> php artisan registro:invia-s13 --anno=2025-2026
|
||||
|
||||
# Svuotare le cache
|
||||
docker exec -it <app> php artisan optimize:clear
|
||||
```
|
||||
|
||||
Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i
|
||||
comandi qui sopra servono solo per anticiparle.
|
||||
Le pulizie girano già da sole ogni notte e il modulo S-13 parte da solo
|
||||
l'ultima domenica del mese alle 8 (container `scheduler`); i comandi qui sopra
|
||||
servono solo per anticiparli. Senza un server di posta funzionante il modulo
|
||||
S-13 non parte (il comando lo dice).
|
||||
|
||||
---
|
||||
|
||||
@@ -677,14 +702,24 @@ docker compose --profile dev up -d --build
|
||||
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
|
||||
- Media mensile di percorrenza e durata media delle assegnazioni
|
||||
- Campagna attiva con barra di progresso
|
||||
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
|
||||
**da rientrare**
|
||||
- Due liste rapide con azione diretta: **da assegnare** (prima i prioritari,
|
||||
con il badge ★, poi quelli fermi da più tempo) e **da rientrare**; quando
|
||||
l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre
|
||||
l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con
|
||||
il territorio scelto e senza l'elenco dei territori assegnati
|
||||
- **Richieste** dal modulo pubblico (amministratore e assistenti): vedi
|
||||
[Richieste dal modulo pubblico](#richieste-dal-modulo-pubblico)
|
||||
|
||||
### Territori
|
||||
- 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)
|
||||
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
|
||||
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico:
|
||||
una tabella sola con le colonne in linea, l'anno come riga di separazione,
|
||||
dal più recente (lo stesso nella scheda del proclamatore)
|
||||
- Link PDF pubblico condivisibile, a scadenza configurabile
|
||||
- Soft delete con cestino (ripristino o eliminazione definitiva)
|
||||
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background
|
||||
@@ -696,12 +731,124 @@ docker compose --profile dev up -d --build
|
||||
- Soft delete con cestino
|
||||
|
||||
### 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
|
||||
- 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; ogni territorio ha il pulsante **Rientro**, che
|
||||
porta alla registrazione del rientro e poi torna all'elenco. Aperta da
|
||||
"Assegna →" della Home, la pagina mostra solo il modulo, senza l'elenco
|
||||
- Rientro con calcolo automatico dei giorni
|
||||
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene
|
||||
chiesto se conteggiarlo
|
||||
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
|
||||
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)
|
||||
- **Cartolina cartacea**: assegnando (anche il sorvegliante di gruppo) si può
|
||||
spuntare *Ha chiesto la cartolina cartacea*. Il segno sta sull'assegnazione:
|
||||
nell'elenco degli assegnati compare l'etichetta "cartaceo" e la pagina
|
||||
**Cartacei** (menu, sotto *Assegnazioni*) elenca i territori cartacei fuori
|
||||
e a chi, con i giorni e il pulsante Rientro. Al rientro escono dall'elenco
|
||||
|
||||
### Richieste dal modulo pubblico
|
||||
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
|
||||
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
|
||||
*nuovo territorio* o *riconsegna*, una nota. Nella riconsegna il territorio
|
||||
si sceglie con il **completamento automatico**: si scrive il numero o la
|
||||
zona e compaiono i territori (attivi, o disattivati ma ancora fuori), con
|
||||
numero e zona soltanto; si accetta solo un territorio dell'elenco. A parte
|
||||
questo elenco il modulo non mostra nessun dato già presente: né nomi, né a
|
||||
chi è assegnato un territorio o se lo è
|
||||
- **Contro lo spam**: un campo trappola nascosto, un tempo minimo fra apertura
|
||||
e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5
|
||||
richieste all'ora dallo stesso indirizzo
|
||||
- **Avviso per email** ad amministratori e assistenti (permesso
|
||||
`richieste.gestisci`) con l'email confermata, se il server di posta
|
||||
funziona; la richiesta resta comunque nella Home
|
||||
- **Nella Home**, riquadro *Richieste*: per un nuovo territorio i proclamatori
|
||||
attivi con un nome simile a quello scritto (uguale, iniziale, una lettera
|
||||
sbagliata, in qualsiasi ordine e senza accenti), ognuno porta al modulo
|
||||
*Assegna* già con quel proclamatore; *Nessuno di questi* apre il modulo
|
||||
vuoto. Per una riconsegna, a chi risulta assegnato il territorio e *Registra
|
||||
rientro →*. Fatta l'assegnazione o il rientro, la richiesta risulta evasa;
|
||||
altrimenti si può **scartare**
|
||||
- Nome e nota sono cifrati nel database e non vanno nel registro delle
|
||||
attività; le richieste gestite si cancellano da sole dopo 90 giorni
|
||||
(`richieste:pulizia`, ogni notte)
|
||||
|
||||
### Gruppi di servizio
|
||||
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
|
||||
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
|
||||
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
|
||||
anche membro
|
||||
- Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
|
||||
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
|
||||
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
|
||||
è già presa, un numero in coda), e una password temporanea. Si vedono una
|
||||
volta sola; la password va cambiata al primo accesso (la pagina dice
|
||||
"Password temporanea", non "Ripristino da backup"). L'email non si
|
||||
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
|
||||
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
|
||||
temporanea dalla pagina del gruppo o dal pannello *Utenti* ("Nuova password
|
||||
temporanea" sulla riga del sorvegliante): si vede una volta sola, va
|
||||
cambiata al primo accesso, e le sessioni aperte con quella vecchia si
|
||||
chiudono
|
||||
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
|
||||
**in ordine di priorità** come "Da assegnare" nella Home (prima i
|
||||
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
|
||||
tipologia e "solo prioritari", e "Seleziona tutti quelli mostrati".
|
||||
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
|
||||
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
|
||||
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
|
||||
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
|
||||
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
|
||||
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
|
||||
apre la piantina e rimanda in reparto quelli non ancora dati. Il suo
|
||||
pacchetto è in ordine di priorità (prima i da dare, prioritari in testa,
|
||||
poi quelli già dati) e si filtra per stato, zona, tipologia e "solo
|
||||
prioritari"; sotto il nome del gruppo compaiono nome e cognome del
|
||||
sorvegliante. Toccando il numero di un territorio si apre l'**anteprima**
|
||||
(la miniatura della piantina) con il collegamento al PDF
|
||||
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
|
||||
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
|
||||
gruppo**, che non resta nello storico: i territori solo nel pacchetto
|
||||
tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
|
||||
elimina** (anche una sessione aperta si chiude): torna un proclamatore come
|
||||
gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
|
||||
gruppo; nel registro delle attività le sue voci restano a nome
|
||||
"Sorvegliante del gruppo …". I territori già dati restano ai membri come
|
||||
assegnazioni qualsiasi, che del gruppo conservano solo il nome ("via
|
||||
gruppo …"). Per ripartire si crea un gruppo nuovo
|
||||
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
|
||||
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
|
||||
se lo si sposta nel cestino o lo si anonimizza. Prima di disattivarlo o
|
||||
cestinarlo dall'elenco si chiede conferma; riattivarlo non ricostruisce il
|
||||
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
|
||||
cestino esce dal gruppo
|
||||
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
|
||||
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
|
||||
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
|
||||
proclamatore (Assegna, Registro) la nota "gruppo …" accanto al nome, che
|
||||
resta comunque sceglibile
|
||||
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
|
||||
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
|
||||
gruppo, email per entrare, stato della password e ultimo accesso) o membro; il
|
||||
sorvegliante ritrova gli stessi numeri nel suo
|
||||
*Profilo*
|
||||
|
||||
### Campagne
|
||||
- CRUD con date di inizio e fine
|
||||
@@ -712,6 +859,10 @@ docker compose --profile dev up -d --build
|
||||
### Registro assegnazioni
|
||||
- Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale
|
||||
- Colonne: territorio, proclamatore, date, giorni, campagna
|
||||
- **Modulo S-13 per email**: l'ultima domenica di ogni mese alle 8 il modulo
|
||||
S-13 dell'anno di servizio in corso parte in PDF agli amministratori,
|
||||
all'email della loro utenza se confermata (`registro:invia-s13`, anche a
|
||||
mano). Non passa dalla coda: il PDF contiene i nomi dei proclamatori
|
||||
|
||||
### Audit log
|
||||
- Tracciamento automatico su tutti i modelli
|
||||
@@ -738,24 +889,33 @@ docker compose --profile dev up -d --build
|
||||
|
||||
## Ruoli e permessi
|
||||
|
||||
Tre ruoli con permessi granulari (spatie/laravel-permission):
|
||||
Quattro ruoli con permessi granulari (spatie/laravel-permission):
|
||||
|
||||
| Permesso | Amministratore | Assistente | Operatore |
|
||||
|-----------------------|:--------------:|:----------:|:---------:|
|
||||
| `settings.manage` | ✅ | | |
|
||||
| `territori.manage` | ✅ | | |
|
||||
| `proclamatori.manage` | ✅ | ✅ | |
|
||||
| `campagne.manage` | ✅ | ✅ | |
|
||||
| `registro.view` | ✅ | ✅ | |
|
||||
| `registro.export` | ✅ | | |
|
||||
| `territori.assign` | ✅ | ✅ | ✅ |
|
||||
| `territori.return` | ✅ | ✅ | ✅ |
|
||||
| `audit.view` | ✅ | | |
|
||||
| `audit.export` | ✅ | | |
|
||||
| Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|
||||
|-----------------------|:--------------:|:----------:|:---------:|:------------:|
|
||||
| `settings.manage` | ✅ | | | |
|
||||
| `territori.manage` | ✅ | | | |
|
||||
| `proclamatori.manage` | ✅ | ✅ | | |
|
||||
| `campagne.manage` | ✅ | ✅ | | |
|
||||
| `registro.view` | ✅ | ✅ | | |
|
||||
| `registro.export` | ✅ | | | |
|
||||
| `territori.assign` | ✅ | ✅ | ✅ | |
|
||||
| `territori.return` | ✅ | ✅ | ✅ | |
|
||||
| `audit.view` | ✅ | | | |
|
||||
| `audit.export` | ✅ | | | |
|
||||
| `richieste.gestisci` | ✅ | ✅ | | |
|
||||
| `gruppo.gestisci` | | | | ✅ |
|
||||
|
||||
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
|
||||
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
|
||||
|
||||
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
|
||||
un gruppo di servizio e sparisce sciogliendolo. Della sua utenza, in *Utenti*,
|
||||
si può solo generare una nuova password temporanea (e aprire il gruppo). Chi ha solo quel ruolo è
|
||||
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
|
||||
dei territori del gruppo e al profilo; le sue azioni ricontrollano che
|
||||
territorio e assegnazione siano del suo gruppo.
|
||||
|
||||
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
|
||||
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
|
||||
@@ -764,13 +924,14 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
## Sicurezza e GDPR
|
||||
|
||||
### Sicurezza
|
||||
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con AES-256-CBC
|
||||
tramite `APP_KEY`
|
||||
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
|
||||
AES-256-CBC tramite `APP_KEY`
|
||||
- **Password**: hash bcrypt
|
||||
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
|
||||
richieste Livewire
|
||||
- **CSRF**: token su tutti i form
|
||||
- **Rate limiting**: sul login
|
||||
- **Rate limiting**: sul login e sul modulo pubblico delle richieste (5
|
||||
all'ora per indirizzo, più campo trappola e tempo minimo contro lo spam)
|
||||
- **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL`
|
||||
- **Scheme forwarding**: lo schema è deciso dal server, non dall'header del
|
||||
client, per non falsare la validazione dei link firmati
|
||||
@@ -779,15 +940,41 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
||||
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
||||
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
|
||||
- **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
|
||||
- **Audit trail**: registro completo delle operazioni
|
||||
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
||||
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
||||
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
||||
in chiaro
|
||||
in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
|
||||
di un proclamatore)
|
||||
- **Richieste dal modulo pubblico**: nome e nota cifrati come i dati dei
|
||||
proclamatori, fuori dal registro delle attività e dalla coda (l'email di
|
||||
avviso parte subito); cancellate 90 giorni dopo essere state gestite. Non
|
||||
sono nei backup (né XML né `.zip`, che contiene lo stesso XML): sono
|
||||
messaggi in arrivo, non dati gestionali, e un ripristino non le tocca.
|
||||
L'azzeramento (*Zona pericolosa*) le cancella
|
||||
- **Email**: il modulo S-13 (con i nomi) va solo agli amministratori con
|
||||
l'email confermata, l'avviso delle richieste solo a chi le gestisce
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
@@ -795,15 +982,17 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
|
||||
| Tabella | Descrizione |
|
||||
|-------------------|--------------------------------------------------------------|
|
||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
|
||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova (`password_temporanea` dice se il motivo è una password provvisoria data dall'amministratore invece di un ripristino); `proclamatore_id` lega l'utenza del sorvegliante (email fittizia) al suo proclamatore; sciolto il gruppo l'utenza si elimina |
|
||||
| `settings` | Configurazione singleton |
|
||||
| `zone` | Zone territoriali |
|
||||
| `tipologie` | Tipologie di territorio |
|
||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete |
|
||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
|
||||
| `gruppi_servizio` | Gruppi di servizio attivi: nome (unico), sorvegliante. Sciolto, il gruppo si elimina |
|
||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
|
||||
| `anni_teocratici` | Periodi settembre → agosto |
|
||||
| `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`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento; `cartaceo` se il proclamatore ha chiesto la cartolina cartacea |
|
||||
| `richieste` | Richieste dal modulo pubblico: tipo (`nuovo`/`riconsegna`), nome e nota cifrati, numero del territorio, stato (`aperta`/`evasa`/`scartata`), l'assegnazione che l'ha evasa, chi e quando l'ha gestita |
|
||||
| `activity_log` | Audit trail |
|
||||
|
||||
**Vincoli e indici principali**
|
||||
@@ -830,20 +1019,45 @@ Calcolato dinamicamente, non memorizzato:
|
||||
|
||||
| Stato | Condizione |
|
||||
|----------------|-----------------------------------------------------|
|
||||
| `in_reparto` | Attivo, nessuna assegnazione aperta |
|
||||
| `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi |
|
||||
| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro |
|
||||
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
|
||||
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
||||
| `inattivo` | Contrassegnato come non attivo |
|
||||
|
||||
### Priorità
|
||||
Un territorio è prioritario se lo è per **flag manuale** o perché non viene
|
||||
assegnato da più mesi della soglia configurata. La soglia automatica **vince
|
||||
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
|
||||
quando la soglia è scattata.
|
||||
Un territorio è prioritario se lo è per **flag manuale** o perché è fermo
|
||||
(dall'ultimo rientro, o dalla creazione se mai assegnato) da più giorni della
|
||||
soglia configurata. La soglia automatica **vince sempre** sul flag:
|
||||
l'amministratore può aggiungere priorità, non toglierla quando la soglia è
|
||||
scattata. Assegnare il territorio azzera il flag.
|
||||
|
||||
Dove si sceglie un territorio da dare — "Da assegnare" nella Home, l'aggiunta
|
||||
al pacchetto di un gruppo, "Il mio gruppo" — l'ordine è lo stesso: prima i
|
||||
prioritari, poi quelli fermi da più tempo, poi il numero
|
||||
(`Territorio::ordinaPerPriorita()`).
|
||||
|
||||
### Percentuale di percorrenza delle campagne
|
||||
- **Denominatore**: tutti i territori assegnati nel periodo della campagna
|
||||
- **Numeratore**: le assegnazioni contrassegnate come conteggiate
|
||||
- **Denominatore**: tutti i territori attivi
|
||||
- **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".
|
||||
|
||||
### Gruppi di servizio e statistiche
|
||||
Mettere un territorio nel pacchetto di un gruppo **non** è un'assegnazione:
|
||||
non crea righe in `assegnazioni`, quindi non tocca medie, durate, percorrenza
|
||||
delle campagne né il registro. Conta solo quando il sorvegliante lo dà a un
|
||||
membro: da lì è un'assegnazione come le altre (con `gruppo_servizio_id` per
|
||||
sapere da dove viene). Il tempo passato nel pacchetto prima di essere dato non
|
||||
viene contato. La priorità automatica guarda l'ultima assegnazione, come
|
||||
sempre.
|
||||
|
||||
---
|
||||
|
||||
@@ -854,7 +1068,7 @@ quando la soglia è scattata.
|
||||
| Componente | Tecnologia |
|
||||
|-------------------|--------------------------------------------|
|
||||
| Framework | Laravel 11 |
|
||||
| PHP | 8.3 (FPM) |
|
||||
| PHP | 8.3 (FPM), versione fissata nel Dockerfile |
|
||||
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
||||
| Database | MariaDB 11 |
|
||||
| Cache/Sessioni/Code| Redis 7 |
|
||||
@@ -869,14 +1083,16 @@ quando la soglia è scattata.
|
||||
```
|
||||
TerManager2/
|
||||
├── app/
|
||||
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin
|
||||
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin,
|
||||
│ │ # utenti:conferma-email, territori:file-orfani
|
||||
│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
|
||||
│ ├── Http/Middleware/ # RequirePasswordChange
|
||||
│ ├── Http/Middleware/ # RequirePasswordChange, LimitaSorvegliante
|
||||
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
|
||||
│ ├── Livewire/ # Tutti i componenti di interfaccia
|
||||
│ ├── Models/ # 9 modelli Eloquent
|
||||
│ ├── Livewire/ # Tutti i componenti di interfaccia (Gruppi/: gruppi di servizio)
|
||||
│ ├── Models/ # 12 modelli Eloquent
|
||||
│ ├── Providers/ # AppServiceProvider
|
||||
│ └── Services/ # Miniature PDF, stato import, archivio backup
|
||||
│ └── Services/ # Miniature PDF, import, archivio backup, posta,
|
||||
│ # piantine, modulo S-13, gruppi di servizio
|
||||
├── bootstrap/app.php # Middleware, proxy e host fidati
|
||||
├── config/ # Configurazione Laravel
|
||||
├── database/
|
||||
@@ -898,7 +1114,7 @@ TerManager2/
|
||||
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
|
||||
│ └── livewire/ # Una vista per componente
|
||||
├── routes/web.php # Rotte con i relativi permessi
|
||||
├── tests/ # Suite PHPUnit (profilo compose "test")
|
||||
├── tests/ # Suite PHPUnit (profilo compose "test") e tests/js (Node)
|
||||
├── phpunit.xml # Configurazione e ambiente dei test
|
||||
├── docker-compose.yml # Unico, produzione e sviluppo
|
||||
├── .env.example # Tutta la configurazione
|
||||
|
||||
BIN
_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx
Normal file
BIN
_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx
Normal file
Binary file not shown.
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),
|
||||
]);
|
||||
|
||||
// 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');
|
||||
|
||||
return $user;
|
||||
|
||||
75
app/Console/Commands/InviaModuloS13.php
Normal file
75
app/Console/Commands/InviaModuloS13.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\ModuloS13Mail;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\User;
|
||||
use App\Services\ConfigurazionePosta;
|
||||
use App\Services\Registro\ModuloS13;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Il modulo S-13 dell'anno di servizio in corso, per email agli
|
||||
* amministratori (l'indirizzo della loro utenza, se confermato). Lo lancia lo
|
||||
* scheduler l'ultima domenica di ogni mese (routes/console.php); a mano si
|
||||
* può scegliere l'anno con --anno=2025-2026.
|
||||
*
|
||||
* Parte subito, non in coda: il PDF contiene i nomi dei proclamatori, che in
|
||||
* coda starebbero in chiaro. Senza un server di posta funzionante non manda
|
||||
* niente e lo dice.
|
||||
*/
|
||||
class InviaModuloS13 extends Command
|
||||
{
|
||||
protected $signature = 'registro:invia-s13 {--anno= : etichetta dell\'anno di servizio, es. 2025-2026 (predefinito: quello in corso)}';
|
||||
|
||||
protected $description = 'Manda per email agli amministratori il modulo S-13 dell\'anno di servizio';
|
||||
|
||||
public function handle(ModuloS13 $modulo, ConfigurazionePosta $posta): int
|
||||
{
|
||||
if (! $posta->funzionante()) {
|
||||
$this->warn('Nessun server di posta funzionante: modulo S-13 non inviato.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$anno = $this->option('anno')
|
||||
? AnnoTeocratico::where('label', $this->option('anno'))->first()
|
||||
: AnnoTeocratico::corrente();
|
||||
if (! $anno) {
|
||||
$this->error("Anno di servizio {$this->option('anno')} non trovato.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$destinatari = User::role('amministratore')->get()
|
||||
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
|
||||
->values();
|
||||
if ($destinatari->isEmpty()) {
|
||||
$this->warn('Nessun amministratore con un\'email confermata: modulo S-13 non inviato.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$pdf = $modulo->genera($anno);
|
||||
|
||||
// Uno per uno: un indirizzo rifiutato non ferma gli altri.
|
||||
$inviati = 0;
|
||||
foreach ($destinatari as $utente) {
|
||||
try {
|
||||
Mail::to($utente->email)->send(new ModuloS13Mail($utente, $anno, $pdf));
|
||||
$inviati++;
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$this->error("Invio a {$utente->email} non riuscito: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Nel registro quanti, non chi: gli indirizzi stanno nelle utenze.
|
||||
activity()->withProperties(['anno' => $anno->label, 'destinatari' => $inviati])->log('s13_inviato');
|
||||
$this->info("Modulo S-13 {$anno->label} inviato a {$inviati} amministratori su {$destinatari->count()}.");
|
||||
|
||||
return $inviati === $destinatari->count() ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/PuliziaRichieste.php
Normal file
34
app/Console/Commands/PuliziaRichieste.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Le richieste dal modulo pubblico portano nome e note scritti da chi non ha
|
||||
* un account: gestite (evase o scartate) servono ancora un po' per
|
||||
* riferimento, poi si cancellano. Quelle aperte restano finché qualcuno non
|
||||
* le gestisce.
|
||||
*/
|
||||
class PuliziaRichieste extends Command
|
||||
{
|
||||
/** Dopo quanti giorni dalla gestione una richiesta si cancella. */
|
||||
public const GIORNI = 90;
|
||||
|
||||
protected $signature = 'richieste:pulizia';
|
||||
|
||||
protected $description = 'Cancella le richieste del modulo pubblico gestite da più di ' . self::GIORNI . ' giorni';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$cancellate = Richiesta::query()
|
||||
->where('stato', '!=', Richiesta::APERTA)
|
||||
->where('gestita_at', '<', now()->subDays(self::GIORNI))
|
||||
->delete();
|
||||
|
||||
$this->info("Richieste cancellate: {$cancellate}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ class LoginController extends Controller
|
||||
$request->session()->regenerate();
|
||||
activity()->causedBy(auth()->user())->log('login');
|
||||
|
||||
// Il sorvegliante di gruppo va dritto al suo gruppo.
|
||||
if (Auth::user()->isSorvegliante() && Auth::user()->roles->count() === 1) {
|
||||
return redirect()->route('mio-gruppo');
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Cambio password obbligatorio, imposto dopo il ripristino di un backup.
|
||||
* Cambio password obbligatorio, imposto dopo il ripristino di un backup o al
|
||||
* primo accesso con una password temporanea data dall'amministratore
|
||||
* (sorvegliante di gruppo): `password_temporanea` dice quale dei due.
|
||||
*
|
||||
* Volutamente NON e' un componente Livewire: la pagina deve restare
|
||||
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
|
||||
@@ -27,6 +29,8 @@ class PasswordChangeController extends Controller
|
||||
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
|
||||
// chi e' stato dirottato dal middleware no.
|
||||
'forced' => (bool) $user->password_change_required,
|
||||
// Perché: una password temporanea, oppure un ripristino da backup.
|
||||
'temporanea' => (bool) $user->password_temporanea,
|
||||
// Il ripristino gira in coda e non ha una sessione in cui lasciare
|
||||
// un flash; deposita l'esito in cache a nome di chi l'ha avviato e
|
||||
// lo si legge qui, che e' la prima pagina che quella persona vede.
|
||||
@@ -43,32 +47,43 @@ class PasswordChangeController extends Controller
|
||||
|
||||
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,
|
||||
// la "password attuale" di un account e' quella che aveva all'epoca del
|
||||
// backup, che l'utente puo' benissimo non ricordare — l'amministratore
|
||||
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori
|
||||
// proprio le persone che questa schermata deve sbloccare. La garanzia
|
||||
// qui e' la sessione: si arriva a questa pagina gia' autenticati.
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
], [
|
||||
// Dopo un ripristino no: gli hash sono quelli del backup, e la
|
||||
// "password attuale" di un account e' quella che aveva all'epoca, che
|
||||
// l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
|
||||
// proprio le persone che questa schermata deve sbloccare; la garanzia li'
|
||||
// e' la sessione. Fuori da quel caso, invece, bastava una sessione
|
||||
// lasciata aperta per cambiare la password a qualcun altro e chiuderlo
|
||||
// fuori dal suo account.
|
||||
$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.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.
|
||||
$user->update([
|
||||
'password' => $validated['password'],
|
||||
'password_change_required' => false,
|
||||
'password_temporanea' => false,
|
||||
]);
|
||||
|
||||
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||
// 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();
|
||||
$user->setRememberToken(null);
|
||||
$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.',
|
||||
]);
|
||||
|
||||
$operationId = (string) Str::uuid();
|
||||
|
||||
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
|
||||
// una porta d'ingresso separata: un ripristino avviato mentre gira un
|
||||
// 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([
|
||||
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.',
|
||||
'message' => $this->stato->messaggioOccupato($inCorso),
|
||||
], 409);
|
||||
}
|
||||
|
||||
$operationId = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||
$percorso = $request->file('backupZip')->storeAs(
|
||||
@@ -128,12 +133,22 @@ class BackupController extends Controller
|
||||
$operationId . '.zip',
|
||||
'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.');
|
||||
|
||||
RestoreBackupArchive::dispatch(
|
||||
|
||||
@@ -3,37 +3,30 @@
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PubblicazionePiantina;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Anteprima della cartolina, senza sostituire niente.
|
||||
* Il PDF dell'anteprima di una pubblicazione, da guardare prima di confermare.
|
||||
*
|
||||
* Il PDF viene composto al volo e servito da leggere: non tocca `pdf_path` del
|
||||
* territorio, non archivia versioni, non lascia file sul disco. Serve a
|
||||
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione
|
||||
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un
|
||||
* confine sbagliato quando è già nello storico.
|
||||
* Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
|
||||
* soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
|
||||
* e scade con l'anteprima; la rotta resta comunque riservata
|
||||
* all'amministratore come il resto delle piantine.
|
||||
*/
|
||||
class PiantinaAnteprimaController extends Controller
|
||||
{
|
||||
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response
|
||||
public function __invoke(string $token, PubblicazionePiantina $pubblicazione): Response
|
||||
{
|
||||
$mappa->load('territorio.zona');
|
||||
$percorso = $pubblicazione->pdfAnteprima($token);
|
||||
|
||||
abort_unless($mappa->collegata(), 404);
|
||||
abort_if($percorso === null, 404);
|
||||
|
||||
$pdf = $pdfService->genera($mappa->territorio, $mappa);
|
||||
|
||||
return response($pdf, 200, [
|
||||
return response()->file($percorso, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
// `inline`: si apre nel visualizzatore del browser invece di
|
||||
// finire fra i download, che di un'anteprima non se ne fa niente.
|
||||
'Content-Disposition' => 'inline; filename="anteprima-territorio-'
|
||||
. $mappa->territorio->numero . '.pdf"',
|
||||
// Un'anteprima rispecchia lo stato di adesso: se la cache la
|
||||
// congela, si continua a guardare il confine di prima.
|
||||
'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupOperationState;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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)
|
||||
{
|
||||
$request->validate([
|
||||
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
|
||||
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();
|
||||
$fileDiPrima = XmlExchange::fileDeiTerritori();
|
||||
$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'])) {
|
||||
$stato->markFailed($operationId, $result['error']);
|
||||
|
||||
return back()->withErrors(['xmlImport' => $result['error']]);
|
||||
}
|
||||
|
||||
$stato->markCompleted($operationId);
|
||||
|
||||
$stats = $result['stats'];
|
||||
$issues = $result['issues'];
|
||||
|
||||
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
|
||||
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
|
||||
$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')
|
||||
->with('success', $message)
|
||||
|
||||
88
app/Http/Controllers/TerritorioFileController.php
Normal file
88
app/Http/Controllers/TerritorioFileController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il PDF di un territorio del pacchetto, per il sorvegliante di gruppo:
|
||||
* solo se il territorio è nel pacchetto del gruppo che sorveglia.
|
||||
*/
|
||||
public function pdfGruppo(Territorio $territorio): Response
|
||||
{
|
||||
$this->delPacchetto($territorio);
|
||||
|
||||
return $this->pdf($territorio);
|
||||
}
|
||||
|
||||
public function miniatura(Territorio $territorio): Response
|
||||
{
|
||||
return $this->servi($territorio->thumbnail_path, null, null);
|
||||
}
|
||||
|
||||
/** La miniatura per l'anteprima in "Il mio gruppo", con la stessa regola del PDF. */
|
||||
public function miniaturaGruppo(Territorio $territorio): Response
|
||||
{
|
||||
$this->delPacchetto($territorio);
|
||||
|
||||
return $this->miniatura($territorio);
|
||||
}
|
||||
|
||||
/** Il territorio deve stare nel pacchetto del gruppo che l'utente sorveglia. */
|
||||
private function delPacchetto(Territorio $territorio): void
|
||||
{
|
||||
$gruppo = auth()->user()->gruppoSorvegliato();
|
||||
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
58
app/Http/Middleware/LimitaSorvegliante.php
Normal file
58
app/Http/Middleware/LimitaSorvegliante.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
|
||||
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
|
||||
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
|
||||
* componenti che può aprire controllano da sé che territori e assegnazioni
|
||||
* siano del suo gruppo, e gli altri non può caricarli.
|
||||
*
|
||||
* Vale solo per chi ha il ruolo sorvegliante e nessun altro: un utente con
|
||||
* anche un altro ruolo resta regolato dai permessi di quel ruolo. Sciolto il
|
||||
* gruppo l'utenza si elimina: una sessione ancora aperta non ha più utente.
|
||||
*/
|
||||
class LimitaSorvegliante
|
||||
{
|
||||
private const ROTTE_AMMESSE = [
|
||||
'mio-gruppo',
|
||||
'mio-gruppo.*',
|
||||
'assegnazioni.rientra',
|
||||
'profile',
|
||||
'password.change',
|
||||
'password.change.store',
|
||||
'logout',
|
||||
'verification.*',
|
||||
// Le azioni Livewire: la rotta si chiama "default.livewire.update"
|
||||
// (Livewire 3.7), non "livewire.update". Con 'livewire.*' ogni clic
|
||||
// del sorvegliante finiva in 403.
|
||||
'*livewire.*',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $user->isSorvegliante() || $user->roles->count() > 1) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->routeIs(self::ROTTE_AMMESSE)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->routeIs('dashboard')) {
|
||||
return redirect()->route('mio-gruppo');
|
||||
}
|
||||
|
||||
abort(403, 'Il sorvegliante di gruppo può usare solo la pagina del proprio gruppo.');
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ class RequirePasswordChange
|
||||
'password.change',
|
||||
'password.change.store',
|
||||
'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
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Jobs;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
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->fase($this->operationId, 'Lettura dei dati gestionali');
|
||||
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
|
||||
$this->rimuoviArchiviVecchi($archivio);
|
||||
|
||||
$xml = $exchange->currentXmlBackup();
|
||||
$datiPiantine = $piantine->esporta();
|
||||
|
||||
$stato->fase($this->operationId, 'Compressione dei file');
|
||||
|
||||
$archivio->create(
|
||||
$xml,
|
||||
$datiPiantine,
|
||||
$destinazione,
|
||||
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\User;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
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);
|
||||
$postoUnico->markRunning($this->importId);
|
||||
$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 = [];
|
||||
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);
|
||||
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
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'];
|
||||
$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])) {
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
}
|
||||
|
||||
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
|
||||
// alone is not unique: "1 A" and "1-A" are distinct
|
||||
// territory numbers that both slug to "territorio-1-a".
|
||||
// Without it the second import overwrites the first's PDF
|
||||
// and both rows point at one file — then removing the PDF
|
||||
// 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)
|
||||
. '-' . $territorio->id . '.pdf';
|
||||
. '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
|
||||
$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);
|
||||
|
||||
$territorio->update([
|
||||
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
|
||||
$stateService->appendLog($this->importId, 'Import completato.');
|
||||
$stateService->markCompleted($this->importId);
|
||||
$postoUnico->markCompleted($this->importId);
|
||||
} catch (\Throwable $exception) {
|
||||
$stateService->increment($this->importId, 'errors');
|
||||
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
|
||||
$stateService->markFailed($this->importId);
|
||||
$postoUnico->markFailed($this->importId, $exception->getMessage());
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
* 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
|
||||
* 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
|
||||
{
|
||||
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||
);
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if ($territoriMap === []) {
|
||||
return null;
|
||||
$trovati = [];
|
||||
$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;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'normalized_number' => $normalizedNumber,
|
||||
'matched_number' => $territoriMap[$normalizedNumber]->numero,
|
||||
'territorio' => $territoriMap[$normalizedNumber],
|
||||
// "1A" che non esiste, mentre 1 si': e' il caso che prima
|
||||
// sovrascriveva il territorio 1. Lo si annota per spiegarlo.
|
||||
$base = preg_replace('/\p{L}$/u', '', $codice);
|
||||
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;
|
||||
}
|
||||
|
||||
if (count($matches) > 1) {
|
||||
$territorio = reset($trovati);
|
||||
|
||||
return [
|
||||
'type' => 'ambiguous',
|
||||
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
|
||||
'type' => 'single',
|
||||
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
|
||||
'matched_number' => $territorio->numero,
|
||||
'territorio' => $territorio,
|
||||
];
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
|
||||
/**
|
||||
* I numeri di territorio presenti nel nome del file, gia' nella forma di
|
||||
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
|
||||
*
|
||||
* @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)) {
|
||||
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
|
||||
// Il lookahead finale e' quello che separa "12 B" (numero 12B) da
|
||||
// "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 ltrim($normalized, '0') ?: '0';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
|
||||
}
|
||||
}
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,13 @@ namespace App\Jobs;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
@@ -21,6 +25,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
||||
@@ -72,13 +77,21 @@ class ResetApplication implements ShouldQueue
|
||||
$stato->avanza($this->operationId, 1);
|
||||
$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', 12);
|
||||
|
||||
DB::transaction(function () use ($stato) {
|
||||
$passi = [
|
||||
// Le richieste dal modulo pubblico, prima delle assegnazioni che evadono.
|
||||
'Richieste' => fn () => Richiesta::query()->delete(),
|
||||
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||
'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(),
|
||||
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
|
||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||
'Zone' => fn () => Zona::query()->delete(),
|
||||
@@ -100,6 +113,13 @@ class ResetApplication implements ShouldQueue
|
||||
// ricreare la riga.
|
||||
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');
|
||||
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Setting;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
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);
|
||||
|
||||
$staging = null;
|
||||
$scambio = null;
|
||||
$confermato = false;
|
||||
|
||||
try {
|
||||
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
|
||||
$stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
|
||||
|
||||
$estratto = $archivio->extract(
|
||||
$this->zipPath,
|
||||
@@ -56,34 +80,47 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
);
|
||||
$staging = $estratto['staging'];
|
||||
|
||||
$piantine->valida($estratto['piantine']);
|
||||
|
||||
$stato->fase($this->operationId, 'Riscrittura del database', 1);
|
||||
|
||||
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
|
||||
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
|
||||
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
|
||||
// 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);
|
||||
|
||||
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']);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$stato->markFailed($this->operationId, $result['error']);
|
||||
|
||||
return;
|
||||
throw new RuntimeException($result['error']);
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$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
|
||||
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
|
||||
// caricata continuano a decidere gli accessi con quella di prima
|
||||
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
} 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]);
|
||||
$stato->markFailed($this->operationId, $e->getMessage());
|
||||
$stato->markFailed($this->operationId, $messaggio);
|
||||
|
||||
return;
|
||||
} finally {
|
||||
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
}
|
||||
|
||||
$stats = $result['stats'];
|
||||
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
|
||||
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
|
||||
$fileRipristinati = $scambio['file'];
|
||||
|
||||
$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.',
|
||||
$stats['territori_importati'] ?? 0,
|
||||
$stats['proclamatori_importati'] ?? 0,
|
||||
$stats['assegnazioni_importate'] ?? 0,
|
||||
$esitoPiantine['mappe'],
|
||||
$fileRipristinati,
|
||||
$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
|
||||
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
|
||||
// il middleware dirotta l'amministratore sul cambio password. Lo si
|
||||
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
$stato->log($this->operationId, $messaggio);
|
||||
$stato->markCompleted($this->operationId, [
|
||||
'stats' => $stats,
|
||||
'issues' => array_slice($result['issues'], 0, 50),
|
||||
'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
|
||||
'file_ripristinati' => $fileRipristinati,
|
||||
'messaggio' => $messaggio,
|
||||
]);
|
||||
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
* muore per cause che handle() non intercetta, cosi' il posto unico si
|
||||
* libera subito invece di restare occupato fino alla scadenza.
|
||||
*
|
||||
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino
|
||||
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi
|
||||
* con database e file disallineati, e chi legge deve sapere che la strada
|
||||
* e' rilanciare il ripristino, non ignorare l'errore.
|
||||
* Qui il messaggio dice anche in che stato si e' rimasti. Un processo
|
||||
* ucciso a meta' non arriva al commit, e il database annulla da se' la
|
||||
* transazione rimasta aperta: i dati restano quelli di prima. L'unica
|
||||
* 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
|
||||
{
|
||||
app(BackupOperationState::class)->markFailed(
|
||||
$this->operationId,
|
||||
($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);
|
||||
@@ -177,6 +250,9 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
{
|
||||
DB::table('users')->update([
|
||||
'password_change_required' => true,
|
||||
// Il motivo ora è il ripristino, anche per chi aveva una
|
||||
// password temporanea (pagina del cambio password).
|
||||
'password_temporanea' => false,
|
||||
'remember_token' => null,
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,30 +2,70 @@
|
||||
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use App\Livewire\Concerns\InviaLink;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Computed;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
class Assegna extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
use InviaLink;
|
||||
|
||||
public ?int $territorio_id = null;
|
||||
public ?int $proclamatore_id = null;
|
||||
public string $assigned_at = '';
|
||||
public string $territorioSearch = '';
|
||||
public string $linkSearch = '';
|
||||
|
||||
/** Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
|
||||
public bool $cartaceo = false;
|
||||
|
||||
/**
|
||||
* La richiesta dal modulo pubblico da cui si è partiti (?richiesta=,
|
||||
* dalla Home): si mostra sopra il modulo e, fatta l'assegnazione, risulta
|
||||
* evasa. Locked: la imposta solo mount().
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $richiestaId = null;
|
||||
|
||||
// Optional pre-selection from parent context
|
||||
public ?int $preselectedTerritorioId = null;
|
||||
|
||||
/**
|
||||
* Aperta da "Assegna →" della Home (?dalla_home=1, lo mette solo la Home)
|
||||
* la pagina serve ad assegnare quel territorio: l'elenco dei territori
|
||||
* assegnati sotto il modulo non si mostra (né si carica).
|
||||
*/
|
||||
public bool $elencoNascosto = false;
|
||||
|
||||
public function mount(?int $territorioId = null)
|
||||
{
|
||||
$this->preselectedTerritorioId = $territorioId;
|
||||
$this->territorio_id = $territorioId;
|
||||
$this->assigned_at = now()->format('Y-m-d');
|
||||
$this->elencoNascosto = request()->boolean('dalla_home');
|
||||
|
||||
// Dalla Home, da una richiesta: il proclamatore scelto fra i nomi
|
||||
// simili arriva già impostato (?proclamatore=), il resto del modulo è
|
||||
// quello di sempre.
|
||||
if (($id = request()->integer('proclamatore')) && Proclamatore::attivi()->whereKey($id)->exists()) {
|
||||
$this->proclamatore_id = $id;
|
||||
}
|
||||
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
|
||||
&& Richiesta::aperte()->whereKey($id)->exists()) {
|
||||
$this->richiestaId = $id;
|
||||
}
|
||||
|
||||
// ?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
|
||||
@@ -43,118 +83,133 @@ class Assegna extends Component
|
||||
|
||||
$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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Check territory is active
|
||||
if (!$territorio->attivo) {
|
||||
session()->flash('error', "Il territorio {$territorio->numero} è inattivo.");
|
||||
$this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante.
|
||||
if ($territorio->gruppo_servizio_id) {
|
||||
$this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto.");
|
||||
return;
|
||||
}
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||
if (!$proclamatore->attivo) {
|
||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||
return;
|
||||
}
|
||||
|
||||
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
|
||||
$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,
|
||||
'proclamatore_id' => $this->proclamatore_id,
|
||||
'anno_teocratico_id' => $annoTeocratico->id,
|
||||
'assigned_at' => $assignedDate,
|
||||
'created_by' => auth()->id(),
|
||||
// C'è un link da mandare solo se il territorio ha il PDF.
|
||||
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||
'cartaceo' => $this->cartaceo,
|
||||
]);
|
||||
});
|
||||
|
||||
if (is_string($esito)) {
|
||||
$this->addError('assigned_at', $esito);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$assegnazione = $esito;
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->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([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore' => $proclamatore->nome_completo,
|
||||
'proclamatore_id' => $proclamatore->id,
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
|
||||
return $this->redirect(route('territori.show', $territorio), navigate: true);
|
||||
// Partita da una richiesta del modulo pubblico: ora è evasa.
|
||||
if ($this->richiestaId) {
|
||||
Richiesta::find($this->richiestaId)?->evadi($assegnazione, auth()->user());
|
||||
$this->richiestaId = null;
|
||||
}
|
||||
$this->cartaceo = false;
|
||||
|
||||
// Non si va avanti: si apre l'invio del link (vedi in cima).
|
||||
$this->apriInvio($assegnazione->id);
|
||||
}
|
||||
|
||||
public function toggleLinkSent(int $assegnazioneId): void
|
||||
// ─── Invio del link (trait InviaLink) ──────────────────────
|
||||
|
||||
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
|
||||
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
|
||||
return auth()->user()->can('territori.assign');
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedThumbnailUrl(): ?string
|
||||
protected function paginaDopoInvio(): string
|
||||
{
|
||||
if (!$this->territorio_id) {
|
||||
return null;
|
||||
}
|
||||
$t = Territorio::find($this->territorio_id);
|
||||
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
|
||||
return $this->destinazione(route('assegnazioni.assegna'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$territoriQuery = Territorio::where('attivo', true)
|
||||
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
|
||||
->with(['zona', 'tipologia']);
|
||||
|
||||
if (trim($this->territorioSearch) !== '') {
|
||||
$term = trim($this->territorioSearch);
|
||||
$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()
|
||||
// Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
|
||||
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
||||
// serve all'anteprima, che si aggiorna nel browser.
|
||||
// Quelli nel pacchetto di un gruppo di servizio li assegna il
|
||||
// sorvegliante: qui si trovano ma non si scelgono.
|
||||
$territori = Territorio::attivi()
|
||||
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
|
||||
->get()
|
||||
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
|
||||
->sortBy('numero', SORT_NATURAL);
|
||||
|
||||
// All currently assigned territories with links
|
||||
$assegnazioniAperte = Assegnazione::aperte()
|
||||
->with(['territorio.zona', 'proclamatore'])
|
||||
->get()
|
||||
->sortBy(fn($a) => (int) $a->territorio?->numero);
|
||||
$opzioniTerritori = $territori
|
||||
->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
|
||||
(bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
|
||||
(bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
|
||||
default => null,
|
||||
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// Ricerca rapida nella lista dei link: numero territorio, zona,
|
||||
// proclamatore in un solo campo. In memoria perché nome/cognome del
|
||||
// proclamatore sono cifrati e non filtrabili via SQL.
|
||||
$assegnazioniAperteVisibili = $assegnazioniAperte;
|
||||
if (trim($this->linkSearch) !== '') {
|
||||
$term = mb_strtolower(trim($this->linkSearch));
|
||||
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) {
|
||||
$haystack = mb_strtolower(
|
||||
($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));
|
||||
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
||||
|
||||
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
||||
return view('livewire.assegnazioni.assegna', [
|
||||
'territoriDisponibili' => $territoriDisponibili,
|
||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
||||
'assegnazioniAperte' => $assegnazioniAperte,
|
||||
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
|
||||
'sogliaRientro' => $sogliaRientro,
|
||||
'invio' => $this->invioCorrente(),
|
||||
'opzioniTerritori' => $opzioniTerritori,
|
||||
'opzioniProclamatori' => $opzioniProclamatori,
|
||||
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
||||
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Livewire/Assegnazioni/Cartacei.php
Normal file
25
app/Livewire/Assegnazioni/Cartacei.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* I territori fuori in cartaceo, e a chi: le assegnazioni aperte per cui il
|
||||
* proclamatore ha chiesto la cartolina (spunta "cartaceo" su Assegna o in "Il
|
||||
* mio gruppo"). Al rientro escono dall'elenco.
|
||||
*/
|
||||
class Cartacei extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.assegnazioni.cartacei', [
|
||||
'assegnazioni' => Assegnazione::aperte()
|
||||
->where('cartaceo', true)
|
||||
->with(['territorio.zona', 'proclamatore'])
|
||||
->orderBy('assigned_at')
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
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,16 @@
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use Livewire\Attributes\Locked;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Richiesta;
|
||||
|
||||
class Rientra extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Assegnazione $assegnazione;
|
||||
public string $returned_at = '';
|
||||
public bool $showCampaignPrompt = false;
|
||||
@@ -22,14 +26,27 @@ class Rientra extends Component
|
||||
|
||||
public bool $counted_in_campaign = false;
|
||||
|
||||
/**
|
||||
* La richiesta di riconsegna dal modulo pubblico da cui si è partiti
|
||||
* (?richiesta=, dalla Home): registrato il rientro, risulta evasa.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $richiestaId = null;
|
||||
|
||||
public function mount(Assegnazione $assegnazione)
|
||||
{
|
||||
$this->autorizza($assegnazione);
|
||||
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
|
||||
|
||||
if ($assegnazione->returned_at) {
|
||||
abort(404, 'Assegnazione già rientrata.');
|
||||
}
|
||||
|
||||
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
|
||||
&& Richiesta::aperte()->whereKey($id)->exists()) {
|
||||
$this->richiestaId = $id;
|
||||
}
|
||||
|
||||
$this->returned_at = now()->format('Y-m-d');
|
||||
$this->checkCampaign();
|
||||
}
|
||||
@@ -66,8 +83,28 @@ class Rientra extends Component
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi ha territori.return registra qualsiasi rientro. Il sorvegliante di
|
||||
* gruppo (gruppo.gestisci) solo quelli del suo gruppo: assegnazioni nate
|
||||
* dal gruppo o territori del suo pacchetto.
|
||||
*/
|
||||
private function autorizza(Assegnazione $assegnazione): void
|
||||
{
|
||||
$utente = auth()->user();
|
||||
if ($utente->can('territori.return')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gruppo = $utente->can('gruppo.gestisci') ? $utente->gruppoSorvegliato() : null;
|
||||
abort_unless(
|
||||
$gruppo && ($assegnazione->gruppo_servizio_id === $gruppo->id || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->autorizza($this->assegnazione);
|
||||
$this->validate();
|
||||
|
||||
// mount() rejects an already-returned assignment, but the component
|
||||
@@ -92,22 +129,60 @@ class Rientra extends Component
|
||||
->performedOn($this->assegnazione)
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore' => $proclamatore->nome_completo,
|
||||
'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
|
||||
'giorni' => $this->assegnazione->giorni,
|
||||
'campagna' => $this->counted_in_campaign,
|
||||
])
|
||||
->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'
|
||||
);
|
||||
}
|
||||
|
||||
// Partito da una richiesta di riconsegna (Home): ora è evasa.
|
||||
if ($this->richiestaId) {
|
||||
Richiesta::find($this->richiestaId)?->evadi($this->assegnazione, auth()->user());
|
||||
}
|
||||
|
||||
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($this->paginaPredefinita()), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dove si torna senza una pagina di partenza: la scheda del territorio,
|
||||
* o "Il mio gruppo" per il sorvegliante, che la scheda non la vede.
|
||||
*/
|
||||
public function paginaPredefinita(): string
|
||||
{
|
||||
return auth()->user()->can('territori.assign')
|
||||
? route('territori.show', $this->assegnazione->territorio_id)
|
||||
: route('mio-gruppo');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$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', [
|
||||
'campagna' => $campagna,
|
||||
'giorniPercorrenza' => $giorniPercorrenza,
|
||||
'sogliaPercorrenza' => $sogliaPercorrenza,
|
||||
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,22 @@ class AuditLog extends Component
|
||||
$events = Activity::select('description')->distinct()->pluck('description');
|
||||
$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', [
|
||||
'activities' => $query->paginate(30),
|
||||
'activities' => $activities,
|
||||
'nomiProclamatori' => $nomiProclamatori,
|
||||
'events' => $events,
|
||||
'users' => $users,
|
||||
]);
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaCreate extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public string $descrizione = '';
|
||||
public string $start_date = '';
|
||||
public string $end_date = '';
|
||||
@@ -34,7 +37,7 @@ class CampagnaCreate extends Component
|
||||
]);
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaEdit extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Campagna $campagna;
|
||||
public string $descrizione = '';
|
||||
public string $start_date = '';
|
||||
@@ -47,7 +50,7 @@ class CampagnaEdit extends Component
|
||||
]);
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function deleteCampagna(int $id)
|
||||
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
|
||||
|
||||
$campagna = Campagna::findOrFail($id);
|
||||
$campagna->delete();
|
||||
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
|
||||
$this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -9,6 +10,8 @@ use App\Models\Territorio;
|
||||
|
||||
class CampagnaShow extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Campagna $campagna;
|
||||
|
||||
public function mount(Campagna $campagna)
|
||||
@@ -18,16 +21,28 @@ class CampagnaShow extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Assignments counted for this campaign:
|
||||
// - assigned on or after campaign start
|
||||
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
|
||||
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
|
||||
->where('counted_in_campaign', true)
|
||||
->where('assigned_at', '>=', $this->campagna->start_date)
|
||||
// Le assegnazioni contate per la campagna, cioè quelle per cui al
|
||||
// rientro si è risposto sì (campagnaApplicabile propone anche quelle
|
||||
// uscite poco prima dell'inizio e rientrate durante). Le stesse della
|
||||
// percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
|
||||
// e l'elenco era più corto di quanto la percentuale contasse.
|
||||
$conteggiate = $this->campagna->assegnazioniConteggiate()
|
||||
->with(['territorio', 'proclamatore'])
|
||||
->orderBy('returned_at')
|
||||
->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
|
||||
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
|
||||
->where(function ($q) {
|
||||
@@ -38,6 +53,7 @@ class CampagnaShow extends Component
|
||||
|
||||
return view('livewire.campagne.campagna-show', [
|
||||
'conteggiate' => $conteggiate,
|
||||
'inCorso' => $inCorso,
|
||||
'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');
|
||||
}
|
||||
}
|
||||
132
app/Livewire/Concerns/InviaLink.php
Normal file
132
app/Livewire/Concerns/InviaLink.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
/**
|
||||
* Invio del link della piantina dopo un'assegnazione, condiviso dalla pagina
|
||||
* Assegna e da "Il mio gruppo" del sorvegliante. La vista è il partial
|
||||
* livewire/assegnazioni/_invio-link.
|
||||
*
|
||||
* Dopo l'assegnazione non si va avanti: 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.
|
||||
*
|
||||
* Chi usa il trait dice chi può mandare il link di un'assegnazione
|
||||
* (puoInviare) e dove si va dopo (paginaDopoInvio).
|
||||
*/
|
||||
trait InviaLink
|
||||
{
|
||||
/** 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 = '';
|
||||
|
||||
abstract protected function puoInviare(Assegnazione $assegnazione): bool;
|
||||
|
||||
abstract protected function paginaDopoInvio(): string;
|
||||
|
||||
/**
|
||||
* Apre l'invio: subito dopo l'assegnazione, o da un'assegnazione rimasta
|
||||
* in sospeso ("Invia link" nell'elenco, che lo chiede con apri-invio).
|
||||
*/
|
||||
#[On('apri-invio')]
|
||||
public function apriInvio(int $assegnazioneId): void
|
||||
{
|
||||
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
|
||||
if (! $assegnazione) {
|
||||
return;
|
||||
}
|
||||
abort_unless($this->puoInviare($assegnazione), 403);
|
||||
|
||||
$this->invioId = $assegnazione->id;
|
||||
$this->canaleUsato = null;
|
||||
$this->erroreInvio = '';
|
||||
}
|
||||
|
||||
/** Copia link o Condividi: l'invio vero lo fa chi usa l'app, qui si annota il canale. */
|
||||
public function canale(string $canale): void
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
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 il nome.
|
||||
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->paginaDopoInvio(), navigate: true);
|
||||
}
|
||||
|
||||
/** L'invio in corso, per la vista; null se l'assegnazione nel frattempo è rientrata o sparita. */
|
||||
protected function invioCorrente(): ?Assegnazione
|
||||
{
|
||||
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
|
||||
if ($this->invioId && ! $invio) {
|
||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||
}
|
||||
|
||||
return $invio;
|
||||
}
|
||||
|
||||
private function assegnazioneInInvio(): ?Assegnazione
|
||||
{
|
||||
if (! $this->invioId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$assegnazione = Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
|
||||
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
|
||||
|
||||
return $assegnazione;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
||||
// 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.';
|
||||
$this->rifiutaPerOccupato($inCorso);
|
||||
|
||||
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();
|
||||
|
||||
$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.');
|
||||
|
||||
$this->logSpediti = 0;
|
||||
|
||||
88
app/Livewire/Gruppi/GruppiIndex.php
Normal file
88
app/Livewire/Gruppi/GruppiIndex.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Gruppi;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Services\GruppiServizio;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
|
||||
* gruppo si sceglie il sorvegliante, che riceve un'utenza con un'email
|
||||
* fittizia ricavata dal suo nome (GruppiServizio::emailFittizia) e una
|
||||
* password temporanea, mostrate qui una volta sola. Membri e pacchetto si
|
||||
* gestiscono nella pagina del gruppo (GruppoShow).
|
||||
*/
|
||||
class GruppiIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
public ?int $sorveglianteId = null;
|
||||
|
||||
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
|
||||
#[Locked]
|
||||
public ?int $creatoId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $passwordTemporanea = null;
|
||||
|
||||
public function crea(): void
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->validate([
|
||||
'nome' => 'required|string|max:100|unique:gruppi_servizio,nome',
|
||||
'sorveglianteId' => 'required|integer|exists:proclamatori,id',
|
||||
], [
|
||||
'nome.unique' => 'Esiste già un gruppo con questo nome.',
|
||||
'sorveglianteId.required' => 'Scegli il sorvegliante.',
|
||||
]);
|
||||
|
||||
try {
|
||||
[$gruppo, $password] = app(GruppiServizio::class)->crea(
|
||||
$this->nome,
|
||||
Proclamatore::findOrFail($this->sorveglianteId),
|
||||
);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->addError('sorveglianteId', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['nome', 'sorveglianteId']);
|
||||
$this->creatoId = $gruppo->id;
|
||||
$this->passwordTemporanea = $password;
|
||||
}
|
||||
|
||||
public function chiudiPassword(): void
|
||||
{
|
||||
$this->reset(['creatoId', 'passwordTemporanea']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$gruppi = GruppoServizio::with('sorvegliante')
|
||||
->withCount(['membri', 'territori'])
|
||||
->orderBy('nome')
|
||||
->get();
|
||||
|
||||
// Possono diventare sorveglianti i proclamatori attivi senza gruppo (e
|
||||
// quindi senza utenza: sciolto il gruppo, la si elimina).
|
||||
$candidati = Proclamatore::attivi()
|
||||
->whereNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('utente')
|
||||
->get();
|
||||
|
||||
return view('livewire.gruppi.gruppi-index', [
|
||||
'gruppi' => $gruppi,
|
||||
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
|
||||
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
200
app/Livewire/Gruppi/GruppoShow.php
Normal file
200
app/Livewire/Gruppi/GruppoShow.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Gruppi;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\GruppiServizio;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Un gruppo di servizio, lato amministratore: membri (aggiungere/togliere),
|
||||
* pacchetto (aggiungere in blocco, rimandare in reparto quelli non dati),
|
||||
* nuova password temporanea per il sorvegliante, scioglimento.
|
||||
*/
|
||||
class GruppoShow extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use RicordaRitorno;
|
||||
|
||||
public GruppoServizio $gruppo;
|
||||
|
||||
/** @var int[] proclamatori da aggiungere */
|
||||
public array $nuoviMembri = [];
|
||||
|
||||
/** @var int[] territori da aggiungere al pacchetto */
|
||||
public array $nuoviTerritori = [];
|
||||
|
||||
// Filtri dei territori in reparto da aggiungere al pacchetto. L'ordine è
|
||||
// quello di priorità (Territorio::ordinaPerPriorita), come nella Home.
|
||||
public string $cercaTerritorio = '';
|
||||
|
||||
public string $filtroZona = '';
|
||||
|
||||
public string $filtroTipologia = '';
|
||||
|
||||
public bool $soloPrioritari = false;
|
||||
|
||||
#[Locked]
|
||||
public ?string $passwordTemporanea = null;
|
||||
|
||||
public function mount(GruppoServizio $gruppo): void
|
||||
{
|
||||
$this->gruppo = $gruppo;
|
||||
}
|
||||
|
||||
private function servizio(): GruppiServizio
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
return app(GruppiServizio::class);
|
||||
}
|
||||
|
||||
private function esegui(callable $azione, string $ok): void
|
||||
{
|
||||
try {
|
||||
$azione();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->gruppo->refresh();
|
||||
$this->avvisa($ok);
|
||||
}
|
||||
|
||||
public function aggiungiMembri(): void
|
||||
{
|
||||
$ids = array_map('intval', $this->nuoviMembri);
|
||||
if ($ids === []) {
|
||||
$this->avvisaErrore('Spunta almeno un proclamatore.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.');
|
||||
$this->nuoviMembri = [];
|
||||
}
|
||||
|
||||
public function togliMembro(int $proclamatoreId): void
|
||||
{
|
||||
$membro = Proclamatore::findOrFail($proclamatoreId);
|
||||
$this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo.");
|
||||
}
|
||||
|
||||
public function aggiungiTerritori(): void
|
||||
{
|
||||
$ids = array_map('intval', $this->nuoviTerritori);
|
||||
if ($ids === []) {
|
||||
$this->avvisaErrore('Spunta almeno un territorio.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.');
|
||||
$this->nuoviTerritori = [];
|
||||
}
|
||||
|
||||
/** Tutti i territori che i filtri mostrano finiscono tra quelli da aggiungere. */
|
||||
public function selezionaMostrati(): void
|
||||
{
|
||||
$this->nuoviTerritori = collect($this->nuoviTerritori)
|
||||
->map(fn ($id) => (string) $id)
|
||||
->merge($this->candidatiFiltrati()->pluck('id')->map(fn ($id) => (string) $id))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function deselezionaTutti(): void
|
||||
{
|
||||
$this->nuoviTerritori = [];
|
||||
}
|
||||
|
||||
/** I territori in reparto, in ordine di priorità. */
|
||||
private function candidati(): \Illuminate\Support\Collection
|
||||
{
|
||||
return Territorio::ordinaPerPriorita(
|
||||
Territorio::inReparto()->with(['zona', 'tipologia', 'ultimaAssegnazione'])->get()
|
||||
);
|
||||
}
|
||||
|
||||
private function candidatiFiltrati(?\Illuminate\Support\Collection $candidati = null): \Illuminate\Support\Collection
|
||||
{
|
||||
$cerca = Str::lower(Str::ascii(trim($this->cercaTerritorio)));
|
||||
|
||||
return ($candidati ?? $this->candidati())
|
||||
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
|
||||
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
|
||||
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
|
||||
->when($cerca !== '', fn ($c) => $c->filter(fn (Territorio $t) => str_contains(
|
||||
Str::lower(Str::ascii("{$t->numero} {$t->zona?->nome} {$t->tipologia?->nome}")),
|
||||
$cerca
|
||||
)))
|
||||
->values();
|
||||
}
|
||||
|
||||
public function rimanda(int $territorioId): void
|
||||
{
|
||||
$territorio = Territorio::findOrFail($territorioId);
|
||||
$this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto.");
|
||||
}
|
||||
|
||||
/** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */
|
||||
public function nuovaPassword(): void
|
||||
{
|
||||
$this->servizio();
|
||||
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
|
||||
if (! $utente) {
|
||||
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
|
||||
* torna all'elenco dei gruppi.
|
||||
*/
|
||||
public function sciogli()
|
||||
{
|
||||
$nome = $this->gruppo->nome;
|
||||
$this->servizio()->sciogli($this->gruppo);
|
||||
|
||||
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è stata eliminata.");
|
||||
return $this->redirect(route('gruppi.index'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->gruppo->load(['sorvegliante.utente']);
|
||||
|
||||
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
|
||||
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
|
||||
|
||||
// I territori in reparto (attivi, non assegnati, fuori da ogni
|
||||
// pacchetto), in ordine di priorità; zone e tipologie dei filtri sono
|
||||
// quelle che ci sono tra loro.
|
||||
$candidati = $this->candidati();
|
||||
|
||||
return view('livewire.gruppi.gruppo-show', [
|
||||
'membri' => $membri,
|
||||
'territori' => $territori,
|
||||
// Si possono aggiungere i proclamatori attivi senza gruppo...
|
||||
'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
|
||||
// ...e i territori in reparto.
|
||||
'totaleCandidati' => $candidati->count(),
|
||||
'candidatiTerritori' => $this->candidatiFiltrati($candidati),
|
||||
'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
|
||||
'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
227
app/Livewire/Gruppi/MioGruppo.php
Normal file
227
app/Livewire/Gruppi/MioGruppo.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Gruppi;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Livewire\Concerns\InviaLink;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Services\GruppiServizio;
|
||||
use Carbon\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* "Il mio gruppo": la sola pagina del sorvegliante di gruppo. Vede il
|
||||
* pacchetto del suo gruppo e i membri; dà i territori ai membri (o a sé),
|
||||
* manda il link, registra i rientri (pagina Rientra), apre la piantina e
|
||||
* rimanda in reparto quelli non ancora dati. Le regole stanno in
|
||||
* App\Services\GruppiServizio; qui ogni azione ricontrolla che il territorio
|
||||
* o l'assegnazione siano del suo gruppo.
|
||||
*/
|
||||
class MioGruppo extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use InviaLink;
|
||||
|
||||
/** Il territorio del pacchetto che si sta assegnando. */
|
||||
#[Locked]
|
||||
public ?int $territorioDaAssegnare = null;
|
||||
|
||||
public ?int $membroId = null;
|
||||
|
||||
public string $dataAssegnazione = '';
|
||||
|
||||
/** Il membro ha chiesto la cartolina cartacea (elenco Cartacei). */
|
||||
public bool $cartaceo = false;
|
||||
|
||||
// Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano.
|
||||
#[Url(except: '')]
|
||||
public string $filtroZona = '';
|
||||
|
||||
#[Url(except: '')]
|
||||
public string $filtroTipologia = '';
|
||||
|
||||
/** '' tutti, 'da_dare', 'dati' */
|
||||
#[Url(except: '')]
|
||||
public string $filtroStato = '';
|
||||
|
||||
#[Url(except: false)]
|
||||
public bool $soloPrioritari = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->dataAssegnazione = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function gruppo(): ?GruppoServizio
|
||||
{
|
||||
return auth()->user()->gruppoSorvegliato();
|
||||
}
|
||||
|
||||
private function gruppoOFallisci(): GruppoServizio
|
||||
{
|
||||
$gruppo = $this->gruppo();
|
||||
abort_unless($gruppo, 403, 'Non sei il sorvegliante di un gruppo di servizio attivo.');
|
||||
|
||||
return $gruppo;
|
||||
}
|
||||
|
||||
private function territorioDelPacchetto(int $id): Territorio
|
||||
{
|
||||
$gruppo = $this->gruppoOFallisci();
|
||||
$territorio = Territorio::find($id);
|
||||
abort_unless($territorio && $territorio->gruppo_servizio_id === $gruppo->id, 403);
|
||||
|
||||
return $territorio;
|
||||
}
|
||||
|
||||
// ─── Assegnare un territorio del pacchetto ──────────────────
|
||||
|
||||
public function apriAssegnazione(int $territorioId): void
|
||||
{
|
||||
$this->territorioDelPacchetto($territorioId);
|
||||
$this->territorioDaAssegnare = $territorioId;
|
||||
$this->membroId = null;
|
||||
$this->cartaceo = false;
|
||||
$this->dataAssegnazione = now()->format('Y-m-d');
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
public function annullaAssegnazione(): void
|
||||
{
|
||||
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
public function assegna(): void
|
||||
{
|
||||
$this->validate([
|
||||
'membroId' => 'required|integer',
|
||||
'dataAssegnazione' => 'required|date|before_or_equal:today',
|
||||
], [
|
||||
'membroId.required' => 'Scegli a chi dare il territorio.',
|
||||
'dataAssegnazione.before_or_equal' => 'La data non può essere nel futuro.',
|
||||
]);
|
||||
|
||||
$gruppo = $this->gruppoOFallisci();
|
||||
$territorio = $this->territorioDelPacchetto((int) $this->territorioDaAssegnare);
|
||||
$membro = Proclamatore::find($this->membroId);
|
||||
|
||||
if (! $membro) {
|
||||
$this->addError('membroId', 'Scegli un membro del gruppo.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione), $this->cartaceo);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->addError('membroId', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
|
||||
|
||||
// Come dalla pagina Assegna: si apre l'invio del link.
|
||||
$this->apriInvio($assegnazione->id);
|
||||
}
|
||||
|
||||
// ─── Rimandare in reparto ───────────────────────────────────
|
||||
|
||||
public function rimanda(int $territorioId): void
|
||||
{
|
||||
$territorio = $this->territorioDelPacchetto($territorioId);
|
||||
|
||||
try {
|
||||
app(GruppiServizio::class)->rimandaInReparto($this->gruppoOFallisci(), $territorio);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->avvisa("Territorio {$territorio->numero} rimandato in reparto.");
|
||||
}
|
||||
|
||||
// ─── Link già inviato / condiviso dall'elenco ───────────────
|
||||
|
||||
private function assegnazioneDelGruppo(int $id): Assegnazione
|
||||
{
|
||||
$assegnazione = Assegnazione::aperte()->with('territorio')->find($id);
|
||||
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
|
||||
|
||||
return $assegnazione;
|
||||
}
|
||||
|
||||
public function toggleLinkSent(int $assegnazioneId): void
|
||||
{
|
||||
$assegnazione = $this->assegnazioneDelGruppo($assegnazioneId);
|
||||
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
|
||||
}
|
||||
|
||||
/** "Condividi" dall'elenco andato a buon fine (elencoCercabile.condividi). */
|
||||
public function segnaCondiviso(int $assegnazioneId): void
|
||||
{
|
||||
$this->assegnazioneDelGruppo($assegnazioneId)->registraInvioLink(true, 'condividi');
|
||||
}
|
||||
|
||||
// ─── Invio del link (trait InviaLink) ───────────────────────
|
||||
|
||||
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||
{
|
||||
$gruppo = $this->gruppo();
|
||||
|
||||
return $gruppo !== null
|
||||
&& ($assegnazione->gruppo_servizio_id === $gruppo->id
|
||||
|| $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id);
|
||||
}
|
||||
|
||||
protected function paginaDopoInvio(): string
|
||||
{
|
||||
return route('mio-gruppo');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$gruppo = $this->gruppo()?->load('sorvegliante');
|
||||
|
||||
// Prima quelli da dare, in ordine di priorità come "Da assegnare"
|
||||
// nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
|
||||
// più vecchio.
|
||||
$pacchetto = $gruppo
|
||||
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
|
||||
: collect();
|
||||
[$dati, $daDare] = $pacchetto->partition(fn (Territorio $t) => $t->assegnazioneCorrente !== null);
|
||||
$pacchetto = Territorio::ordinaPerPriorita($daDare)
|
||||
->concat($dati->sortBy(fn (Territorio $t) => $t->assegnazioneCorrente->assigned_at)->values());
|
||||
|
||||
$territori = $pacchetto
|
||||
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
|
||||
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
|
||||
->when($this->filtroStato === 'da_dare', fn ($c) => $c->whereNull('assegnazioneCorrente'))
|
||||
->when($this->filtroStato === 'dati', fn ($c) => $c->whereNotNull('assegnazioneCorrente'))
|
||||
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
|
||||
->values();
|
||||
|
||||
$membri = $gruppo
|
||||
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
|
||||
: collect();
|
||||
|
||||
return view('livewire.gruppi.mio-gruppo', [
|
||||
'gruppo' => $gruppo,
|
||||
'pacchetto' => $pacchetto,
|
||||
'territori' => $territori,
|
||||
'zone' => $pacchetto->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
|
||||
'tipologie' => $pacchetto->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
|
||||
'membri' => $membri,
|
||||
'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
|
||||
'invio' => $this->invioCorrente(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,50 +3,67 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Services\RichiesteServizio;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
class Home extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
/**
|
||||
* Le richieste dal modulo pubblico si confermano portandole su Assegna o
|
||||
* Rientra (link nel riquadro); qui si possono solo scartare.
|
||||
*/
|
||||
public function scartaRichiesta(int $id): void
|
||||
{
|
||||
$this->authorize('richieste.gestisci');
|
||||
Richiesta::findOrFail($id)->scarta(auth()->user());
|
||||
$this->avvisa('Richiesta scartata.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Le richieste aperte, con quello che serve a confermarle: per un
|
||||
* territorio nuovo i proclamatori dal nome simile a quello scritto, per
|
||||
* una riconsegna l'assegnazione aperta di quel territorio.
|
||||
*/
|
||||
private function richiesteAperte(): \Illuminate\Support\Collection
|
||||
{
|
||||
if (! auth()->user()->can('richieste.gestisci')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$aperte = Richiesta::aperte()->orderBy('created_at')->get();
|
||||
if ($aperte->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$servizio = app(RichiesteServizio::class);
|
||||
$proclamatori = Proclamatore::attivi()->get();
|
||||
|
||||
return $aperte->map(fn (Richiesta $r) => [
|
||||
'richiesta' => $r,
|
||||
'simili' => $r->tipo === Richiesta::NUOVO ? $servizio->nomiSimili($r->nome, $proclamatori) : collect(),
|
||||
'assegnazione' => $r->tipo === Richiesta::RICONSEGNA ? $servizio->assegnazioneAperta($r->numero_territorio) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadPdfDaAssegnare()
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
|
||||
$settings = Setting::instance();
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
|
||||
$territori = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorniGiacenza = 0;
|
||||
}
|
||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
||||
$territorio->setAttribute(
|
||||
'home_is_prioritario',
|
||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
||||
$territori = Territorio::ordinaPerPriorita(
|
||||
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
|
||||
);
|
||||
return $territorio;
|
||||
})
|
||||
->sort(function (Territorio $left, Territorio $right) {
|
||||
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
||||
if ($p !== 0) return $p;
|
||||
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
||||
if ($g !== 0) return $g;
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})
|
||||
->values();
|
||||
|
||||
$pdf = Pdf::loadView('pdf.territori-lista', [
|
||||
'titolo' => 'Territori da Assegnare',
|
||||
@@ -90,7 +107,6 @@ class Home extends Component
|
||||
{
|
||||
$settings = Setting::instance();
|
||||
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
$annoCorrente = AnnoTeocratico::corrente();
|
||||
$campagnaAttiva = Campagna::attiva();
|
||||
|
||||
@@ -149,46 +165,11 @@ class Home extends Component
|
||||
];
|
||||
}
|
||||
|
||||
// Quick lists
|
||||
$territoriDaAssegnare = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorniGiacenza = 0;
|
||||
}
|
||||
|
||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
||||
$territorio->setAttribute(
|
||||
'home_is_prioritario',
|
||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
||||
);
|
||||
|
||||
return $territorio;
|
||||
})
|
||||
->sort(function (Territorio $left, Territorio $right) {
|
||||
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
||||
|
||||
if ($priorityComparison !== 0) {
|
||||
return $priorityComparison;
|
||||
}
|
||||
|
||||
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
||||
|
||||
if ($giacenzaComparison !== 0) {
|
||||
return $giacenzaComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})
|
||||
->take($homeLimit)
|
||||
->values();
|
||||
// Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
|
||||
// (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
|
||||
$territoriDaAssegnare = Territorio::ordinaPerPriorita(
|
||||
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
|
||||
)->take($homeLimit)->values();
|
||||
|
||||
$daRientrare = Territorio::daRientrare()
|
||||
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
||||
@@ -208,6 +189,7 @@ class Home extends Component
|
||||
'homeLimit' => $homeLimit,
|
||||
'territoriDaAssegnare' => $territoriDaAssegnare,
|
||||
'daRientrare' => $daRientrare,
|
||||
'richieste' => $this->richiesteAperte(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,45 +2,54 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\Zona;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Editor delle piantine a schermo intero (solo amministratore).
|
||||
*
|
||||
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
|
||||
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
|
||||
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
|
||||
* modulo che poi gira di mano in mano.
|
||||
* Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
|
||||
* disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
|
||||
* La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
|
||||
* (anteprima e conferma, nel componente Pubblicazione). I dati del territorio
|
||||
* — numero, zona, tipologia, confini — sono invece dati del gestionale e si
|
||||
* salvano subito, come dalla scheda territorio.
|
||||
*
|
||||
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e
|
||||
* solo dopo si compila la scheda del territorio — che può anche non esistere
|
||||
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
|
||||
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
|
||||
* Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
|
||||
* aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
|
||||
* disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
|
||||
*
|
||||
* Con la mappa si parla per eventi, e solo con il necessario: i dati di tutte
|
||||
* le piantine li chiede la mappa una volta (datiMappa), poi riceve solo la
|
||||
* piantina cambiata. Prima ogni richiesta rispediva tutte le geometrie e
|
||||
* l'area KML, fino a una decina di MB per aprire un pannello.
|
||||
*/
|
||||
#[Layout('components.layouts.editor')]
|
||||
class PiantineEditor extends Component
|
||||
{
|
||||
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
|
||||
use Avvisa;
|
||||
|
||||
/** Piantina aperta, o null quando non ce n'è nessuna selezionata. */
|
||||
#[Locked]
|
||||
public ?int $mappaId = null;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
/** Geometria appena disegnata, non ancora salvata. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
/** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
|
||||
public string $salvatoAlle = '';
|
||||
|
||||
public bool $pannelloAperto = false;
|
||||
|
||||
// ─── Scheda del territorio ──────────────────────────────────────────────
|
||||
// ─── Territorio ─────────────────────────────────────────────────────────
|
||||
|
||||
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
||||
public string $territorioScelto = '';
|
||||
@@ -53,15 +62,24 @@ class PiantineEditor extends Component
|
||||
|
||||
public string $confini = '';
|
||||
|
||||
/** Campi di creazione al volo, aperti solo quando servono. */
|
||||
public string $nuovaZona = '';
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
|
||||
if (! $mappa) {
|
||||
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
|
||||
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->caricaScheda($mappa->territorio);
|
||||
$this->aggiornaMappaJs(inquadra: true);
|
||||
$this->carica($mappa);
|
||||
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
|
||||
}
|
||||
|
||||
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
|
||||
public function nuova(): void
|
||||
{
|
||||
$this->svuota();
|
||||
$this->dispatch('piantina-selezionata', mappaId: null, inquadra: false);
|
||||
$this->dispatch('editor-disegna');
|
||||
}
|
||||
|
||||
private function carica(TerritorioMappa $mappa): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->salvatoAlle = '';
|
||||
$this->nuovaZona = '';
|
||||
$this->nuovaTipologia = '';
|
||||
$this->caricaTerritorio($mappa->territorio);
|
||||
}
|
||||
|
||||
private function svuota(): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
$this->reset([
|
||||
'mappaId', 'nome', 'geometriaInAttesa',
|
||||
'mappaId', 'nome', 'salvatoAlle',
|
||||
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
||||
'nuovaZona', 'nuovaTipologia',
|
||||
]);
|
||||
$this->resetValidation();
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function chiudiPannello(): void
|
||||
{
|
||||
$this->pannelloAperto = false;
|
||||
}
|
||||
|
||||
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
|
||||
private function caricaScheda(?Territorio $territorio): void
|
||||
private function caricaTerritorio(?Territorio $territorio): void
|
||||
{
|
||||
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
|
||||
$this->numero = (string) ($territorio->numero ?? '');
|
||||
@@ -113,12 +139,123 @@ class PiantineEditor extends Component
|
||||
$this->confini = (string) ($territorio->confini ?? '');
|
||||
}
|
||||
|
||||
/** Cambiare territorio nella tendina ricarica la sua scheda. */
|
||||
// ─── Confine (bozza, salvataggio automatico) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Arriva dalla mappa dopo un disegno o lo spostamento di un vertice.
|
||||
* $nuova: il disegno è una piantina nuova (strumento "Disegna"), non il
|
||||
* confine ridisegnato di quella aperta.
|
||||
*/
|
||||
#[On('geometria-modificata')]
|
||||
public function salvaGeometria($geometry, bool $nuova = false): void
|
||||
{
|
||||
if (! $this->geometriaValida($geometry)) {
|
||||
$this->avvisaErrore('Il confine disegnato non è valido: ridisegnalo.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$geojson = json_encode($geometry);
|
||||
$mappa = (! $nuova && $this->mappaId) ? TerritorioMappa::find($this->mappaId) : null;
|
||||
|
||||
if ($mappa) {
|
||||
$mappa->update(['geojson' => $geojson]);
|
||||
} else {
|
||||
$mappa = TerritorioMappa::create(['geojson' => $geojson]);
|
||||
$this->carica($mappa);
|
||||
activity()->causedBy(auth()->user())->log('piantina_creata');
|
||||
|
||||
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
|
||||
{
|
||||
$this->resetValidation();
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($valore === 'nuovo') {
|
||||
// Si compilano i campi e si crea con il pulsante: un territorio
|
||||
// non nasce da un salvataggio automatico a metà digitazione.
|
||||
$this->numero = '';
|
||||
$this->zonaId = '';
|
||||
$this->tipologiaId = '';
|
||||
@@ -127,34 +264,158 @@ class PiantineEditor extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
|
||||
}
|
||||
if ($valore === '') {
|
||||
$numero = $mappa->territorio?->numero;
|
||||
$mappa->update([
|
||||
'territorio_id' => null,
|
||||
// Senza territorio serve un'etichetta, altrimenti la piantina
|
||||
// finisce in elenco come un anonimo "Piantina #12".
|
||||
'nome' => $mappa->nome ?: ($numero ? "Ex territorio {$numero}" : null),
|
||||
]);
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->caricaTerritorio(null);
|
||||
$this->salvato($mappa);
|
||||
|
||||
// ─── Disegno ────────────────────────────────────────────────────────────
|
||||
|
||||
#[On('poligono-disegnato')]
|
||||
public function poligonoDisegnato($geometry): void
|
||||
{
|
||||
if (! is_array($geometry)
|
||||
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|
||||
|| ! is_array($geometry['coordinates'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = json_encode($geometry);
|
||||
$this->pannelloAperto = true;
|
||||
$territorio = Territorio::find($valore);
|
||||
if (! $territorio) {
|
||||
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
|
||||
$this->caricaTerritorio($mappa->territorio);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
$this->aggiornaMappaJs();
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa || $this->territorioScelto !== 'nuovo') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->numero = trim($this->numero);
|
||||
$this->confini = trim($this->confini);
|
||||
|
||||
$this->validate(
|
||||
[
|
||||
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
],
|
||||
$this->messaggiTerritorio(),
|
||||
);
|
||||
|
||||
try {
|
||||
$territorio = DB::transaction(function () use ($mappa) {
|
||||
$territorio = Territorio::create($this->valoriTerritorio() + ['attivo' => true]);
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
|
||||
return $territorio;
|
||||
});
|
||||
} catch (\Illuminate\Database\UniqueConstraintViolationException) {
|
||||
$this->addError('numero', 'Esiste già un territorio con questo numero.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
||||
|
||||
$this->caricaTerritorio($territorio);
|
||||
$this->salvato($mappa);
|
||||
$this->avvisa("Territorio {$territorio->numero} creato e collegato.");
|
||||
}
|
||||
|
||||
// ─── Dati del territorio (salvati subito nel gestionale) ────────────────
|
||||
|
||||
public function updatedNumero(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('numero');
|
||||
}
|
||||
|
||||
public function updatedZonaId(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('zonaId');
|
||||
}
|
||||
|
||||
public function updatedTipologiaId(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('tipologiaId');
|
||||
}
|
||||
|
||||
public function updatedConfini(): void
|
||||
{
|
||||
$this->salvaCampoTerritorio('confini');
|
||||
}
|
||||
|
||||
private function salvaCampoTerritorio(string $campo): void
|
||||
{
|
||||
// In creazione i campi si raccolgono e basta: li salva creaTerritorio().
|
||||
$territorio = $this->territorioCollegato();
|
||||
if (! $territorio) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->numero = trim($this->numero);
|
||||
|
||||
$regole = [
|
||||
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')->ignore($territorio->id)],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
|
||||
$this->validateOnly($campo, $regole, $this->messaggiTerritorio());
|
||||
|
||||
// Solo la colonna del campo cambiato: con un altro campo ancora non
|
||||
// valido a schermo, riscrivere tutto salverebbe anche quel valore.
|
||||
$colonna = ['numero' => 'numero', 'zonaId' => 'zona_id', 'tipologiaId' => 'tipologia_id', 'confini' => 'confini'][$campo];
|
||||
$territorio->update([$colonna => $this->valoriTerritorio()[$colonna]]);
|
||||
|
||||
$this->salvato($this->mappaAperta());
|
||||
}
|
||||
|
||||
private function valoriTerritorio(): array
|
||||
{
|
||||
return [
|
||||
'numero' => trim($this->numero),
|
||||
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
||||
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
||||
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function messaggiTerritorio(): array
|
||||
{
|
||||
return [
|
||||
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
||||
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
||||
'numero.max' => 'Il numero può avere al massimo 20 caratteri.',
|
||||
'confini.max' => 'I confini possono avere al massimo 5000 caratteri.',
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
||||
|
||||
public function creaZona()
|
||||
public function creaZona(): void
|
||||
{
|
||||
$this->nuovaZona = trim($this->nuovaZona);
|
||||
$dati = $this->validate(
|
||||
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
||||
[
|
||||
@@ -163,16 +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->nuovaZona = '';
|
||||
|
||||
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
|
||||
$this->updatedZonaId();
|
||||
$this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
public function creaTipologia()
|
||||
public function creaTipologia(): void
|
||||
{
|
||||
$this->nuovaTipologia = trim($this->nuovaTipologia);
|
||||
$dati = $this->validate(
|
||||
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
||||
[
|
||||
@@ -181,221 +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->nuovaTipologia = '';
|
||||
|
||||
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||
$this->updatedTipologiaId();
|
||||
$this->avvisa("Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
// ─── Salvataggio ────────────────────────────────────────────────────────
|
||||
// ─── Pubblicazione ed eliminazione ──────────────────────────────────────
|
||||
|
||||
public function salva()
|
||||
public function pubblica(): void
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null;
|
||||
|
||||
if (! $mappa && ! $this->geometriaInAttesa) {
|
||||
session()->flash('error', 'Disegna il poligono prima di salvare.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = $this->salvaScheda($mappa);
|
||||
|
||||
// salvaScheda() torna false quando la validazione della scheda non
|
||||
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
|
||||
if ($territorio === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valori = [
|
||||
'territorio_id' => $territorio?->id,
|
||||
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
|
||||
];
|
||||
|
||||
if ($this->geometriaInAttesa) {
|
||||
$valori['geojson'] = $this->geometriaInAttesa;
|
||||
}
|
||||
|
||||
if ($mappa) {
|
||||
$mappa->update($valori);
|
||||
} else {
|
||||
$mappa = TerritorioMappa::create($valori);
|
||||
$this->mappaId = $mappa->id;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->log('piantina_salvata');
|
||||
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea o aggiorna il territorio della scheda.
|
||||
*
|
||||
* @return Territorio|null|false Il territorio collegato, null se la
|
||||
* piantina resta scollegata, false se la
|
||||
* scheda non è valida.
|
||||
*/
|
||||
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
|
||||
{
|
||||
if ($this->territorioScelto === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$esistente = $this->territorioScelto === 'nuovo'
|
||||
? null
|
||||
: Territorio::find($this->territorioScelto);
|
||||
|
||||
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
|
||||
session()->flash('error', 'Il territorio scelto non esiste più.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate(
|
||||
[
|
||||
'numero' => [
|
||||
'required', 'string', 'max:20',
|
||||
Rule::unique('territori', 'numero')->ignore($esistente?->id),
|
||||
],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
],
|
||||
[
|
||||
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
||||
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
||||
],
|
||||
['numero' => 'numero'],
|
||||
);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->pannelloAperto = true;
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Un territorio ha una piantina sola.
|
||||
if ($esistente) {
|
||||
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
|
||||
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
|
||||
->exists();
|
||||
|
||||
if ($occupato) {
|
||||
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$valori = [
|
||||
'numero' => trim($this->numero),
|
||||
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
||||
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
||||
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
||||
];
|
||||
|
||||
if ($esistente) {
|
||||
$esistente->update($valori);
|
||||
return $esistente;
|
||||
}
|
||||
|
||||
$territorio = Territorio::create($valori + ['attivo' => true]);
|
||||
$this->territorioScelto = (string) $territorio->id;
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
||||
|
||||
return $territorio;
|
||||
}
|
||||
|
||||
// ─── Immagine ed eliminazione ───────────────────────────────────────────
|
||||
|
||||
public function generaImmagine(PiantinaRenderer $renderer)
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||
$this->avvisaErrore($motivo);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$renderer->render($mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function elimina()
|
||||
#[On('piantina-pubblicata')]
|
||||
public function pubblicata(int $mappaId): void
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||
|
||||
if ($mappa) {
|
||||
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
|
||||
}
|
||||
}
|
||||
|
||||
public function elimina(): void
|
||||
{
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$etichetta = $mappa->etichetta();
|
||||
$id = $mappa->id;
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
$this->nuova();
|
||||
session()->flash('success', "{$etichetta} eliminata.");
|
||||
$this->svuota();
|
||||
$this->dispatch('piantina-rimossa', mappaId: $id);
|
||||
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
||||
|
||||
public function datiMappa(?PiantineArea $area = null): array
|
||||
/** Chiamata una volta dalla mappa all'avvio ($wire.datiMappa()). */
|
||||
public function datiMappa(PiantineArea $area): array
|
||||
{
|
||||
$area ??= app(PiantineArea::class);
|
||||
|
||||
$piantine = TerritorioMappa::with('territorio')->get()
|
||||
->filter(fn ($m) => $m->geometria() !== null)
|
||||
->map(fn ($m) => [
|
||||
'mappaId' => $m->id,
|
||||
'etichetta' => $m->etichetta(),
|
||||
'collegata' => $m->collegata(),
|
||||
'geometry' => $m->geometria(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'area' => $area->area(),
|
||||
'piantine' => $piantine,
|
||||
'piantine' => TerritorioMappa::with('territorio.zona')->get()
|
||||
->filter(fn (TerritorioMappa $m) => $m->geometria() !== null)
|
||||
->map(fn (TerritorioMappa $m) => $this->datiPiantina($m))
|
||||
->values()
|
||||
->all(),
|
||||
'selezionata' => $this->mappaId,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(bool $inquadra = false): void
|
||||
private function datiPiantina(TerritorioMappa $mappa): array
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
|
||||
return [
|
||||
'mappaId' => $mappa->id,
|
||||
'etichetta' => $mappa->etichetta(),
|
||||
'collegata' => $mappa->collegata(),
|
||||
'stato' => $mappa->stato(),
|
||||
'statoEtichetta' => $mappa->statoEtichetta(),
|
||||
'geometry' => $mappa->geometria(),
|
||||
];
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area)
|
||||
/** Dopo ogni salvataggio automatico: ora in testata, mappa e indirizzo allineati. */
|
||||
private function salvato(?TerritorioMappa $mappa, bool $seleziona = false): void
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa = $mappa->fresh(['territorio.zona']);
|
||||
$this->salvatoAlle = now()->format('H:i');
|
||||
|
||||
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
|
||||
|
||||
if ($seleziona) {
|
||||
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: false);
|
||||
}
|
||||
|
||||
// La piantina aperta resta nell'indirizzo: ricaricando la pagina si
|
||||
// torna su questa e non su un editor vuoto.
|
||||
$this->dispatch('editor-indirizzo', url: route('piantine.editor', $mappa->id));
|
||||
}
|
||||
|
||||
private function mappaAperta(): ?TerritorioMappa
|
||||
{
|
||||
return $this->mappaId ? TerritorioMappa::with('territorio.zona')->find($this->mappaId) : null;
|
||||
}
|
||||
|
||||
private function territorioCollegato(): ?Territorio
|
||||
{
|
||||
if ($this->territorioScelto === '' || $this->territorioScelto === 'nuovo') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
return $mappa?->territorio;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$mappa = $this->mappaAperta();
|
||||
|
||||
// Nella tendina: i territori attivi senza piantina, più quello già
|
||||
// collegato anche se nel frattempo è stato disattivato o cestinato.
|
||||
$occupati = TerritorioMappa::whereNotNull('territorio_id')
|
||||
->when($mappa, fn ($q) => $q->whereKeyNot($mappa->id))
|
||||
->pluck('territorio_id');
|
||||
|
||||
$territori = Territorio::attivi()
|
||||
->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', [
|
||||
'mappa' => $mappa,
|
||||
'areaCaricata' => $area->area() !== null,
|
||||
'datiMappaIniziali' => $this->datiMappa($area),
|
||||
'territori' => Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero']),
|
||||
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
||||
'opzioniTerritori' => $opzioniTerritori,
|
||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||
'title' => 'Editor delle piantine',
|
||||
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,60 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* Pagina di gestione delle piantine (solo amministratore).
|
||||
*
|
||||
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano
|
||||
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno
|
||||
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su
|
||||
* una mappa larga un terzo di schermo non si taglia un confine.
|
||||
* È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
|
||||
* con modifiche da pubblicare), l'area della città e lo storico dei PDF
|
||||
* sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
|
||||
* da qui si apre, si pubblica (con la stessa finestra di anteprima
|
||||
* dell'editor) e si elimina.
|
||||
*/
|
||||
class PiantineManager extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithFileUploads;
|
||||
use WithPagination;
|
||||
|
||||
public const FILTRI = [
|
||||
'tutte' => 'Tutte',
|
||||
'bozze' => 'Bozze',
|
||||
'da-pubblicare' => 'Da pubblicare',
|
||||
'pubblicate' => 'Pubblicate',
|
||||
'scollegate' => 'Scollegate',
|
||||
];
|
||||
|
||||
#[Url(as: 'q', except: '')]
|
||||
public string $cerca = '';
|
||||
|
||||
#[Url(except: 'tutte')]
|
||||
public string $filtro = 'tutte';
|
||||
|
||||
public $kmlFile = null;
|
||||
|
||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
||||
public ?int $collegamentoAperto = null;
|
||||
|
||||
public ?int $territorioDaCollegare = null;
|
||||
public function updatedFiltro(): void
|
||||
{
|
||||
if (! array_key_exists($this->filtro, self::FILTRI)) {
|
||||
$this->filtro = 'tutte';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||
|
||||
public function caricaKml(PiantineArea $area)
|
||||
public function caricaKml(PiantineArea $area): void
|
||||
{
|
||||
$this->validate(
|
||||
['kmlFile' => 'required|file|max:10240'],
|
||||
@@ -62,111 +84,42 @@ class PiantineManager extends Component
|
||||
$this->kmlFile = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
|
||||
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
$this->avvisa("Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
}
|
||||
|
||||
// ─── Generazione ────────────────────────────────────────────────────────
|
||||
// ─── Piantine ───────────────────────────────────────────────────────────
|
||||
|
||||
public function generaImmagine(int $mappaId, PiantinaRenderer $renderer)
|
||||
public function pubblica(int $mappaId): void
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||
|
||||
try {
|
||||
$renderer->render($mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
if (! $mappa) {
|
||||
$this->avvisaErrore('La piantina non esiste più.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
|
||||
if (! $mappa->collegata()) {
|
||||
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
|
||||
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||
$this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||
}
|
||||
|
||||
/** La finestra di pubblicazione ha finito: basta ridisegnare l'elenco. */
|
||||
#[On('piantina-pubblicata')]
|
||||
public function pubblicata(): void {}
|
||||
|
||||
public function elimina(int $mappaId): void
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->find($mappaId);
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
}
|
||||
|
||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||
|
||||
public function apriCollegamento(int $mappaId): void
|
||||
{
|
||||
$this->collegamentoAperto = $mappaId;
|
||||
$this->territorioDaCollegare = TerritorioMappa::find($mappaId)?->territorio_id;
|
||||
}
|
||||
|
||||
public function chiudiCollegamento(): void
|
||||
{
|
||||
$this->collegamentoAperto = null;
|
||||
$this->territorioDaCollegare = null;
|
||||
}
|
||||
|
||||
public function collega(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::findOrFail($mappaId);
|
||||
|
||||
if (! $this->territorioDaCollegare) {
|
||||
session()->flash('error', 'Scegli il territorio a cui collegare la piantina.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = Territorio::findOrFail($this->territorioDaCollegare);
|
||||
|
||||
// Un territorio ha una piantina sola: se ne aveva già un'altra il
|
||||
// vincolo di unicità la respingerebbe con un errore di database, che
|
||||
// non spiega niente a chi sta guardando la pagina.
|
||||
$occupato = TerritorioMappa::where('territorio_id', $territorio->id)
|
||||
->where('id', '!=', $mappa->id)
|
||||
->exists();
|
||||
|
||||
if ($occupato) {
|
||||
session()->flash('error', "Il territorio {$territorio->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
$this->chiudiCollegamento();
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
|
||||
}
|
||||
|
||||
public function scollega(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$numero = $mappa->territorio?->numero ?? '?';
|
||||
|
||||
// Senza territorio serve un'etichetta, altrimenti la piantina sparisce
|
||||
// dall'elenco sotto un anonimo "Piantina #12".
|
||||
$mappa->update([
|
||||
'territorio_id' => null,
|
||||
'nome' => $mappa->nome ?: "Ex territorio {$numero}",
|
||||
]);
|
||||
|
||||
session()->flash('success', "Piantina scollegata dal territorio {$numero}. Il PDF del territorio resta com'è.");
|
||||
}
|
||||
|
||||
public function eliminaPiantina(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$etichetta = $mappa->etichetta();
|
||||
|
||||
if ($mappa->image_path) {
|
||||
@@ -174,59 +127,106 @@ class PiantineManager extends Component
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
|
||||
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||
}
|
||||
|
||||
// ─── Storico versioni ───────────────────────────────────────────────────
|
||||
|
||||
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService)
|
||||
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService): void
|
||||
{
|
||||
$versione = TerritorioPdfVersione::with('territorio')->findOrFail($versioneId);
|
||||
$versione = TerritorioPdfVersione::with('territorio')->find($versioneId);
|
||||
|
||||
if (! $versione) {
|
||||
return;
|
||||
}
|
||||
|
||||
$numero = $versione->territorio?->numero ?? '?';
|
||||
|
||||
try {
|
||||
$pdfService->ripristina($versione);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Versione precedente ripristinata per il territorio {$numero}.");
|
||||
$this->avvisa("Versione precedente ripristinata per il territorio {$numero}. Il PDF che c'era è nello storico.");
|
||||
}
|
||||
|
||||
public function eliminaVersione(int $versioneId)
|
||||
public function eliminaVersione(int $versioneId): void
|
||||
{
|
||||
$versione = TerritorioPdfVersione::findOrFail($versioneId);
|
||||
$versione = TerritorioPdfVersione::find($versioneId);
|
||||
|
||||
if (Storage::disk('public')->exists($versione->pdf_path)) {
|
||||
Storage::disk('public')->delete($versione->pdf_path);
|
||||
if (! $versione) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($versione->pdf_path);
|
||||
$versione->delete();
|
||||
|
||||
session()->flash('success', 'Versione eliminata definitivamente.');
|
||||
$this->avvisa('Versione eliminata definitivamente.');
|
||||
}
|
||||
|
||||
// ─── Dati per la vista ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tutte le piantine con il loro stato, in memoria: sono qualche centinaio
|
||||
* al massimo, e lo stato dipende dall'impronta, che si calcola in PHP.
|
||||
*
|
||||
* @return Collection<int, TerritorioMappa>
|
||||
*/
|
||||
private function piantine(): Collection
|
||||
{
|
||||
return TerritorioMappa::with('territorio.zona')
|
||||
->get()
|
||||
->sortBy(fn (TerritorioMappa $m) => [
|
||||
$m->territorio_id === null ? 1 : 0,
|
||||
strlen((string) $m->territorio?->numero),
|
||||
(string) $m->territorio?->numero,
|
||||
mb_strtolower((string) $m->nome),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
private function corrisponde(TerritorioMappa $mappa, string $filtro): bool
|
||||
{
|
||||
return match ($filtro) {
|
||||
'bozze' => $mappa->stato() === TerritorioMappa::STATO_BOZZA,
|
||||
'da-pubblicare' => $mappa->stato() === TerritorioMappa::STATO_MODIFICATA,
|
||||
'pubblicate' => $mappa->stato() === TerritorioMappa::STATO_PUBBLICATA,
|
||||
'scollegate' => ! $mappa->collegata(),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$piantine = $this->piantine();
|
||||
|
||||
$conteggi = collect(self::FILTRI)
|
||||
->map(fn ($etichetta, $filtro) => $piantine->filter(fn ($m) => $this->corrisponde($m, $filtro))->count());
|
||||
|
||||
$cerca = mb_strtolower(trim($this->cerca));
|
||||
|
||||
$elenco = $piantine
|
||||
->filter(fn ($m) => $this->corrisponde($m, $this->filtro))
|
||||
->filter(fn (TerritorioMappa $m) => $cerca === '' || str_contains(mb_strtolower(implode(' ', [
|
||||
$m->etichetta(),
|
||||
$m->territorio?->numero,
|
||||
$m->nome,
|
||||
$m->territorio?->zona?->nome,
|
||||
])), $cerca))
|
||||
->values();
|
||||
|
||||
return view('livewire.piantine.piantine-manager', [
|
||||
'config' => $area->config(),
|
||||
'piantine' => TerritorioMappa::with('territorio')
|
||||
->get()
|
||||
->sortBy(fn ($m) => [
|
||||
$m->collegata() ? 0 : 1,
|
||||
strlen((string) $m->territorio?->numero),
|
||||
(string) $m->territorio?->numero,
|
||||
(string) $m->nome,
|
||||
])
|
||||
->values(),
|
||||
'territori' => Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero']),
|
||||
'elenco' => $elenco,
|
||||
'totale' => $piantine->count(),
|
||||
'conteggi' => $conteggi,
|
||||
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||
->orderByDesc('sostituito_il')
|
||||
->limit(30)
|
||||
->get(),
|
||||
->orderByDesc('id')
|
||||
->paginate(15, pageName: 'versioni'),
|
||||
'title' => 'Piantine dei territori',
|
||||
]);
|
||||
}
|
||||
|
||||
124
app/Livewire/Piantine/Pubblicazione.php
Normal file
124
app/Livewire/Piantine/Pubblicazione.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PubblicazionePiantina;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* La finestra di pubblicazione, uguale nell'editor e nella gestione.
|
||||
*
|
||||
* Si apre con l'evento "pubblica-piantina", prepara l'anteprima in coda
|
||||
* (controllando a intervalli quando è pronta), la mostra, e solo su
|
||||
* "Conferma" sostituisce il PDF del territorio. Chiuderla butta l'anteprima.
|
||||
*/
|
||||
class Pubblicazione extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
#[Locked]
|
||||
public ?int $mappaId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $token = null;
|
||||
|
||||
public string $fase = '';
|
||||
|
||||
public string $messaggio = '';
|
||||
|
||||
public string $etichetta = '';
|
||||
|
||||
#[On('pubblica-piantina')]
|
||||
public function apri(int $mappaId, PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
$this->chiudi($pubblicazione);
|
||||
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||
if (! $mappa) {
|
||||
$this->avvisaErrore('La piantina non esiste più.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->etichetta = $mappa->etichetta();
|
||||
|
||||
try {
|
||||
$this->token = $pubblicazione->avvia($mappa);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->fase = PubblicazionePiantina::ERRORE;
|
||||
$this->messaggio = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->aggiorna($pubblicazione);
|
||||
}
|
||||
|
||||
/** Chiamata a intervalli finché l'anteprima è in preparazione. */
|
||||
public function aggiorna(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if (! $this->token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stato = $pubblicazione->stato($this->token);
|
||||
|
||||
if (! $stato) {
|
||||
$this->fase = PubblicazionePiantina::ERRORE;
|
||||
$this->messaggio = 'L\'anteprima è scaduta: generala di nuovo.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fase = $stato['stato'];
|
||||
$this->messaggio = $stato['messaggio'] ?? '';
|
||||
}
|
||||
|
||||
public function conferma(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if (! $this->token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$mappa = $pubblicazione->conferma($this->token);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->fase = PubblicazionePiantina::ERRORE;
|
||||
$this->messaggio = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
|
||||
|
||||
$this->avvisa("{$mappa->etichetta()} pubblicata: il PDF del territorio è stato sostituito, il precedente è nello storico.");
|
||||
$this->dispatch('piantina-pubblicata', mappaId: $mappa->id);
|
||||
}
|
||||
|
||||
/** Riprova dopo un errore, con lo stato attuale della piantina. */
|
||||
public function riprova(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if ($this->mappaId) {
|
||||
$this->apri($this->mappaId, $pubblicazione);
|
||||
}
|
||||
}
|
||||
|
||||
public function chiudi(PubblicazionePiantina $pubblicazione): void
|
||||
{
|
||||
if ($this->token) {
|
||||
$pubblicazione->annulla($this->token);
|
||||
}
|
||||
|
||||
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.piantine.pubblicazione');
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,89 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
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
|
||||
{
|
||||
/** Data dell'ultima revisione del testo dell'informativa. */
|
||||
public const REVISIONE = '21 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',
|
||||
'gruppo.gestisci' => 'solo il proprio gruppo di servizio: i territori del pacchetto e i membri del gruppo',
|
||||
'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',
|
||||
'richieste.gestisci' => 'richieste arrivate dal modulo pubblico (nuovo territorio o riconsegna)',
|
||||
'audit.view' => 'consultazione del registro delle attività',
|
||||
'audit.export' => 'esportazione del registro delle attività',
|
||||
];
|
||||
|
||||
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', [
|
||||
'congregazione' => $settings->congregazione_nome ?? 'Congregazione',
|
||||
'auditRetention' => $settings->audit_retention_days ?? 365,
|
||||
'revisione' => self::REVISIONE,
|
||||
'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(),
|
||||
'richieste' => Richiesta::count(),
|
||||
'richiesteAperte' => Richiesta::aperte()->count(),
|
||||
'attivita' => Activity::count(),
|
||||
'attivitaDal' => Activity::min('created_at'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreCestino extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function restore(int $id)
|
||||
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($proclamatore)
|
||||
->log('restored');
|
||||
session()->flash('success', "Proclamatore ripristinato.");
|
||||
$this->avvisa("Proclamatore ripristinato.");
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
|
||||
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->log('force_deleted_proclamatore');
|
||||
$proclamatore->forceDelete();
|
||||
session()->flash('success', 'Proclamatore eliminato definitivamente.');
|
||||
$this->avvisa('Proclamatore eliminato definitivamente.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreCreate extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public string $nome = '';
|
||||
public string $cognome = '';
|
||||
public bool $attivo = true;
|
||||
@@ -37,7 +40,7 @@ class ProclamatoreCreate extends Component
|
||||
->log('created');
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreEdit extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Proclamatore $proclamatore;
|
||||
public string $nome = '';
|
||||
public string $cognome = '';
|
||||
@@ -35,14 +38,18 @@ class ProclamatoreEdit extends Component
|
||||
|
||||
$this->validate();
|
||||
|
||||
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
|
||||
$gruppo = ! $this->attivo ? $this->proclamatore->gruppoSorvegliato() : null;
|
||||
|
||||
$this->proclamatore->update([
|
||||
'nome' => $this->nome,
|
||||
'cognome' => $this->cognome,
|
||||
'attivo' => $this->attivo,
|
||||
]);
|
||||
|
||||
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
|
||||
return $this->redirect(route('proclamatori.index'), navigate: true);
|
||||
$avviso = "Proclamatore {$this->proclamatore->nome_completo} aggiornato." . ($gruppo ? ' ' . ProclamatoreIndex::gruppoSciolto($gruppo) : '');
|
||||
session()->flash('success', $avviso);
|
||||
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -50,6 +57,7 @@ class ProclamatoreEdit extends Component
|
||||
return view('livewire.proclamatori.proclamatore-form', [
|
||||
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
|
||||
'btnLabel' => 'Salva Modifiche',
|
||||
'gruppoSorvegliato' => $this->proclamatore->gruppoSorvegliato(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,26 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
// Ricerca, filtro e ordinamento nell'indirizzo: si ritrovano tornando.
|
||||
#[Url(except: '')]
|
||||
public string $search = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroStato = '';
|
||||
#[Url(except: 'cognome')]
|
||||
public string $sortField = 'cognome';
|
||||
#[Url(except: 'asc')]
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
protected $queryString = [
|
||||
@@ -49,7 +58,13 @@ class ProclamatoreIndex extends Component
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$gruppo = $proclamatore->attivo ? $proclamatore->gruppoSorvegliato() : null;
|
||||
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
|
||||
$proclamatore->update(['attivo' => !$proclamatore->attivo]);
|
||||
|
||||
if ($gruppo) {
|
||||
$this->avvisa("{$proclamatore->nome_completo} non è più attivo. " . self::gruppoSciolto($gruppo));
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteProclamatore(int $id)
|
||||
@@ -59,12 +74,15 @@ class ProclamatoreIndex extends Component
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
|
||||
// si scioglie (Proclamatore::booted).
|
||||
$gruppo = $proclamatore->gruppoSorvegliato();
|
||||
$proclamatore->delete();
|
||||
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
|
||||
}
|
||||
|
||||
public function anonimizza(int $id)
|
||||
@@ -72,8 +90,15 @@ class ProclamatoreIndex extends Component
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$gruppo = $proclamatore->gruppoSorvegliato();
|
||||
$proclamatore->anonimizza();
|
||||
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
|
||||
}
|
||||
|
||||
/** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
|
||||
public static function gruppoSciolto(GruppoServizio $gruppo): string
|
||||
{
|
||||
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è stata eliminata.";
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -81,7 +106,7 @@ class ProclamatoreIndex extends Component
|
||||
// withCount rather than letting the view call
|
||||
// ->assegnazioni()->aperte()->count() per row, which issued one
|
||||
// COUNT(*) per displayed proclamatore on every render.
|
||||
$query = Proclamatore::query()->withCount([
|
||||
$query = Proclamatore::query()->with('gruppo')->withCount([
|
||||
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
|
||||
]);
|
||||
|
||||
@@ -99,6 +124,8 @@ class ProclamatoreIndex extends Component
|
||||
$filtered = $filtered->where('attivo', true);
|
||||
} elseif ($this->filtroStato === 'inattivo') {
|
||||
$filtered = $filtered->where('attivo', false);
|
||||
} elseif ($this->filtroStato === 'gruppo') {
|
||||
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
|
||||
}
|
||||
|
||||
// Sort in-memory
|
||||
@@ -123,6 +150,8 @@ class ProclamatoreIndex extends Component
|
||||
$query->where('attivo', true);
|
||||
} elseif ($this->filtroStato === 'inattivo') {
|
||||
$query->where('attivo', false);
|
||||
} elseif ($this->filtroStato === 'gruppo') {
|
||||
$query->whereNotNull('gruppo_servizio_id');
|
||||
}
|
||||
|
||||
// cognome/nome encrypted, so sort in-memory
|
||||
@@ -145,6 +174,8 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
return view('livewire.proclamatori.proclamatore-index', [
|
||||
'proclamatori' => $proclamatori,
|
||||
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
|
||||
'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class ProclamatoreShow extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Proclamatore $proclamatore;
|
||||
|
||||
public function mount(Proclamatore $proclamatore)
|
||||
@@ -20,8 +24,11 @@ class ProclamatoreShow extends Component
|
||||
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
|
||||
->with(['territorio', 'annoTeocratico', 'campagna'])
|
||||
->orderByDesc('assigned_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label)
|
||||
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
|
||||
->sortKeysDesc();
|
||||
|
||||
$stats = [
|
||||
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
|
||||
@@ -34,9 +41,21 @@ class ProclamatoreShow extends Component
|
||||
),
|
||||
];
|
||||
|
||||
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
|
||||
// sua utenza) o membro. I gruppi sciolti non esistono più.
|
||||
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
|
||||
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
|
||||
|
||||
return view('livewire.proclamatori.proclamatore-show', [
|
||||
'assegnazioniPerAnno' => $assegnazioni,
|
||||
'stats' => $stats,
|
||||
'gruppoSorvegliato' => $gruppoSorvegliato,
|
||||
'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(),
|
||||
'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(),
|
||||
'utenza' => $utenza,
|
||||
'ultimoAccesso' => $utenza
|
||||
? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at')
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class Profile extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
||||
@@ -25,15 +27,30 @@ class Profile extends Component
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Il sorvegliante di gruppo entra con l'email fittizia creata col
|
||||
// gruppo (GruppiServizio::emailFittizia): dal profilo non la cambia.
|
||||
$emailFissa = $user->isSorvegliante();
|
||||
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
] + ($emailFissa ? [] : [
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
]);
|
||||
]));
|
||||
|
||||
$user->update([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
]);
|
||||
$user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['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.
|
||||
// Il sorvegliante di gruppo non conferma l'email (User::hasVerifiedEmail).
|
||||
if ($emailCambiata && ! $user->isSorvegliante()) {
|
||||
$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.');
|
||||
|
||||
@@ -63,17 +80,23 @@ class Profile extends Component
|
||||
auth()->user()->update([
|
||||
'password' => $this->password,
|
||||
'password_change_required' => false,
|
||||
'password_temporanea' => false,
|
||||
]);
|
||||
|
||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||
|
||||
session()->flash('password_success', 'Password aggiornata con successo.');
|
||||
$this->avvisa('Password aggiornata con successo.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Il sorvegliante vede qui i dettagli del suo gruppo di servizio.
|
||||
$gruppo = auth()->user()->gruppoSorvegliato();
|
||||
|
||||
return view('livewire.profile', [
|
||||
'user' => auth()->user(),
|
||||
'gruppo' => $gruppo,
|
||||
'riepilogoGruppo' => $gruppo?->riepilogo(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -11,24 +12,28 @@ use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Zona;
|
||||
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
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */
|
||||
private const S13_RIGHE_PER_PAGINA = 21;
|
||||
|
||||
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
|
||||
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
|
||||
|
||||
// Ricerca, filtri e ordinamento nell'indirizzo: si ritrovano tornando.
|
||||
#[Url(except: '')]
|
||||
public string $search = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroAnno = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroZona = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroTipologia = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroStato = ''; // aperte, chiuse
|
||||
#[Url(except: 'territorio_numero')]
|
||||
public string $sortField = 'territorio_numero';
|
||||
#[Url(except: 'asc')]
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
// ─── Modal create/edit ──────────────────────────────────────
|
||||
@@ -37,7 +42,6 @@ class Registro extends Component
|
||||
|
||||
public string $form_territorio_id = '';
|
||||
public string $form_proclamatore_id = '';
|
||||
public string $form_anno_id = '';
|
||||
public string $form_assigned_at = '';
|
||||
public string $form_returned_at = '';
|
||||
public bool $form_counted_in_campaign = false;
|
||||
@@ -101,7 +105,6 @@ class Registro extends Component
|
||||
$this->editingId = null;
|
||||
$this->form_territorio_id = '';
|
||||
$this->form_proclamatore_id = '';
|
||||
$this->form_anno_id = '';
|
||||
$this->form_assigned_at = now()->format('Y-m-d');
|
||||
$this->form_returned_at = '';
|
||||
$this->form_counted_in_campaign = false;
|
||||
@@ -118,7 +121,6 @@ class Registro extends Component
|
||||
$this->editingId = $id;
|
||||
$this->form_territorio_id = (string) $a->territorio_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_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
|
||||
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
|
||||
@@ -134,28 +136,69 @@ class Registro extends Component
|
||||
$this->validate([
|
||||
'form_territorio_id' => 'required|exists:territori,id',
|
||||
'form_proclamatore_id' => 'required|exists:proclamatori,id',
|
||||
'form_anno_id' => 'required|exists:anni_teocratici,id',
|
||||
'form_assigned_at' => 'required|date',
|
||||
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
|
||||
'form_assigned_at' => 'required|date|before_or_equal:today',
|
||||
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
|
||||
'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 = [
|
||||
'territorio_id' => $this->form_territorio_id,
|
||||
'proclamatore_id' => $this->form_proclamatore_id,
|
||||
'anno_teocratico_id' => $this->form_anno_id,
|
||||
'assigned_at' => $this->form_assigned_at,
|
||||
'returned_at' => $this->form_returned_at ?: null,
|
||||
'counted_in_campaign' => $this->form_counted_in_campaign,
|
||||
'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
|
||||
'assigned_at' => $uscita,
|
||||
'returned_at' => $rientro,
|
||||
// "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,
|
||||
'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) {
|
||||
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 {
|
||||
$data['created_by'] = auth()->id();
|
||||
Assegnazione::create($data);
|
||||
$assegnazione = Assegnazione::create($data + ['created_by' => auth()->id()]);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -199,16 +242,15 @@ class Registro extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del
|
||||
* territorio") compilato con le assegnazioni dell'anno di servizio scelto
|
||||
* nella modale.
|
||||
* Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
|
||||
* modale; le regole di compilazione stanno in ModuloS13.
|
||||
*
|
||||
* 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
|
||||
* territorio, dati personali che il ruolo assegnatario non deve poter
|
||||
* esportare in blocco.
|
||||
*/
|
||||
public function downloadS13()
|
||||
public function downloadS13(ModuloS13 $modulo)
|
||||
{
|
||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||
|
||||
@@ -224,134 +266,15 @@ class Registro extends Component
|
||||
|
||||
$this->showS13Modal = false;
|
||||
|
||||
$pdf = Pdf::loadView('pdf.registro-s13', [
|
||||
'annoLabel' => $anno->label,
|
||||
'pagine' => $this->impaginaS13($this->righeS13($anno)),
|
||||
])->setPaper('a4', 'portrait');
|
||||
$pdf = $modulo->genera($anno);
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print($pdf->output()),
|
||||
'S-13-registro-assegnazioni-' . $anno->label . '.pdf'
|
||||
fn () => print($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
|
||||
{
|
||||
if ($this->filtroAnno === '') {
|
||||
@@ -425,8 +348,14 @@ class Registro extends Component
|
||||
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
||||
'zone' => Zona::attive()->orderBy('nome')->get(),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(),
|
||||
'territori' => Territorio::attivi()->orderBy('numero')->get(),
|
||||
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
|
||||
// Campi di scelta con ricerca della finestra di modifica. Ci sono
|
||||
// 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
137
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
137
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Richieste;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Services\RichiesteServizio;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
|
||||
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
|
||||
* mostra è l'elenco dei territori nella riconsegna, per il completamento
|
||||
* automatico del numero: numero e zona, mai a chi è assegnato né se lo è.
|
||||
* Nomi e altri dati non si vedono.
|
||||
*
|
||||
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
|
||||
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
|
||||
* - un tempo minimo fra apertura e invio (i programmi inviano subito);
|
||||
* - al massimo MASSIMO_ORA richieste all'ora per indirizzo.
|
||||
* Nei primi due casi si risponde come se fosse andata bene, senza salvare:
|
||||
* chi prova non impara cosa l'ha fermato.
|
||||
*/
|
||||
class ModuloRichiesta extends Component
|
||||
{
|
||||
public const MASSIMO_ORA = 5;
|
||||
|
||||
public const SECONDI_MINIMI = 3;
|
||||
|
||||
public string $tipo = Richiesta::NUOVO;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
public string $numero = '';
|
||||
|
||||
public string $note = '';
|
||||
|
||||
/** Trappola: nascosto alle persone, i programmi lo riempiono. */
|
||||
public string $sito = '';
|
||||
|
||||
#[Locked]
|
||||
public int $apertoAlle = 0;
|
||||
|
||||
#[Locked]
|
||||
public bool $inviata = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->apertoAlle = now()->timestamp;
|
||||
}
|
||||
|
||||
public function invia(RichiesteServizio $richieste): void
|
||||
{
|
||||
if ($this->sito !== '' || now()->timestamp - $this->apertoAlle < self::SECONDI_MINIMI) {
|
||||
$this->inviata = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$chiave = 'richiesta-pubblica:' . sha1((string) request()->ip());
|
||||
if (RateLimiter::tooManyAttempts($chiave, self::MASSIMO_ORA)) {
|
||||
$this->addError('nome', "Hai già mandato diverse richieste: riprova fra un po'.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
|
||||
'nome' => 'required|string|min:3|max:100',
|
||||
'numero' => ['nullable', 'required_if:tipo,' . Richiesta::RICONSEGNA, 'string', 'max:20',
|
||||
function ($attributo, $valore, $errore) {
|
||||
if ($this->tipo === Richiesta::RICONSEGNA && ! self::territori()->whereRaw('LOWER(numero) = ?', [Str::lower(trim((string) $valore))])->exists()) {
|
||||
$errore("Scegli il territorio dall'elenco.");
|
||||
}
|
||||
}],
|
||||
'note' => 'nullable|string|max:1000',
|
||||
], [
|
||||
'nome.required' => 'Scrivi nome e cognome.',
|
||||
'nome.min' => 'Scrivi nome e cognome.',
|
||||
'numero.required_if' => 'Scegli il territorio che riconsegni.',
|
||||
]);
|
||||
|
||||
RateLimiter::hit($chiave, 3600);
|
||||
$richieste->registra($this->tipo, $this->nome, $this->numero, $this->note);
|
||||
|
||||
$this->reset(['nome', 'numero', 'note']);
|
||||
$this->inviata = true;
|
||||
}
|
||||
|
||||
public function nuova(): void
|
||||
{
|
||||
$this->reset(['tipo', 'nome', 'numero', 'note', 'inviata']);
|
||||
$this->apertoAlle = now()->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* I territori che si possono riconsegnare: quelli attivi e quelli ancora
|
||||
* fuori anche se nel frattempo disattivati.
|
||||
*/
|
||||
private static function territori(): Builder
|
||||
{
|
||||
return Territorio::query()->where(fn ($q) => $q->where('attivo', true)
|
||||
->orWhereHas('assegnazioni', fn ($a) => $a->whereNull('returned_at')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le proposte del campo con ricerca (<x-scelta>): solo numero e zona. Il
|
||||
* valore è il numero, come lo salva la richiesta.
|
||||
*/
|
||||
private static function opzioniTerritori(): array
|
||||
{
|
||||
return self::territori()->with('zona')->get()
|
||||
->sortBy('numero', SORT_NATURAL)
|
||||
->map(fn (Territorio $t) => array_filter([
|
||||
'valore' => (string) $t->numero,
|
||||
'etichetta' => "N° {$t->numero}",
|
||||
'dettaglio' => $t->zona?->nome,
|
||||
'chiavi' => [(string) $t->numero, (string) $t->zona?->nome],
|
||||
], fn ($v) => $v !== null))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.richieste.modulo-richiesta', [
|
||||
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
|
||||
// Solo quando serve: chi chiede un territorio nuovo non vede l'elenco.
|
||||
'opzioniTerritori' => $this->tipo === Richiesta::RICONSEGNA && ! $this->inviata ? self::opzioniTerritori() : [],
|
||||
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,11 @@ class BackupManager extends Component
|
||||
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);
|
||||
|
||||
|
||||
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;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SettingsEdit extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $congregazione_nome = '';
|
||||
public int $giorni_giacenza_da_assegnare = 120;
|
||||
public int $giorni_giacenza_prioritari = 180;
|
||||
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
|
||||
'audit_retention_days' => $this->audit_retention_days,
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Impostazioni aggiornate.');
|
||||
$this->avvisa('Impostazioni aggiornate.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Tipologia;
|
||||
|
||||
class TipologieIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $nuovaTipologia = '';
|
||||
public ?int $editingId = null;
|
||||
public string $editingNome = '';
|
||||
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
|
||||
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
|
||||
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
|
||||
$this->nuovaTipologia = '';
|
||||
session()->flash('success', 'Tipologia aggiunta.');
|
||||
$this->avvisa('Tipologia aggiunta.');
|
||||
}
|
||||
|
||||
public function startEdit(int $id)
|
||||
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
|
||||
$tipo->update(['nome' => $this->editingNome]);
|
||||
$this->editingId = null;
|
||||
$this->editingNome = '';
|
||||
session()->flash('success', 'Tipologia aggiornata.');
|
||||
$this->avvisa('Tipologia aggiornata.');
|
||||
}
|
||||
|
||||
public function cancelEdit()
|
||||
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
|
||||
{
|
||||
$tipo = Tipologia::findOrFail($id);
|
||||
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;
|
||||
}
|
||||
$tipo->delete();
|
||||
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
|
||||
$this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,23 +2,40 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\GruppiServizio;
|
||||
use Illuminate\Validation\Rule;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UsersIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* Nuova password temporanea di un sorvegliante di gruppo, mostrata una
|
||||
* volta sola (come nella pagina del gruppo). Locked: le scrive solo
|
||||
* nuovaPasswordSorvegliante().
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $passwordPerUtenteId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $passwordTemporanea = null;
|
||||
|
||||
public ?int $editingUserId = null;
|
||||
public string $editName = '';
|
||||
public string $editEmail = '';
|
||||
@@ -28,7 +45,10 @@ class UsersIndex extends Component
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// "sorvegliante" si dà solo creando un gruppo di servizio, con
|
||||
// l'utenza collegata al proclamatore: da qui non si assegna.
|
||||
$this->availableRoles = Role::query()
|
||||
->where('name', '!=', 'sorvegliante')
|
||||
->orderBy('name')
|
||||
->pluck('name')
|
||||
->all();
|
||||
@@ -79,12 +99,54 @@ class UsersIndex extends Component
|
||||
if (! empty($this->availableRoles)) {
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le utenze dei sorveglianti di gruppo si gestiscono
|
||||
* da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un
|
||||
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
|
||||
*/
|
||||
private function gestitaDaiGruppi(User $user): bool
|
||||
{
|
||||
if ($user->proclamatore_id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->avvisaErrore("L'utenza di {$user->name} è di un sorvegliante di gruppo: si gestisce da Gruppi di servizio.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function startEdit(int $userId): void
|
||||
{
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
if ($this->gestitaDaiGruppi($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingUserId = $user->id;
|
||||
$this->editName = $user->name;
|
||||
@@ -114,6 +176,26 @@ class UsersIndex extends Component
|
||||
|
||||
$validated = $this->validate($this->editRules());
|
||||
$user = User::query()->findOrFail($this->editingUserId);
|
||||
if ($this->gestitaDaiGruppi($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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->email = $validated['editEmail'];
|
||||
@@ -122,71 +204,87 @@ class UsersIndex extends Component
|
||||
$user->password = $validated['editPassword'];
|
||||
}
|
||||
|
||||
$emailCambiata = $user->isDirty('email');
|
||||
$user->save();
|
||||
$user->syncRoles([$validated['editSelectedRole']]);
|
||||
|
||||
$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
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
if ($this->gestitaDaiGruppi($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $currentUser || $currentUser->id === $user->id) {
|
||||
session()->flash('error', 'Non puoi cancellare il tuo utente.');
|
||||
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $currentUser) {
|
||||
$causerName = $user->name;
|
||||
$causerEmail = $user->email;
|
||||
$deletedAt = now()->toDateTimeString();
|
||||
|
||||
Activity::query()
|
||||
->where('causer_type', User::class)
|
||||
->where('causer_id', $user->id)
|
||||
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
|
||||
foreach ($activities as $activity) {
|
||||
$properties = $activity->properties?->toArray() ?? [];
|
||||
$properties['causer_name'] = $causerName;
|
||||
$properties['causer_email'] = $causerEmail;
|
||||
$properties['causer_deleted_at'] = $deletedAt;
|
||||
|
||||
$activity->properties = $properties;
|
||||
$activity->save();
|
||||
}
|
||||
});
|
||||
|
||||
Assegnazione::query()
|
||||
->where('created_by', $user->id)
|
||||
->update(['created_by' => $currentUser->id]);
|
||||
|
||||
Assegnazione::query()
|
||||
->where('returned_by', $user->id)
|
||||
->update(['returned_by' => $currentUser->id]);
|
||||
|
||||
$user->syncRoles([]);
|
||||
$user->delete();
|
||||
});
|
||||
$user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
|
||||
|
||||
if ($this->editingUserId === $userId) {
|
||||
$this->cancelEdit();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
|
||||
$this->avvisa('Utente cancellato. I log sono stati preservati.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante ha perso la password: una temporanea nuova, da cambiare
|
||||
* al primo accesso. È l'unica cosa che di quell'utenza si fa da qui; il
|
||||
* resto passa dal gruppo (gestitaDaiGruppi).
|
||||
*/
|
||||
public function nuovaPasswordSorvegliante(int $userId): void
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
try {
|
||||
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($user);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->passwordPerUtenteId = $user->id;
|
||||
}
|
||||
|
||||
public function chiudiPassword(): void
|
||||
{
|
||||
$this->reset(['passwordPerUtenteId', 'passwordTemporanea']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.users-index', [
|
||||
'users' => User::query()->with('roles')->orderBy('name')->get(),
|
||||
// Per ogni sorvegliante il suo gruppo, per il collegamento.
|
||||
'gruppiDeiSorveglianti' => GruppoServizio::pluck('id', 'sorvegliante_id'),
|
||||
'utentePassword' => $this->passwordPerUtenteId ? User::find($this->passwordPerUtenteId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,36 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\TerritorioPdfImportDispatcher;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class XmlExchange extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithFileUploads;
|
||||
|
||||
public array $importStats = [];
|
||||
@@ -52,23 +61,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([
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
||||
]);
|
||||
|
||||
$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) {
|
||||
$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));
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
|
||||
$storedPath = $file->storeAs(
|
||||
'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'
|
||||
);
|
||||
|
||||
@@ -77,14 +176,34 @@ class XmlExchange extends Component
|
||||
'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(
|
||||
$importId,
|
||||
$storedFiles,
|
||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
|
||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
|
||||
$ignorati,
|
||||
$scartati
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function refreshPdfImportStatus(): void
|
||||
@@ -114,8 +233,9 @@ class XmlExchange extends Component
|
||||
* contenuti non possono essere ri-renderizzati dal server senza cancellare
|
||||
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
|
||||
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
|
||||
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
|
||||
* non partecipa al posto unico: puo' girare mentre un backup lavora.
|
||||
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
|
||||
* 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
|
||||
{
|
||||
@@ -156,10 +276,10 @@ class XmlExchange extends Component
|
||||
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)
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
|
||||
|
||||
$this->currentPdfImportId = $importId;
|
||||
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
||||
@@ -172,13 +292,13 @@ class XmlExchange extends Component
|
||||
$this->logPdfSpediti = 0;
|
||||
$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()
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -236,16 +356,99 @@ class XmlExchange extends Component
|
||||
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
|
||||
{
|
||||
$this->importStats = [];
|
||||
$this->importIssues = [];
|
||||
|
||||
$xml = @simplexml_load_string($content);
|
||||
if (! $xml) {
|
||||
return ['error' => 'Impossibile leggere il file XML.'];
|
||||
$xml = $this->leggiXml($content);
|
||||
if (is_string($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');
|
||||
if (! $actorId) {
|
||||
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
||||
@@ -264,14 +467,23 @@ class XmlExchange extends Component
|
||||
'assegnazioni_saltate' => 0,
|
||||
];
|
||||
|
||||
DB::transaction(function () use ($xml, $actorId, &$stats) {
|
||||
try {
|
||||
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||
Assegnazione::query()->delete();
|
||||
GruppoServizio::query()->delete();
|
||||
Campagna::query()->delete();
|
||||
Territorio::query()->withTrashed()->forceDelete();
|
||||
Proclamatore::query()->withTrashed()->forceDelete();
|
||||
Tipologia::query()->delete();
|
||||
Zona::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();
|
||||
|
||||
// The mass delete above fires no model events, so Setting's
|
||||
@@ -299,6 +511,10 @@ class XmlExchange extends Component
|
||||
]);
|
||||
}
|
||||
|
||||
if ($posta !== []) {
|
||||
Setting::instance()->update($posta);
|
||||
}
|
||||
|
||||
// Users are UPSERTED by email, never wiped. Deleting the users
|
||||
// table during a restore risks locking out the administrator
|
||||
// performing the import (e.g. a backup that predates their account,
|
||||
@@ -306,10 +522,18 @@ class XmlExchange extends Component
|
||||
// accounts in the file are (re)created. $userMap remaps the
|
||||
// created_by/returned_by FKs on the assignments below.
|
||||
$userMap = [];
|
||||
$utentiDaCollegare = [];
|
||||
foreach (($xml->users->user ?? []) as $userNode) {
|
||||
$email = trim((string) ($userNode->email ?? ''));
|
||||
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
||||
|
||||
// Nei file del 19-20 ottobre 2026 c'erano le utenze disattivate
|
||||
// degli ex sorveglianti: ora, sciolto il gruppo, l'utenza non
|
||||
// esiste più. Le loro assegnazioni passano a chi importa.
|
||||
if (trim((string) ($userNode->disattivato_at ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($email === '') {
|
||||
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
|
||||
continue;
|
||||
@@ -334,6 +558,10 @@ class XmlExchange extends Component
|
||||
$roleNames[] = $roleName;
|
||||
}
|
||||
}
|
||||
$eAttore = $attore !== null && $user->id === $attore->id;
|
||||
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
|
||||
$roleNames[] = 'amministratore';
|
||||
}
|
||||
$user->syncRoles($roleNames);
|
||||
|
||||
// Password hash, remember_token, verification flag and
|
||||
@@ -341,13 +569,24 @@ class XmlExchange extends Component
|
||||
// hash is preserved exactly (the 'hashed' cast would re-validate
|
||||
// the bcrypt cost and could reject a hash made with a different
|
||||
// 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 = [];
|
||||
$passwordHash = (string) ($userNode->password ?? '');
|
||||
if ($passwordHash !== '') {
|
||||
if ($passwordHash !== '' && ! $eAttore) {
|
||||
$meta['password'] = $passwordHash;
|
||||
$meta['password_change_required'] = true;
|
||||
$meta['password_temporanea'] = false;
|
||||
}
|
||||
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 ?? ''));
|
||||
|
||||
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
||||
@@ -361,6 +600,10 @@ class XmlExchange extends Component
|
||||
|
||||
DB::table('users')->where('id', $user->id)->update($meta);
|
||||
|
||||
// Utenza di un sorvegliante: si collega al proclamatore dopo che
|
||||
// i proclamatori sono stati importati (vedi i gruppi di servizio).
|
||||
$utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
|
||||
|
||||
$stats['utenti_importati']++;
|
||||
if ($legacyId !== '') {
|
||||
$userMap[$legacyId] = $user->id;
|
||||
@@ -396,27 +639,62 @@ class XmlExchange extends Component
|
||||
}
|
||||
|
||||
$proclamatoreMap = [];
|
||||
$gruppiDeiProclamatori = [];
|
||||
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
||||
$p = Proclamatore::create([
|
||||
'nome' => (string) $proclamatoreNode->nome,
|
||||
'cognome' => (string) $proclamatoreNode->cognome,
|
||||
'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);
|
||||
$stats['proclamatori_importati']++;
|
||||
$gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? '');
|
||||
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$proclamatoreMap[$legacyId] = $p->id;
|
||||
}
|
||||
}
|
||||
|
||||
$annoMap = [];
|
||||
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
||||
// Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di
|
||||
// loro. Poi i collegamenti che li aspettavano (membri, utenze).
|
||||
$gruppoMap = [];
|
||||
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
|
||||
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
|
||||
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
|
||||
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
|
||||
// non si importa.
|
||||
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
$g = GruppoServizio::create([
|
||||
'nome' => (string) $gruppoNode->nome,
|
||||
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
|
||||
]);
|
||||
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
|
||||
$legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$gruppoMap[$legacyId] = $g->id;
|
||||
}
|
||||
}
|
||||
foreach ($gruppiDeiProclamatori as $idProclamatore => $legacyGruppo) {
|
||||
if (isset($gruppoMap[$legacyGruppo])) {
|
||||
DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]);
|
||||
}
|
||||
}
|
||||
foreach ($utentiDaCollegare as $idUtente => $legacyProclamatore) {
|
||||
DB::table('users')->where('id', $idUtente)->update([
|
||||
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$annoMap = []; foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
||||
$label = (string) $annoNode->label;
|
||||
$anno = AnnoTeocratico::create([
|
||||
'label' => $label,
|
||||
'start_date' => (string) $annoNode->start_date,
|
||||
'end_date' => (string) $annoNode->end_date,
|
||||
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
|
||||
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
|
||||
]);
|
||||
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
||||
$stats['anni_importati']++;
|
||||
@@ -430,8 +708,8 @@ class XmlExchange extends Component
|
||||
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
||||
$campagna = Campagna::create([
|
||||
'descrizione' => (string) $campagnaNode->descrizione,
|
||||
'start_date' => (string) $campagnaNode->start_date,
|
||||
'end_date' => (string) $campagnaNode->end_date,
|
||||
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
|
||||
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
|
||||
]);
|
||||
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
||||
$stats['campagne_importate']++;
|
||||
@@ -455,9 +733,14 @@ class XmlExchange extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
|
||||
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
|
||||
continue;
|
||||
// Zona e tipologia sono facoltative (le colonne sono nullable e
|
||||
// un territorio resta senza quando la sua zona viene
|
||||
// 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 ?? '');
|
||||
@@ -465,12 +748,13 @@ class XmlExchange extends Component
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => $numero,
|
||||
'zona_id' => $zoneMap[$legacyZonaId],
|
||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
|
||||
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
|
||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
|
||||
'confini' => (string) ($territorioNode->confini ?? ''),
|
||||
'note' => (string) ($territorioNode->note ?? ''),
|
||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
||||
'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null,
|
||||
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
||||
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
||||
]);
|
||||
@@ -532,6 +816,11 @@ class XmlExchange extends Component
|
||||
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
|
||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
||||
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
|
||||
'cartaceo' => ((int) ($assegnazioneNode->cartaceo ?? 0)) === 1,
|
||||
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
|
||||
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null,
|
||||
'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,
|
||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||
'created_by' => $createdBy,
|
||||
@@ -541,6 +830,14 @@ class XmlExchange extends Component
|
||||
$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 [
|
||||
'stats' => $stats,
|
||||
@@ -576,21 +873,84 @@ class XmlExchange extends Component
|
||||
'email' => (string) $u->email,
|
||||
'password' => (string) $u->getAuthPassword(),
|
||||
'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(),
|
||||
// Sorvegliante di gruppo: il proclamatore collegato.
|
||||
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
|
||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||
])->all(),
|
||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
||||
'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(),
|
||||
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
||||
'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(),
|
||||
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
|
||||
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']),
|
||||
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
|
||||
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||
'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', 'cartaceo', 'gruppo_servizio_id', 'gruppo_nome', '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
|
||||
{
|
||||
$rows = $this->extractInsertRows($sql);
|
||||
@@ -880,8 +1240,12 @@ class XmlExchange extends Component
|
||||
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 {
|
||||
return Carbon::parse($raw)->toDateString();
|
||||
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
|
||||
} catch (\Throwable $e) {
|
||||
return $nullable ? null : null;
|
||||
}
|
||||
@@ -923,7 +1287,7 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
|
||||
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
|
||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||
$rolesNode = $node->addChild('roles');
|
||||
@@ -957,6 +1321,7 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
||||
$node->addChild('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? ''));
|
||||
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
|
||||
$this->addTimestampNodes($node, $proclamatore);
|
||||
}
|
||||
@@ -972,6 +1337,7 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
||||
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
||||
$node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? ''));
|
||||
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
|
||||
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
||||
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
||||
@@ -988,6 +1354,16 @@ class XmlExchange extends Component
|
||||
$this->addTimestampNodes($node, $anno);
|
||||
}
|
||||
|
||||
// Gruppi di servizio: il sorvegliante è un proclamatore (legacy id).
|
||||
$gruppiNode = $xml->addChild('gruppi_servizio');
|
||||
foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) {
|
||||
$node = $gruppiNode->addChild('gruppo');
|
||||
$this->addLegacyIdAttribute($node, $gruppo);
|
||||
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
|
||||
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
|
||||
$this->addTimestampNodes($node, $gruppo);
|
||||
}
|
||||
|
||||
$campagneNode = $xml->addChild('campagne');
|
||||
foreach ($dataset['campagne'] as $campagna) {
|
||||
$node = $campagneNode->addChild('campagna');
|
||||
@@ -1012,6 +1388,11 @@ class XmlExchange extends Component
|
||||
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
|
||||
$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_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
|
||||
$node->addChild('cartaceo', (string) ((int) ($assegnazione['cartaceo'] ?? 0)));
|
||||
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
|
||||
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
|
||||
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
|
||||
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||
$this->addTimestampNodes($node, $assegnazione);
|
||||
@@ -1022,6 +1403,13 @@ class XmlExchange extends Component
|
||||
|
||||
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);
|
||||
$dom = dom_import_simplexml($child);
|
||||
$dom->textContent = $value;
|
||||
@@ -1062,7 +1450,7 @@ class XmlExchange extends Component
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateTimeString();
|
||||
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||
if ($operationId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->confermaReset = '';
|
||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||
$this->operationId = $operationId;
|
||||
|
||||
CreateBackupArchive::dispatch($this->operationId, true);
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Zona;
|
||||
|
||||
class ZoneIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $nuovaZona = '';
|
||||
public ?int $editingId = null;
|
||||
public string $editingNome = '';
|
||||
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
|
||||
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
|
||||
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
|
||||
$this->nuovaZona = '';
|
||||
session()->flash('success', 'Zona aggiunta.');
|
||||
$this->avvisa('Zona aggiunta.');
|
||||
}
|
||||
|
||||
public function startEdit(int $id)
|
||||
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
|
||||
$zona->update(['nome' => $this->editingNome]);
|
||||
$this->editingId = null;
|
||||
$this->editingNome = '';
|
||||
session()->flash('success', 'Zona aggiornata.');
|
||||
$this->avvisa('Zona aggiornata.');
|
||||
}
|
||||
|
||||
public function cancelEdit()
|
||||
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
|
||||
{
|
||||
$zona = Zona::findOrFail($id);
|
||||
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;
|
||||
}
|
||||
$zona->delete();
|
||||
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
|
||||
$this->avvisa("Zona '{$zona->nome}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioCestino extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function restore(int $id)
|
||||
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($territorio)
|
||||
->log('restored');
|
||||
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
|
||||
$this->avvisa("Territorio {$territorio->numero} ripristinato.");
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
|
||||
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
|
||||
$this->rimuoviFile($territorio);
|
||||
|
||||
$territorio->forceDelete();
|
||||
session()->flash('success', 'Territorio eliminato definitivamente.');
|
||||
$this->avvisa('Territorio eliminato definitivamente.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\Territorio;
|
||||
@@ -11,6 +12,8 @@ use App\Services\TerritorioThumbnailService;
|
||||
|
||||
class TerritorioCreate extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
use WithFileUploads;
|
||||
|
||||
public string $numero = '';
|
||||
@@ -64,7 +67,7 @@ class TerritorioCreate extends Component
|
||||
->log('created');
|
||||
|
||||
session()->flash('success', "Territorio {$territorio->numero} creato.");
|
||||
return redirect()->route('territori.index');
|
||||
return redirect()->to($this->destinazione(route('territori.index')));
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\Territorio;
|
||||
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioEdit extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
use WithFileUploads;
|
||||
|
||||
public Territorio $territorio;
|
||||
@@ -64,14 +67,17 @@ class TerritorioEdit extends Component
|
||||
|
||||
if ($this->pdf) {
|
||||
$thumbService = app(TerritorioThumbnailService::class);
|
||||
// Remove old PDF and thumbnail
|
||||
if ($this->territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
||||
}
|
||||
if ($this->territorio->thumbnail_path) {
|
||||
$thumbService->delete($this->territorio->thumbnail_path);
|
||||
}
|
||||
// Prima il nuovo, poi il vecchio nello storico (ripristinabile
|
||||
// dalla pagina Piantine). Prima il vecchio si cancellava subito:
|
||||
// un caricamento sbagliato non si poteva disfare, e con il
|
||||
// salvataggio fallito il territorio restava senza PDF.
|
||||
$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['thumbnail_path'] = $thumbService->generate($pdfPath);
|
||||
}
|
||||
@@ -79,7 +85,7 @@ class TerritorioEdit extends Component
|
||||
$this->territorio->update($data);
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -11,16 +13,28 @@ use App\Models\Tipologia;
|
||||
|
||||
class TerritorioIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
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 = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterZona = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterTipologia = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterStato = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterPriorita = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterPdf = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterContenuti = '';
|
||||
#[Url(except: 'numero')]
|
||||
public string $sortField = 'numero';
|
||||
#[Url(except: 'asc')]
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
protected $queryString = [
|
||||
@@ -127,7 +141,7 @@ class TerritorioIndex extends Component
|
||||
->performedOn($territorio)
|
||||
->log('soft_deleted');
|
||||
$territorio->delete();
|
||||
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
|
||||
$this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -141,6 +155,7 @@ class TerritorioIndex extends Component
|
||||
'tipologia',
|
||||
'assegnazioneCorrente.proclamatore',
|
||||
'ultimaAssegnazione',
|
||||
'gruppo',
|
||||
]);
|
||||
|
||||
if ($this->search) {
|
||||
@@ -165,6 +180,7 @@ class TerritorioIndex extends Component
|
||||
if ($this->filterStato) {
|
||||
match ($this->filterStato) {
|
||||
'in_reparto' => $query->inReparto(),
|
||||
'in_gruppo' => $query->inGruppo(),
|
||||
'assegnato' => $query->assegnato(),
|
||||
'da_rientrare' => $query->daRientrare(),
|
||||
'inattivo' => $query->where('attivo', false),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Assegnazione;
|
||||
|
||||
class TerritorioShow extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Territorio $territorio;
|
||||
|
||||
public function mount(Territorio $territorio)
|
||||
@@ -20,8 +23,11 @@ class TerritorioShow extends Component
|
||||
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
|
||||
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
|
||||
->orderByDesc('assigned_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label)
|
||||
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
|
||||
->sortKeysDesc();
|
||||
|
||||
return view('livewire.territori.territorio-show', [
|
||||
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
||||
|
||||
50
app/Mail/ModuloS13Mail.php
Normal file
50
app/Mail/ModuloS13Mail.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\User;
|
||||
use Illuminate\Mail\Attachment;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Il modulo S-13 in PDF per un amministratore (registro:invia-s13). Non va in
|
||||
* coda (niente ShouldQueue): il PDF contiene i nomi dei proclamatori, che in
|
||||
* coda starebbero in chiaro.
|
||||
*/
|
||||
class ModuloS13Mail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public User $utente,
|
||||
public AnnoTeocratico $anno,
|
||||
private string $pdf,
|
||||
) {}
|
||||
|
||||
public function nomeFile(): string
|
||||
{
|
||||
return "S-13-registro-assegnazioni-{$this->anno->label}.pdf";
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: "Modulo S-13 — anno di servizio {$this->anno->label}");
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'mail.modulo-s13',
|
||||
with: ['data' => now()->format('d/m/Y')],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<int, Attachment> */
|
||||
public function attachments(): array
|
||||
{
|
||||
return [
|
||||
Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,22 @@ class Assegnazione extends Model
|
||||
'campaign_id',
|
||||
'pdf_access_code',
|
||||
'link_sent',
|
||||
'link_da_inviare',
|
||||
'link_canale',
|
||||
'cartaceo',
|
||||
'note',
|
||||
'created_by',
|
||||
'returned_by',
|
||||
'gruppo_servizio_id',
|
||||
'gruppo_nome',
|
||||
];
|
||||
|
||||
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */
|
||||
public function gruppo()
|
||||
{
|
||||
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -32,6 +43,10 @@ class Assegnazione extends Model
|
||||
'returned_at' => 'date',
|
||||
'counted_in_campaign' => 'boolean',
|
||||
'link_sent' => 'boolean',
|
||||
'link_da_inviare' => 'boolean',
|
||||
// Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei).
|
||||
'cartaceo' => 'boolean',
|
||||
'gruppo_servizio_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -53,6 +68,58 @@ class Assegnazione extends Model
|
||||
$territorio->update(['prioritario' => false]);
|
||||
}
|
||||
});
|
||||
|
||||
// Al rientro un territorio del pacchetto di un gruppo torna in reparto
|
||||
// ed esce dal pacchetto. Qui e non nella pagina Rientra: vale per ogni
|
||||
// via (rientro del sorvegliante, dell'amministratore, Registro).
|
||||
static::updated(function (self $assegnazione): void {
|
||||
if (! $assegnazione->wasChanged('returned_at') || $assegnazione->returned_at === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Territorio::withTrashed()
|
||||
->whereKey($assegnazione->territorio_id)
|
||||
->whereNotNull('gruppo_servizio_id')
|
||||
->update(['gruppo_servizio_id' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────
|
||||
@@ -95,7 +162,8 @@ class Assegnazione extends Model
|
||||
public function getGiorniAttribute(): int
|
||||
{
|
||||
$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
|
||||
@@ -173,9 +241,56 @@ class Assegnazione extends Model
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -197,16 +312,69 @@ class Assegnazione extends Model
|
||||
|
||||
// ─── 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.
|
||||
* Returns the matching campaign or null.
|
||||
*/
|
||||
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)
|
||||
->where('end_date', '>=', $this->assigned_at)
|
||||
->where('end_date', '>=', $assignedAt)
|
||||
->orderByDesc('start_date')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,13 @@ class Campagna extends Model
|
||||
*/
|
||||
public static function attiva(): ?static
|
||||
{
|
||||
return static::where('start_date', '<=', now())
|
||||
->where('end_date', '>=', now())
|
||||
// Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -60,6 +65,16 @@ class Campagna extends Model
|
||||
->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.
|
||||
*/
|
||||
@@ -70,9 +85,11 @@ class Campagna extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign coverage percentage.
|
||||
* Numerator: assignments counted for campaign
|
||||
* Denominator: total active territories
|
||||
* Quota dei territori attivi percorsi durante la campagna.
|
||||
*
|
||||
* 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
|
||||
{
|
||||
@@ -82,13 +99,17 @@ class Campagna extends Model
|
||||
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)
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
85
app/Models/GruppoServizio.php
Normal file
85
app/Models/GruppoServizio.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
/**
|
||||
* Gruppo di servizio: un sorvegliante (proclamatore con un'utenza limitata
|
||||
* al gruppo), i membri (proclamatori, un solo gruppo a testa) e un pacchetto
|
||||
* di territori che il sorvegliante distribuisce ai membri.
|
||||
*
|
||||
* Il pacchetto non è un'assegnazione: un territorio del pacchetto non ancora
|
||||
* dato a nessuno è "in carico al gruppo" e non entra nelle statistiche. Quando
|
||||
* il sorvegliante lo dà a un membro nasce un'assegnazione normale, con
|
||||
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
|
||||
* reparto ed esce dal pacchetto.
|
||||
*
|
||||
* Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
|
||||
* ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
|
||||
* il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
|
||||
*/
|
||||
class GruppoServizio extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
protected $table = 'gruppi_servizio';
|
||||
|
||||
protected $fillable = ['nome', 'sorvegliante_id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['sorvegliante_id' => 'integer'];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['nome', 'sorvegliante_id'])
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function sorvegliante()
|
||||
{
|
||||
return $this->belongsTo(Proclamatore::class, 'sorvegliante_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function membri()
|
||||
{
|
||||
return $this->hasMany(Proclamatore::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
/** Il pacchetto: i territori in carico al gruppo, dati o non ancora dati. */
|
||||
public function territori()
|
||||
{
|
||||
return $this->hasMany(Territorio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
public function assegnazioni()
|
||||
{
|
||||
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* I numeri del gruppo per la scheda del proclamatore e il profilo del
|
||||
* sorvegliante: solo conteggi in SQL, nessun nome da decifrare.
|
||||
*
|
||||
* @return array{membri: int, pacchetto: int, da_dare: int, dati: int, assegnazioni: int}
|
||||
*/
|
||||
public function riepilogo(): array
|
||||
{
|
||||
$pacchetto = $this->territori()->count();
|
||||
$dati = $this->territori()->whereHas('assegnazioni', fn ($q) => $q->whereNull('returned_at'))->count();
|
||||
|
||||
return [
|
||||
'membri' => $this->membri()->count(),
|
||||
'pacchetto' => $pacchetto,
|
||||
'da_dare' => $pacchetto - $dati,
|
||||
'dati' => $dati,
|
||||
'assegnazioni' => $this->assegnazioni()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,62 @@ class Proclamatore extends Model
|
||||
|
||||
protected $table = 'proclamatori';
|
||||
|
||||
protected $fillable = ['nome', 'cognome', 'attivo'];
|
||||
// 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', 'gruppo_servizio_id'];
|
||||
|
||||
/** Il gruppo di servizio di cui è membro (al massimo uno). */
|
||||
public function gruppo()
|
||||
{
|
||||
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
/** L'utenza, se è sorvegliante di un gruppo. */
|
||||
public function utente()
|
||||
{
|
||||
return $this->hasOne(User::class, 'proclamatore_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un sorvegliante che non è più attivo — reso non attivo, spostato nel
|
||||
* cestino o anonimizzato — non guida più il gruppo: il gruppo si scioglie
|
||||
* (GruppiServizio::sciogli), da qualunque pagina arrivi la modifica. Un
|
||||
* membro nel cestino lascia il gruppo.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function (self $proclamatore) {
|
||||
if ($proclamatore->wasChanged('attivo') && ! $proclamatore->attivo) {
|
||||
$proclamatore->lasciaIlGruppo('sorvegliante non più attivo');
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function (self $proclamatore) {
|
||||
$proclamatore->lasciaIlGruppo('sorvegliante eliminato');
|
||||
});
|
||||
}
|
||||
|
||||
/** Il gruppo attivo di cui è sorvegliante, se c'è. */
|
||||
public function gruppoSorvegliato(): ?GruppoServizio
|
||||
{
|
||||
return GruppoServizio::where('sorvegliante_id', $this->id)->first();
|
||||
}
|
||||
|
||||
private function lasciaIlGruppo(string $motivo): void
|
||||
{
|
||||
$gruppo = $this->gruppoSorvegliato();
|
||||
if ($gruppo) {
|
||||
app(\App\Services\GruppiServizio::class)->sciogli($gruppo, $motivo);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->trashed() && $this->gruppo_servizio_id) {
|
||||
static::withTrashed()->whereKey($this->id)->update(['gruppo_servizio_id' => null]);
|
||||
$this->gruppo_servizio_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@@ -21,6 +76,7 @@ class Proclamatore extends Model
|
||||
'nome' => 'encrypted',
|
||||
'cognome' => 'encrypted',
|
||||
'attivo' => 'boolean',
|
||||
'gruppo_servizio_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,6 +96,60 @@ class Proclamatore extends Model
|
||||
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
|
||||
{
|
||||
// Il gruppo di servizio come nota, solo se già caricato (opzioniScelta).
|
||||
$gruppo = $this->relationLoaded('gruppo') ? $this->gruppo_etichetta : null;
|
||||
|
||||
return array_filter([
|
||||
'valore' => (string) $this->id,
|
||||
'etichetta' => $this->nome_completo,
|
||||
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
|
||||
'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. Con $conGruppo
|
||||
* chi fa parte di un gruppo di servizio lo dice accanto al nome (non serve
|
||||
* dove sono tutti dello stesso gruppo, come in "Il mio gruppo").
|
||||
*/
|
||||
public static function opzioniScelta(iterable $proclamatori, bool $conGruppo = true): array
|
||||
{
|
||||
if ($conGruppo && $proclamatori instanceof \Illuminate\Database\Eloquent\Collection) {
|
||||
$proclamatori->loadMissing('gruppo');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* "Sorvegliante del gruppo Zisa" o "Gruppo Zisa"; null fuori dai gruppi.
|
||||
* Senza gruppo_servizio_id non fa query.
|
||||
*/
|
||||
public function getGruppoEtichettaAttribute(): ?string
|
||||
{
|
||||
$gruppo = $this->gruppo;
|
||||
if (! $gruppo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $gruppo->sorvegliante_id === $this->id
|
||||
? "Sorvegliante del gruppo {$gruppo->nome}"
|
||||
: "Gruppo {$gruppo->nome}";
|
||||
}
|
||||
|
||||
public function assegnazioni()
|
||||
{
|
||||
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
||||
@@ -64,6 +174,9 @@ class Proclamatore extends Model
|
||||
$this->nome = 'Anonimo';
|
||||
$this->cognome = 'Proclamatore #' . $this->id;
|
||||
$this->attivo = false;
|
||||
$this->gruppo_servizio_id = null;
|
||||
// Se era sorvegliante, il salvataggio (non più attivo) scioglie il
|
||||
// gruppo ed elimina la sua utenza: booted().
|
||||
$this->save();
|
||||
$this->delete(); // soft delete
|
||||
}
|
||||
|
||||
112
app/Models/Richiesta.php
Normal file
112
app/Models/Richiesta.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
/**
|
||||
* Una richiesta dal modulo pubblico (/richiesta): un proclamatore chiede un
|
||||
* territorio nuovo o ne annuncia la riconsegna. Non tocca niente da sola:
|
||||
* compare nella Home a chi ha `richieste.gestisci` (amministratore e
|
||||
* assistenti), che la porta sul modulo di assegnazione o di rientro già
|
||||
* esistenti; fatta l'operazione la richiesta risulta evasa. Si può anche
|
||||
* scartare. Logica in App\Services\RichiesteServizio.
|
||||
*
|
||||
* Nome e note li scrive chi compila, senza account: cifrati come i dati dei
|
||||
* proclamatori, e fuori dal registro delle attività (che logga solo lo stato).
|
||||
*/
|
||||
class Richiesta extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public const NUOVO = 'nuovo';
|
||||
public const RICONSEGNA = 'riconsegna';
|
||||
|
||||
public const APERTA = 'aperta';
|
||||
public const EVASA = 'evasa';
|
||||
public const SCARTATA = 'scartata';
|
||||
|
||||
protected $table = 'richieste';
|
||||
|
||||
// Appena creata è aperta anche nell'oggetto, non solo nel database.
|
||||
protected $attributes = ['stato' => self::APERTA];
|
||||
|
||||
// L'arrivo lo registra RichiesteServizio (con il numero del territorio):
|
||||
// qui solo i cambi di stato, con chi li ha fatti.
|
||||
protected static $recordEvents = ['updated'];
|
||||
|
||||
protected $fillable = ['tipo', 'nome', 'numero_territorio', 'note', 'stato', 'assegnazione_id', 'gestita_da', 'gestita_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nome' => 'encrypted',
|
||||
'note' => 'encrypted',
|
||||
'gestita_at' => 'datetime',
|
||||
'assegnazione_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['tipo', 'stato', 'assegnazione_id'])
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function assegnazione()
|
||||
{
|
||||
return $this->belongsTo(Assegnazione::class);
|
||||
}
|
||||
|
||||
public function gestitaDa()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'gestita_da');
|
||||
}
|
||||
|
||||
public function scopeAperte($query)
|
||||
{
|
||||
return $query->where('stato', self::APERTA);
|
||||
}
|
||||
|
||||
public function isAperta(): bool
|
||||
{
|
||||
return $this->stato === self::APERTA;
|
||||
}
|
||||
|
||||
public function getTipoEtichettaAttribute(): string
|
||||
{
|
||||
return $this->tipo === self::RICONSEGNA ? 'Riconsegna' : 'Nuovo territorio';
|
||||
}
|
||||
|
||||
/** Chiusa da un'assegnazione o da un rientro fatti a partire da lei. */
|
||||
public function evadi(?Assegnazione $assegnazione, ?User $chi): void
|
||||
{
|
||||
if (! $this->isAperta()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'stato' => self::EVASA,
|
||||
'assegnazione_id' => $assegnazione?->id,
|
||||
'gestita_da' => $chi?->id,
|
||||
'gestita_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function scarta(?User $chi): void
|
||||
{
|
||||
if (! $this->isAperta()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'stato' => self::SCARTATA,
|
||||
'gestita_da' => $chi?->id,
|
||||
'gestita_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,31 @@ class Setting extends Model
|
||||
'pdf_viewer_show_download',
|
||||
'audit_retention_days',
|
||||
'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
|
||||
{
|
||||
return [
|
||||
@@ -33,6 +56,8 @@ class Setting extends Model
|
||||
'home_limit_list' => 'integer',
|
||||
'assignment_link_ttl_hours' => 'integer',
|
||||
'audit_retention_days' => 'integer',
|
||||
'mail_port' => 'integer',
|
||||
'mail_password' => 'encrypted',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,20 @@ class Territorio extends Model
|
||||
'thumbnail_path',
|
||||
'attivo',
|
||||
'prioritario',
|
||||
'gruppo_servizio_id',
|
||||
];
|
||||
|
||||
/** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */
|
||||
public function gruppo()
|
||||
{
|
||||
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attivo' => 'boolean',
|
||||
'gruppo_servizio_id' => 'integer',
|
||||
'prioritario' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -61,9 +69,13 @@ class Territorio extends Model
|
||||
|
||||
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')
|
||||
->whereNull('returned_at')
|
||||
->latestOfMany('assigned_at');
|
||||
->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
|
||||
}
|
||||
|
||||
public function ultimaAssegnazione()
|
||||
@@ -91,9 +103,25 @@ class Territorio extends Model
|
||||
return 'assegnato';
|
||||
}
|
||||
|
||||
// Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in
|
||||
// reparto (non si assegna dalla pagina normale), è del gruppo.
|
||||
if ($this->gruppo_servizio_id) {
|
||||
return 'in_gruppo';
|
||||
}
|
||||
|
||||
return 'in_reparto';
|
||||
}
|
||||
|
||||
/** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */
|
||||
public function getStatoEtichettaAttribute(): string
|
||||
{
|
||||
$stato = $this->stato;
|
||||
|
||||
return $stato === 'in_gruppo'
|
||||
? 'In gruppo ' . ($this->gruppo?->nome ?? '')
|
||||
: str_replace('_', ' ', ucfirst($stato));
|
||||
}
|
||||
|
||||
public function getAssegnatarioAttribute(): ?Proclamatore
|
||||
{
|
||||
return $this->assegnazioneCorrente?->proclamatore;
|
||||
@@ -141,6 +169,59 @@ class Territorio extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* La logica di priorità dei territori da distribuire, la stessa ovunque
|
||||
* (Home "Da assegnare", pacchetto di un gruppo, "Il mio gruppo"): prima i
|
||||
* prioritari — spunta manuale o fermi da più giorni della soglia
|
||||
* "giorni_giacenza_prioritari" — poi chi è fermo da più tempo, poi il
|
||||
* numero. Su ogni territorio imposta giacenza_giorni (dall'ultimo rientro,
|
||||
* o dalla creazione se mai assegnato; 0 se è fuori) e in_priorita, da
|
||||
* mostrare. Vuole 'ultimaAssegnazione' già caricata, per non fare una
|
||||
* query a territorio.
|
||||
*/
|
||||
public static function ordinaPerPriorita(iterable $territori): \Illuminate\Support\Collection
|
||||
{
|
||||
$soglia = (int) (Setting::instance()->giorni_giacenza_prioritari ?? 180);
|
||||
|
||||
return collect($territori)
|
||||
->each(function (self $territorio) use ($soglia) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorni = (int) $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorni = (int) $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorni = 0;
|
||||
}
|
||||
|
||||
$territorio->setAttribute('giacenza_giorni', $giorni);
|
||||
$territorio->setAttribute('in_priorita', (bool) $territorio->prioritario || $giorni > $soglia);
|
||||
})
|
||||
->sort(function (self $a, self $b) {
|
||||
return [(int) $b->in_priorita, $b->giacenza_giorni] <=> [(int) $a->in_priorita, $a->giacenza_giorni]
|
||||
?: strnatcasecmp((string) $a->numero, (string) $b->numero);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────
|
||||
|
||||
public function scopeAttivi($query)
|
||||
@@ -150,12 +231,23 @@ class Territorio extends Model
|
||||
|
||||
public function scopeInReparto($query)
|
||||
{
|
||||
// Quelli nel pacchetto di un gruppo non sono in reparto: stanno al
|
||||
// gruppo finché il sorvegliante non li dà o non li rimanda indietro.
|
||||
return $query->attivi()
|
||||
->whereNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('assegnazioni', function ($q) {
|
||||
$q->whereNull('returned_at');
|
||||
});
|
||||
}
|
||||
|
||||
/** Nel pacchetto di un gruppo e non ancora dati a nessuno. */
|
||||
public function scopeInGruppo($query)
|
||||
{
|
||||
return $query->attivi()
|
||||
->whereNotNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
|
||||
}
|
||||
|
||||
public function scopeAssegnato($query)
|
||||
{
|
||||
return $query->attivi()
|
||||
@@ -216,4 +308,32 @@ 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;
|
||||
}
|
||||
|
||||
/** La miniatura per il sorvegliante di gruppo ("Il mio gruppo"): solo del suo pacchetto. */
|
||||
public function miniaturaGruppoUrl(): ?string
|
||||
{
|
||||
return $this->thumbnail_path
|
||||
? route('mio-gruppo.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
|
||||
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
||||
* territorio, che e' il riferimento con cui lo chiamano tutti.
|
||||
*
|
||||
* `geojson` e' la bozza: l'editor la salva da solo a ogni modifica, e non
|
||||
* tocca il PDF del territorio. Il PDF cambia solo con la pubblicazione, che
|
||||
* ricorda cosa e' stato pubblicato (`geojson_pubblicato`,
|
||||
* `impronta_pubblicata`, `pubblicata_at`).
|
||||
*/
|
||||
class TerritorioMappa extends Model
|
||||
{
|
||||
public const STATO_BOZZA = 'bozza';
|
||||
|
||||
public const STATO_PUBBLICATA = 'pubblicata';
|
||||
|
||||
public const STATO_MODIFICATA = 'modificata';
|
||||
|
||||
protected $table = 'territorio_mappe';
|
||||
|
||||
protected $fillable = [
|
||||
'territorio_id',
|
||||
'nome',
|
||||
'geojson',
|
||||
'geojson_pubblicato',
|
||||
'impronta_pubblicata',
|
||||
'pdf_pubblicato',
|
||||
'pubblicata_at',
|
||||
'image_path',
|
||||
'rendered_at',
|
||||
];
|
||||
@@ -28,12 +43,19 @@ class TerritorioMappa extends Model
|
||||
{
|
||||
return [
|
||||
'rendered_at' => 'datetime',
|
||||
'pubblicata_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* withTrashed: un territorio nel cestino resta collegato. Senza, la
|
||||
* relazione tornava null, l'editor mostrava la piantina come scollegata e
|
||||
* il primo salvataggio cancellava il collegamento — che al ripristino del
|
||||
* territorio non c'era piu'.
|
||||
*/
|
||||
public function territorio()
|
||||
{
|
||||
return $this->belongsTo(Territorio::class, 'territorio_id');
|
||||
return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,15 +64,22 @@ class TerritorioMappa extends Model
|
||||
*/
|
||||
public function geometria(): ?array
|
||||
{
|
||||
$decoded = json_decode($this->geojson, true);
|
||||
$decoded = json_decode((string) $this->geojson, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/** Se è collegata a un territorio, il PDF si può generare. */
|
||||
/** Collegata a un territorio vivo: è la condizione per pubblicare. */
|
||||
public function collegata(): bool
|
||||
{
|
||||
return $this->territorio_id !== null && $this->territorio !== null;
|
||||
return $this->territorio_id !== null
|
||||
&& $this->territorio !== null
|
||||
&& ! $this->territorio->trashed();
|
||||
}
|
||||
|
||||
public function territorioNelCestino(): bool
|
||||
{
|
||||
return $this->territorio !== null && $this->territorio->trashed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +89,7 @@ class TerritorioMappa extends Model
|
||||
*/
|
||||
public function etichetta(): string
|
||||
{
|
||||
if ($this->collegata()) {
|
||||
if ($this->territorio !== null) {
|
||||
return 'Terr. ' . $this->territorio->numero;
|
||||
}
|
||||
|
||||
@@ -68,4 +97,93 @@ class TerritorioMappa extends Model
|
||||
|
||||
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
||||
}
|
||||
|
||||
// ─── Bozza e pubblicazione ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Impronta di tutto cio' che finisce nel PDF: confine, territorio e i
|
||||
* suoi dati stampati sulla cartolina (numero, zona come «Località»,
|
||||
* confini). Se cambia uno qualunque di questi, il PDF in vigore non
|
||||
* corrisponde piu' e la piantina ha modifiche da pubblicare — anche quando
|
||||
* il disegno e' rimasto lo stesso.
|
||||
*/
|
||||
public function impronta(): string
|
||||
{
|
||||
$territorio = $this->territorio;
|
||||
|
||||
// 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,14 +2,22 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
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;
|
||||
|
||||
@@ -18,6 +26,8 @@ class User extends Authenticatable
|
||||
'email',
|
||||
'password',
|
||||
'password_change_required',
|
||||
'password_temporanea',
|
||||
'proclamatore_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -31,9 +41,80 @@ class User extends Authenticatable
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'password_change_required' => 'boolean',
|
||||
// Il cambio obbligatorio viene da una password temporanea data
|
||||
// dall'amministratore, non da un ripristino (pagina del cambio).
|
||||
'password_temporanea' => 'boolean',
|
||||
'proclamatore_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** Il proclamatore di cui è l'utenza (sorvegliante di gruppo). */
|
||||
public function proclamatore()
|
||||
{
|
||||
return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorvegliante di gruppo: vede solo il proprio gruppo e il proprio
|
||||
* profilo (middleware LimitaSorvegliante).
|
||||
*/
|
||||
public function isSorvegliante(): bool
|
||||
{
|
||||
return $this->hasRole('sorvegliante');
|
||||
}
|
||||
|
||||
/** Il gruppo di cui è sorvegliante (sciolto il gruppo, l'utenza non esiste più). */
|
||||
public function gruppoSorvegliato(): ?GruppoServizio
|
||||
{
|
||||
return $this->proclamatore_id
|
||||
? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina l'utenza senza perdere lo storico: le assegnazioni che ha fatto
|
||||
* o fatto rientrare passano a $subentra (la colonna non può restare
|
||||
* vuota), e nel registro delle attività le sue voci si tengono, con
|
||||
* scritto chi era ($nome, $email) al posto del collegamento all'utenza.
|
||||
*/
|
||||
public function eliminaConservandoStorico(self $subentra, string $nome, ?string $email): void
|
||||
{
|
||||
DB::transaction(function () use ($subentra, $nome, $email) {
|
||||
$eliminatoIl = now()->toDateTimeString();
|
||||
|
||||
Activity::query()
|
||||
->where('causer_type', self::class)
|
||||
->where('causer_id', $this->id)
|
||||
->chunkById(200, function ($attivita) use ($nome, $email, $eliminatoIl) {
|
||||
foreach ($attivita as $voce) {
|
||||
$proprieta = $voce->properties?->toArray() ?? [];
|
||||
$proprieta['causer_name'] = $nome;
|
||||
$proprieta['causer_email'] = $email;
|
||||
$proprieta['causer_deleted_at'] = $eliminatoIl;
|
||||
|
||||
$voce->properties = $proprieta;
|
||||
$voce->save();
|
||||
}
|
||||
});
|
||||
|
||||
Assegnazione::query()->where('created_by', $this->id)->update(['created_by' => $subentra->id]);
|
||||
Assegnazione::query()->where('returned_by', $this->id)->update(['returned_by' => $subentra->id]);
|
||||
|
||||
$this->syncRoles([]);
|
||||
$this->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante non conferma l'email (scelta dell'utente, 19 ottobre
|
||||
* 2026): l'utenza la crea l'amministratore con una password temporanea.
|
||||
* Per tutti gli altri resta obbligatoria.
|
||||
*/
|
||||
public function hasVerifiedEmail(): bool
|
||||
{
|
||||
return ! is_null($this->email_verified_at) || $this->isSorvegliante();
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
@@ -41,4 +122,62 @@ class User extends Authenticatable
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
|
||||
* proclamatore: nel registro delle attività i proclamatori compaiono solo
|
||||
* con l'identificativo, quindi anche qui.
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
|
||||
{
|
||||
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
|
||||
if (! $proclamatoreId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
|
||||
if (! is_array($valori)) {
|
||||
return $valori;
|
||||
}
|
||||
foreach (['name', 'email'] as $campo) {
|
||||
if (array_key_exists($campo, $valori)) {
|
||||
$valori[$campo] = "proclamatore #{$proclamatoreId}";
|
||||
}
|
||||
}
|
||||
|
||||
return $valori;
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if ($this->isSorvegliante()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->sendEmailVerificationNotification();
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Notifications/RichiestaRicevuta.php
Normal file
42
app/Notifications/RichiestaRicevuta.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Avviso ad amministratori e assistenti: è arrivata una richiesta dal modulo
|
||||
* pubblico. Volutamente non ShouldQueue: il nome scritto nella richiesta
|
||||
* finirebbe in chiaro nella coda (Redis, failed_jobs).
|
||||
*/
|
||||
class RichiestaRicevuta extends Notification
|
||||
{
|
||||
public function __construct(private Richiesta $richiesta) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$r = $this->richiesta;
|
||||
$messaggio = (new MailMessage)
|
||||
->subject("Nuova richiesta: {$r->tipo_etichetta} — TerManager2")
|
||||
->greeting('Ciao ' . $notifiable->name . ',')
|
||||
->line($r->tipo === Richiesta::RICONSEGNA
|
||||
? "{$r->nome} annuncia la riconsegna del territorio N° {$r->numero_territorio}."
|
||||
: "{$r->nome} chiede un territorio nuovo.");
|
||||
|
||||
if ($r->note) {
|
||||
$messaggio->line('Note: ' . $r->note);
|
||||
}
|
||||
|
||||
return $messaggio
|
||||
->action('Apri la Home di TerManager2', route('dashboard'))
|
||||
->line('La richiesta si conferma dalla Home, nel riquadro "Richieste".')
|
||||
->salutation('TerManager2');
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
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\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
use RuntimeException;
|
||||
@@ -17,6 +23,48 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
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.
|
||||
//
|
||||
// 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)
|
||||
* 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
|
||||
* 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
|
||||
{
|
||||
@@ -29,11 +36,23 @@ class BackupArchive
|
||||
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
||||
* 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_DATA = 'data.xml';
|
||||
private const ENTRY_PIANTINE = 'piantine.json';
|
||||
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
|
||||
@@ -65,12 +84,32 @@ class BackupArchive
|
||||
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
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');
|
||||
$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);
|
||||
|
||||
$cartella = dirname($destinazione);
|
||||
@@ -84,12 +123,14 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$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([
|
||||
'format_version' => self::FORMAT_VERSION,
|
||||
'application' => 'TerManager2',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'app_url' => (string) config('app.url'),
|
||||
'files_count' => $totale,
|
||||
'files_count' => $filePubblici,
|
||||
'piantine_files_count' => $filePiantine,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
try {
|
||||
@@ -110,12 +151,12 @@ class BackupArchive
|
||||
|
||||
try {
|
||||
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
||||
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
||||
$files, $totale, $perLotto, $onProgress, $destinazione
|
||||
) {
|
||||
$nelLotto = 0;
|
||||
$fatti = 0;
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
foreach ($files as [$relativePath, $sorgente, $entry]) {
|
||||
// addFile() legge dal disco durante close(): i PDF non
|
||||
// passano mai interi per la memoria del processo.
|
||||
//
|
||||
@@ -133,7 +174,7 @@ class BackupArchive
|
||||
// raggiunto mai, e l'amministratore leggerebbe la stringa
|
||||
// grezza di PHP. Stesso problema, e stessa scelta, del
|
||||
// close() avvolto in chiudi().
|
||||
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
|
||||
if (! @$zip->addFile($sorgente, $entry)) {
|
||||
throw new RuntimeException(
|
||||
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
||||
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
||||
@@ -157,7 +198,7 @@ class BackupArchive
|
||||
}
|
||||
|
||||
try {
|
||||
$this->verificaArchivio($destinazione, $totale);
|
||||
$this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($destinazione);
|
||||
|
||||
@@ -178,7 +219,7 @@ class BackupArchive
|
||||
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
||||
* 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();
|
||||
|
||||
@@ -187,27 +228,31 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$trovati = 0;
|
||||
$piantineTrovati = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
||||
$trovati++;
|
||||
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
|
||||
$piantineTrovati++;
|
||||
}
|
||||
}
|
||||
|
||||
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
||||
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
||||
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
|
||||
|
||||
$zip->close();
|
||||
|
||||
if (! $haDati || ! $haManifest) {
|
||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
|
||||
if (! $haDati || ! $haManifest || ! $haPiantine) {
|
||||
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(
|
||||
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
||||
$trovati,
|
||||
$fileAttesi
|
||||
$trovati + $piantineTrovati,
|
||||
$fileAttesi + $piantineAttesi
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -294,8 +339,15 @@ class BackupArchive
|
||||
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||
* 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
|
||||
* @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
|
||||
{
|
||||
@@ -334,12 +386,35 @@ class BackupArchive
|
||||
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) {
|
||||
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
||||
}
|
||||
|
||||
$totalBytes = 0;
|
||||
$extracted = 0;
|
||||
$estrattiPiantine = 0;
|
||||
|
||||
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
||||
// due entry di servizio": un archivio con una entry in piu' (una
|
||||
@@ -348,7 +423,7 @@ class BackupArchive
|
||||
$daEstrarre = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $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++;
|
||||
}
|
||||
}
|
||||
@@ -361,11 +436,12 @@ class BackupArchive
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
|
||||
$prefisso = $this->prefissoDi($entryName, $prefissi);
|
||||
if ($prefisso === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
|
||||
$relativePath = substr($entryName, strlen($prefisso));
|
||||
$safePath = $this->safeRelativePath($relativePath);
|
||||
|
||||
if ($safePath === null) {
|
||||
@@ -375,7 +451,8 @@ class BackupArchive
|
||||
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
||||
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
||||
// 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) {
|
||||
throw new RuntimeException(sprintf(
|
||||
@@ -390,7 +467,7 @@ class BackupArchive
|
||||
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
||||
}
|
||||
|
||||
$destination = $staging . '/' . $safePath;
|
||||
$destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
|
||||
$directory = dirname($destination);
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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($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++;
|
||||
$onProgress && $onProgress($extracted, $daEstrarre);
|
||||
}
|
||||
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$zip->close();
|
||||
@@ -425,63 +530,180 @@ class BackupArchive
|
||||
|
||||
$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
|
||||
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||
* 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)) {
|
||||
return 0;
|
||||
foreach ($this->vociDi($radice) as $voce) {
|
||||
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
|
||||
}
|
||||
|
||||
$restored = 0;
|
||||
$totale = iterator_count(new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
));
|
||||
$onProgress && $onProgress(0, $totale);
|
||||
// Una sottocartella assente nello staging vuol dire "nessun
|
||||
// file": la radice resta vuota, come nell'installazione
|
||||
// salvata.
|
||||
$nuovi = $estratto['staging'] . '/' . $nome;
|
||||
foreach ($this->vociDi($nuovi) as $voce) {
|
||||
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->annullaScambio($scambio, $e);
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
throw $e;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
$handle = fopen($file->getPathname(), 'rb');
|
||||
if ($handle === false) {
|
||||
continue;
|
||||
$this->deleteDirectory($scambio['accantonati']);
|
||||
}
|
||||
|
||||
$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)) {
|
||||
fclose($handle);
|
||||
if ($formato >= 2) {
|
||||
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
|
||||
}
|
||||
|
||||
$restored++;
|
||||
$onProgress && $onProgress($restored, $totale);
|
||||
return $radici;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,18 @@ class BackupOperationState
|
||||
public const TIPO_RESTORE = 'restore';
|
||||
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. */
|
||||
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
||||
|
||||
@@ -48,6 +61,16 @@ class BackupOperationState
|
||||
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
||||
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
|
||||
{
|
||||
$state = [
|
||||
@@ -89,16 +112,78 @@ class BackupOperationState
|
||||
$stato = $this->get($id);
|
||||
|
||||
// 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)) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return Cache::get($this->key($id));
|
||||
@@ -213,9 +298,18 @@ class BackupOperationState
|
||||
*/
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
347
app/Services/GruppiServizio.php
Normal file
347
app/Services/GruppiServizio.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Le regole dei gruppi di servizio, in un posto solo: le usano le pagine
|
||||
* dell'amministratore (Gruppi di servizio) e quella del sorvegliante (Il mio
|
||||
* gruppo). Ogni metodo lancia InvalidArgumentException con un messaggio da
|
||||
* mostrare così com'è quando una regola non è rispettata.
|
||||
*/
|
||||
class GruppiServizio
|
||||
{
|
||||
/** Il dominio delle email fittizie dei sorveglianti (iniziale+cognome@…). */
|
||||
public const DOMINIO_EMAIL = 'termanager.it';
|
||||
|
||||
/**
|
||||
* Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
|
||||
* suo nome (emailFittizia), ruolo "sorvegliante", password temporanea da
|
||||
* cambiare al primo accesso, nessuna conferma dell'email. Restituisce il
|
||||
* gruppo e la password temporanea, da mostrare una volta sola
|
||||
* all'amministratore insieme all'email.
|
||||
*
|
||||
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
|
||||
* @return array{0: GruppoServizio, 1: string}
|
||||
*/
|
||||
public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
|
||||
{
|
||||
$nome = trim($nome);
|
||||
if ($nome === '') {
|
||||
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
|
||||
}
|
||||
if (GruppoServizio::where('nome', $nome)->exists()) {
|
||||
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
|
||||
}
|
||||
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
|
||||
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
|
||||
}
|
||||
if ($sorvegliante->gruppo_servizio_id) {
|
||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
|
||||
}
|
||||
if ($sorvegliante->utente()->exists()) {
|
||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
|
||||
}
|
||||
|
||||
$password = Str::password(12, symbols: false);
|
||||
|
||||
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
|
||||
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
|
||||
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
$this->aggiungiMembri($gruppo, $membri);
|
||||
|
||||
$utente = User::create([
|
||||
'name' => $sorvegliante->nome_completo,
|
||||
'email' => self::emailFittizia($sorvegliante),
|
||||
'password' => $password,
|
||||
'password_change_required' => true,
|
||||
'password_temporanea' => true,
|
||||
'proclamatore_id' => $sorvegliante->id,
|
||||
]);
|
||||
$utente->assignRole('sorvegliante');
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
|
||||
->log('gruppo_creato');
|
||||
|
||||
return $gruppo;
|
||||
});
|
||||
|
||||
return [$gruppo, $password];
|
||||
}
|
||||
|
||||
/**
|
||||
* Una password temporanea nuova per il sorvegliante che l'ha persa: da
|
||||
* cambiare al primo accesso ("Password temporanea"). Le sessioni aperte
|
||||
* con quella vecchia si chiudono alla richiesta successiva
|
||||
* (AuthenticateSession: è cambiato l'hash), il "ricordami" si azzera.
|
||||
* Restituisce la password, da mostrare una volta sola.
|
||||
*/
|
||||
public function nuovaPasswordTemporanea(User $utente): string
|
||||
{
|
||||
if (! $utente->proclamatore_id || ! $utente->isSorvegliante()) {
|
||||
throw new InvalidArgumentException("{$utente->name} non è il sorvegliante di un gruppo.");
|
||||
}
|
||||
|
||||
$password = Str::password(12, symbols: false);
|
||||
$utente->forceFill([
|
||||
'password' => $password,
|
||||
'password_change_required' => true,
|
||||
'password_temporanea' => true,
|
||||
'remember_token' => null,
|
||||
])->save();
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'email fittizia del sorvegliante, con cui entra: iniziale del nome e
|
||||
* cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio
|
||||
* dell'app (Francesco Fontana → ffontana@termanager.it, Francesco
|
||||
* D'Angelo → fdangelo@…). Se è già presa, un numero in coda
|
||||
* (ffontana2@…). Non riceve posta: il sorvegliante non conferma l'email.
|
||||
*/
|
||||
public static function emailFittizia(Proclamatore $proclamatore): string
|
||||
{
|
||||
$pulisci = fn (string $testo) => preg_replace('/[^a-z0-9]/', '', Str::lower(Str::ascii($testo)));
|
||||
|
||||
$base = Str::limit(mb_substr($pulisci((string) $proclamatore->nome), 0, 1) . $pulisci((string) $proclamatore->cognome), 60, '');
|
||||
if ($base === '') {
|
||||
$base = "sorvegliante{$proclamatore->id}";
|
||||
}
|
||||
|
||||
$email = "{$base}@" . self::DOMINIO_EMAIL;
|
||||
for ($n = 2; User::where('email', $email)->exists(); $n++) {
|
||||
$email = "{$base}{$n}@" . self::DOMINIO_EMAIL;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
foreach (Proclamatore::whereIn('id', $ids)->get() as $p) {
|
||||
if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo.");
|
||||
}
|
||||
if (! $p->attivo) {
|
||||
throw new InvalidArgumentException("{$p->nome_completo} non è attivo.");
|
||||
}
|
||||
$p->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
}
|
||||
}
|
||||
|
||||
public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
if ($membro->id === $gruppo->sorvegliante_id) {
|
||||
throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.');
|
||||
}
|
||||
if ($membro->gruppo_servizio_id === $gruppo->id) {
|
||||
$membro->update(['gruppo_servizio_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mette nel pacchetto del gruppo dei territori in reparto (attivi, non
|
||||
* assegnati, non già in un altro pacchetto). Restituisce quanti.
|
||||
*
|
||||
* @param int[] $ids
|
||||
*/
|
||||
public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
$territori = Territorio::whereIn('id', $ids)->get();
|
||||
foreach ($territori as $t) {
|
||||
if (! $t->attivo) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo.");
|
||||
}
|
||||
if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo.");
|
||||
}
|
||||
if ($t->assegnazioneCorrente) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare.");
|
||||
}
|
||||
}
|
||||
|
||||
Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territori' => $territori->pluck('numero')->all()])
|
||||
->log('pacchetto_aggiunti');
|
||||
|
||||
return $territori->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rimanda in reparto un territorio del pacchetto non ancora dato a
|
||||
* nessuno (lo fa l'amministratore o il sorvegliante).
|
||||
*/
|
||||
public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void
|
||||
{
|
||||
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||
}
|
||||
if ($territorio->assegnazioneCorrente) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro.");
|
||||
}
|
||||
|
||||
$territorio->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territorio' => $territorio->numero])
|
||||
->log('pacchetto_rimandato_in_reparto');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante dà un territorio del pacchetto a un membro (o a sé):
|
||||
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
|
||||
* territorio resta nel pacchetto finché non rientra.
|
||||
*/
|
||||
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data, bool $cartaceo = false): Assegnazione
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
// Si decide sui dati di adesso, non su quelli con cui è partita la
|
||||
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
|
||||
// o membri.
|
||||
$territorio->refresh();
|
||||
$membro->refresh();
|
||||
|
||||
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||
}
|
||||
if ($membro->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
|
||||
}
|
||||
if (! $membro->attivo) {
|
||||
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
|
||||
}
|
||||
if ($data->isFuture()) {
|
||||
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
|
||||
}
|
||||
|
||||
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data, $cartaceo) {
|
||||
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
|
||||
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
|
||||
}
|
||||
|
||||
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
|
||||
if ($conflitto) {
|
||||
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
|
||||
}
|
||||
|
||||
return Assegnazione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'proclamatore_id' => $membro->id,
|
||||
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
|
||||
'assigned_at' => $data,
|
||||
'created_by' => auth()->id(),
|
||||
'gruppo_servizio_id' => $gruppo->id,
|
||||
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
|
||||
'gruppo_nome' => $gruppo->nome,
|
||||
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||
'cartaceo' => $cartaceo,
|
||||
]);
|
||||
});
|
||||
|
||||
if (is_string($esito)) {
|
||||
throw new InvalidArgumentException($esito);
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($esito)
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore_id' => $membro->id,
|
||||
'gruppo_servizio_id' => $gruppo->id,
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
return $esito;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
|
||||
* sorvegliante non è più attivo) e lo elimina: non resta nello storico.
|
||||
* I territori del pacchetto non ancora dati tornano in reparto; quelli
|
||||
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
|
||||
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
|
||||
* L'utenza del sorvegliante si elimina: torna un proclamatore come gli
|
||||
* altri. Le assegnazioni che aveva fatto o fatto rientrare passano a chi
|
||||
* scioglie il gruppo; nel registro le sue voci restano, a nome
|
||||
* "Sorvegliante del gruppo …", senza dati personali.
|
||||
*/
|
||||
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
|
||||
{
|
||||
if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($gruppo, $motivo) {
|
||||
$liberati = Territorio::withTrashed()
|
||||
->where('gruppo_servizio_id', $gruppo->id)
|
||||
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
|
||||
->pluck('numero');
|
||||
Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(array_filter([
|
||||
'gruppo' => $gruppo->nome,
|
||||
'territori_liberati' => $liberati->all(),
|
||||
'utente_eliminato' => $utente?->id,
|
||||
'motivo' => $motivo,
|
||||
], fn ($v) => $v !== null))
|
||||
->log('gruppo_sciolto');
|
||||
|
||||
if ($utente) {
|
||||
$utente->eliminaConservandoStorico($this->subentrante($utente), "Sorvegliante del gruppo {$gruppo->nome}", null);
|
||||
}
|
||||
|
||||
$gruppo->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi prende le assegnazioni fatte dal sorvegliante quando la sua utenza
|
||||
* si elimina: chi sta sciogliendo il gruppo, altrimenti il primo
|
||||
* amministratore.
|
||||
*/
|
||||
private function subentrante(User $sorvegliante): User
|
||||
{
|
||||
$chi = auth()->user();
|
||||
if ($chi instanceof User && $chi->id !== $sorvegliante->id) {
|
||||
return $chi;
|
||||
}
|
||||
|
||||
return User::role('amministratore')->orderBy('id')->firstOrFail();
|
||||
}
|
||||
|
||||
private function attivo(GruppoServizio $gruppo): void
|
||||
{
|
||||
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
|
||||
throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
|
||||
}
|
||||
|
||||
$immagine = Storage::disk('public')->path($mappa->image_path);
|
||||
$dimensioni = getimagesize($immagine);
|
||||
}
|
||||
|
||||
$dimensioni = @getimagesize($immagine);
|
||||
if ($dimensioni === false) {
|
||||
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
|
||||
}
|
||||
@@ -92,7 +101,7 @@ class PiantinaPdfService
|
||||
($h - $imgH) / 2,
|
||||
$imgW,
|
||||
$imgH,
|
||||
$this->tipoImmagine($mappa->image_path)
|
||||
$this->tipoImmagine($immagine)
|
||||
);
|
||||
|
||||
return $pdf->Output('S');
|
||||
@@ -179,13 +188,26 @@ class PiantinaPdfService
|
||||
*/
|
||||
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
|
||||
{
|
||||
$binario = $this->genera($territorio, $mappa);
|
||||
$this->sostituisciConPdf($territorio, $this->genera($territorio, $mappa), $origine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rende ufficiale un PDF già composto, archiviando il precedente.
|
||||
*
|
||||
* Prima si scrive il nuovo file, poi si archivia il vecchio: nell'ordine
|
||||
* inverso un errore di scrittura (disco pieno) lasciava pdf_path puntato
|
||||
* a un file già spostato nello storico, e i link delle assegnazioni in 404.
|
||||
*/
|
||||
public function sostituisciConPdf(Territorio $territorio, string $binario, string $origine = 'piantine'): void
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
$this->archiviaCorrente($territorio, $origine);
|
||||
|
||||
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
|
||||
$disk->put($nuovoPath, $binario);
|
||||
if (! $disk->put($nuovoPath, $binario)) {
|
||||
throw new RuntimeException('Non è stato possibile scrivere il nuovo PDF sul disco: il PDF in vigore non è stato toccato.');
|
||||
}
|
||||
|
||||
$this->archiviaCorrente($territorio, $origine);
|
||||
|
||||
$territorio->update([
|
||||
'pdf_path' => $nuovoPath,
|
||||
@@ -235,8 +257,11 @@ class PiantinaPdfService
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Piantine;
|
||||
|
||||
use App\Models\TerritorioMappa;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
@@ -14,6 +15,11 @@ use RuntimeException;
|
||||
*/
|
||||
class PiantinaRenderer
|
||||
{
|
||||
/**
|
||||
* Rendering e archiviazione come immagine della piantina.
|
||||
*
|
||||
* @return string il percorso sul disco pubblico
|
||||
*/
|
||||
public function render(TerritorioMappa $mappa): string
|
||||
{
|
||||
$geometry = $mappa->geometria();
|
||||
@@ -22,8 +28,22 @@ class PiantinaRenderer
|
||||
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
||||
}
|
||||
|
||||
[$immagine, $estensione] = $this->immagine($geometry);
|
||||
|
||||
return $this->archivia($mappa, $immagine, $estensione);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo il rendering, senza toccare niente: serve all'anteprima della
|
||||
* pubblicazione, che non deve cambiare la piantina finché non si conferma.
|
||||
*
|
||||
* @return array{0: string, 1: string} contenuto e estensione ('jpg'|'png')
|
||||
*/
|
||||
public function immagine(array $geometry): array
|
||||
{
|
||||
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
|
||||
|
||||
try {
|
||||
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
||||
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
||||
'geometry' => $geometry,
|
||||
@@ -35,6 +55,11 @@ class PiantinaRenderer
|
||||
'format' => $formato,
|
||||
'quality' => (int) config('piantine.qualita'),
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
// Container fermo, nome non risolto o tempo scaduto: il messaggio
|
||||
// di cURL in inglese non dice niente a chi sta pubblicando.
|
||||
throw new RuntimeException('Il servizio che disegna le mappe non risponde (container map-renderer). Riprova fra qualche minuto; se continua, va controllato il server.', 0, $e);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
|
||||
@@ -47,15 +72,28 @@ class PiantinaRenderer
|
||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
|
||||
}
|
||||
|
||||
// Il vecchio file puo' avere un'estensione diversa da quella nuova (si
|
||||
// e' passati da PNG a JPEG): senza questa rimozione resterebbe sul
|
||||
// volume per sempre, senza che niente lo referenzi piu'.
|
||||
if ($mappa->image_path && $mappa->image_path !== $this->percorso($mappa, $formato)) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
|
||||
}
|
||||
|
||||
$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([
|
||||
'image_path' => $path,
|
||||
@@ -65,11 +103,6 @@ class PiantinaRenderer
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function percorso(TerritorioMappa $mappa, string $formato): string
|
||||
{
|
||||
return 'territori-mappe/piantina-' . $mappa->id . '.' . ($formato === 'png' ? 'png' : 'jpg');
|
||||
}
|
||||
|
||||
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
|
||||
private function immagineValida(string $dati, string $formato): bool
|
||||
{
|
||||
|
||||
@@ -12,10 +12,10 @@ use RuntimeException;
|
||||
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
||||
* restano al loro posto.
|
||||
*
|
||||
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
|
||||
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
|
||||
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
|
||||
* KML originale — che quindi conviene conservare fuori di qui.
|
||||
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
|
||||
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
|
||||
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
|
||||
* contiene, e ripristinandolo l'area in uso resta com'e'.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
155
app/Services/RichiesteServizio.php
Normal file
155
app/Services/RichiesteServizio.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RichiestaRicevuta;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Le richieste dal modulo pubblico: registrarle, avvisare chi le gestisce e
|
||||
* aiutarlo a ricondurle a un proclamatore o a un'assegnazione. La conferma
|
||||
* passa dai moduli già esistenti (Assegna, Rientra), che chiudono la
|
||||
* richiesta da cui sono partiti.
|
||||
*/
|
||||
class RichiesteServizio
|
||||
{
|
||||
public function __construct(private ConfigurazionePosta $posta) {}
|
||||
|
||||
/** Registra la richiesta e avvisa per email amministratori e assistenti. */
|
||||
public function registra(string $tipo, string $nome, ?string $numero, ?string $note): Richiesta
|
||||
{
|
||||
$richiesta = Richiesta::create([
|
||||
'tipo' => $tipo === Richiesta::RICONSEGNA ? Richiesta::RICONSEGNA : Richiesta::NUOVO,
|
||||
'nome' => trim($nome),
|
||||
'numero_territorio' => $tipo === Richiesta::RICONSEGNA ? trim((string) $numero) : null,
|
||||
'note' => trim((string) $note) !== '' ? trim((string) $note) : null,
|
||||
]);
|
||||
|
||||
// Nel registro tipo e numero, mai il nome.
|
||||
activity()->performedOn($richiesta)
|
||||
->withProperties(array_filter(['tipo' => $richiesta->tipo, 'territorio' => $richiesta->numero_territorio]))
|
||||
->log('richiesta_ricevuta');
|
||||
|
||||
$this->avvisa($richiesta);
|
||||
|
||||
return $richiesta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi gestisce le richieste e ha un'email vera: amministratori e
|
||||
* assistenti (permesso richieste.gestisci) con l'indirizzo confermato.
|
||||
*/
|
||||
public function destinatari(): Collection
|
||||
{
|
||||
return User::permission('richieste.gestisci')->get()
|
||||
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'email parte subito e non in coda: contiene il nome scritto nella
|
||||
* richiesta, che nel database è cifrato e in coda starebbe in chiaro.
|
||||
* Senza un server di posta funzionante non si manda niente: la richiesta
|
||||
* resta comunque nella Home.
|
||||
*/
|
||||
private function avvisa(Richiesta $richiesta): void
|
||||
{
|
||||
if (! $this->posta->funzionante()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$destinatari = $this->destinatari();
|
||||
if ($destinatari->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Notification::send($destinatari, new RichiestaRicevuta($richiesta));
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* I proclamatori attivi con un nome riconducibile a quello scritto nella
|
||||
* richiesta, dal più simile: uguale, iniziale, una lettera sbagliata.
|
||||
* I nomi sono cifrati, quindi il confronto si fa qui, non in SQL.
|
||||
*
|
||||
* @return Collection<int, Proclamatore>
|
||||
*/
|
||||
public function nomiSimili(string $nome, ?Collection $proclamatori = null, int $quanti = 5): Collection
|
||||
{
|
||||
$cercate = self::parole($nome);
|
||||
if ($cercate === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ($proclamatori ?? Proclamatore::attivi()->get())
|
||||
->map(function (Proclamatore $p) use ($cercate) {
|
||||
$sue = self::parole($p->cognome . ' ' . $p->nome);
|
||||
$punti = 0;
|
||||
foreach ($cercate as $parola) {
|
||||
$migliore = 0;
|
||||
foreach ($sue as $sua) {
|
||||
$migliore = max($migliore, self::somiglianza($parola, $sua));
|
||||
}
|
||||
$punti += $migliore;
|
||||
}
|
||||
|
||||
return [$p, $punti];
|
||||
})
|
||||
->filter(fn ($coppia) => $coppia[1] >= 2)
|
||||
// Prima il punteggio più alto, a pari punti in ordine alfabetico.
|
||||
->sort(fn ($a, $b) => [$b[1], mb_strtolower($a[0]->nome_completo)] <=> [$a[1], mb_strtolower($b[0]->nome_completo)])
|
||||
->take($quanti)
|
||||
->map(fn ($coppia) => $coppia[0])
|
||||
->values();
|
||||
}
|
||||
|
||||
/** L'assegnazione aperta del territorio indicato in una riconsegna, se c'è. */
|
||||
public function assegnazioneAperta(?string $numero): ?Assegnazione
|
||||
{
|
||||
$numero = trim((string) $numero);
|
||||
if ($numero === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$territorio = Territorio::query()
|
||||
->whereRaw('LOWER(numero) = ?', [Str::lower($numero)])
|
||||
->with('assegnazioneCorrente.proclamatore')
|
||||
->first();
|
||||
|
||||
return $territorio?->assegnazioneCorrente;
|
||||
}
|
||||
|
||||
/** @return string[] parole minuscole, senza accenti né segni */
|
||||
private static function parole(string $testo): array
|
||||
{
|
||||
$pulito = preg_replace('/[^a-z0-9 ]+/', ' ', Str::lower(Str::ascii($testo)));
|
||||
|
||||
return array_values(array_filter(explode(' ', (string) $pulito), fn ($p) => strlen($p) >= 2));
|
||||
}
|
||||
|
||||
/** 3 uguale, 2 una inizia con l'altra, 1 una lettera di differenza. */
|
||||
private static function somiglianza(string $a, string $b): int
|
||||
{
|
||||
if ($a === $b) {
|
||||
return 3;
|
||||
}
|
||||
if (min(strlen($a), strlen($b)) >= 3 && (str_starts_with($a, $b) || str_starts_with($b, $a))) {
|
||||
return 2;
|
||||
}
|
||||
if (min(strlen($a), strlen($b)) >= 4 && levenshtein($a, $b) <= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
|
||||
|
||||
public function __construct(
|
||||
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));
|
||||
$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);
|
||||
} 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;
|
||||
}
|
||||
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
|
||||
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
|
||||
{
|
||||
$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(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
'archivio-' . $importId . '.zip',
|
||||
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipAbsolutePath) !== true) {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw new RuntimeException('Impossibile aprire il file ZIP.');
|
||||
}
|
||||
|
||||
$storedFiles = [];
|
||||
$ignorati = [];
|
||||
$entryIndex = 0;
|
||||
|
||||
try {
|
||||
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
if (self::daIgnorare($entryName)) {
|
||||
$ignorati[] = basename($entryName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
|
||||
}
|
||||
|
||||
if ($storedFiles === []) {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
|
||||
}
|
||||
|
||||
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
|
||||
$importId,
|
||||
$storedFiles,
|
||||
$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;
|
||||
|
||||
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',
|
||||
)
|
||||
->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
|
||||
// set an arbitrary Host and have route()/url() emit links pointing at
|
||||
// an attacker's domain — including the public assignment PDF links,
|
||||
@@ -39,8 +46,19 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// Dopo il ripristino di un backup ogni account resta bloccato finche'
|
||||
// non imposta una password nuova. In coda al gruppo web, quindi la
|
||||
// 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: [
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\App\Http\Middleware\RequirePasswordChange::class,
|
||||
// Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo.
|
||||
\App\Http\Middleware\LimitaSorvegliante::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
|
||||
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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user