++ Richieste dal modulo pubblico, territori cartacei, S-13 mensile per email
- /richiesta: modulo pubblico (nuovo territorio o riconsegna) con campo trappola, tempo minimo e limite orario; nome e note cifrati, avviso per email a chi ha richieste.gestisci (amministratore, assistente) - Home: riquadro Richieste con i proclamatori dal nome simile; porta su Assegna/Rientra già esistenti, che chiudono la richiesta; Scarta - Cartaceo sull'assegnazione (Assegna e Il mio gruppo), etichetta nell'elenco degli assegnati, pagina Cartacei, passa per l'XML - registro:invia-s13 agli amministratori l'ultima domenica del mese; richieste:pulizia dopo 90 giorni dalla gestione - Privacy, README e HANDOFF aggiornati; test in RichiesteTest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -76,10 +77,12 @@ 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', 11);
|
||||
$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
|
||||
|
||||
@@ -9,7 +9,9 @@ use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
class Assegna extends Component
|
||||
{
|
||||
@@ -20,6 +22,17 @@ class Assegna extends Component
|
||||
public ?int $proclamatore_id = null;
|
||||
public string $assigned_at = '';
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -37,6 +50,17 @@ class Assegna extends Component
|
||||
$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')) {
|
||||
@@ -109,6 +133,7 @@ class Assegna extends Component
|
||||
'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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -131,6 +156,13 @@ class Assegna extends Component
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -177,6 +209,7 @@ class Assegna extends Component
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Richiesta;
|
||||
|
||||
class Rientra extends Component
|
||||
{
|
||||
@@ -25,6 +26,13 @@ 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);
|
||||
@@ -34,6 +42,11 @@ class Rientra extends Component
|
||||
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();
|
||||
}
|
||||
@@ -132,6 +145,11 @@ class Rientra extends Component
|
||||
);
|
||||
}
|
||||
|
||||
// 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($this->destinazione($this->paginaPredefinita()), navigate: true);
|
||||
}
|
||||
@@ -164,6 +182,7 @@ class Rientra extends Component
|
||||
'campagna' => $campagna,
|
||||
'giorniPercorrenza' => $giorniPercorrenza,
|
||||
'sogliaPercorrenza' => $sogliaPercorrenza,
|
||||
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ class MioGruppo extends Component
|
||||
|
||||
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 = '';
|
||||
@@ -84,13 +87,14 @@ class MioGruppo extends Component
|
||||
$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']);
|
||||
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
@@ -115,14 +119,14 @@ class MioGruppo extends Component
|
||||
}
|
||||
|
||||
try {
|
||||
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione));
|
||||
$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']);
|
||||
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
|
||||
|
||||
// Come dalla pagina Assegna: si apre l'invio del link.
|
||||
$this->apriInvio($assegnazione->id);
|
||||
|
||||
@@ -3,16 +3,58 @@
|
||||
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');
|
||||
@@ -147,6 +189,7 @@ class Home extends Component
|
||||
'homeLimit' => $homeLimit,
|
||||
'territoriDaAssegnare' => $territoriDaAssegnare,
|
||||
'daRientrare' => $daRientrare,
|
||||
'richieste' => $this->richiesteAperte(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\ConfigurazionePosta;
|
||||
@@ -23,7 +24,7 @@ use Spatie\Permission\Models\Role;
|
||||
class Privacy extends Component
|
||||
{
|
||||
/** Data dell'ultima revisione del testo dell'informativa. */
|
||||
public const REVISIONE = '19 ottobre 2026';
|
||||
public const REVISIONE = '21 ottobre 2026';
|
||||
|
||||
/** I permessi spiegati a chi legge l'informativa. */
|
||||
public const PERMESSI = [
|
||||
@@ -36,6 +37,7 @@ class Privacy extends Component
|
||||
'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à',
|
||||
];
|
||||
@@ -78,6 +80,8 @@ class Privacy extends Component
|
||||
'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'),
|
||||
],
|
||||
|
||||
97
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
97
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Richieste;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Services\RichiesteServizio;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
|
||||
* territorio nuovo o annuncia una riconsegna. Non mostra nessun dato già
|
||||
* presente: chi lo apre vede solo i campi da compilare.
|
||||
*
|
||||
* 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',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
], [
|
||||
'nome.required' => 'Scrivi nome e cognome.',
|
||||
'nome.min' => 'Scrivi nome e cognome.',
|
||||
'numero.required_if' => 'Scrivi il numero del 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;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.richieste.modulo-richiesta', [
|
||||
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
|
||||
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
|
||||
}
|
||||
}
|
||||
@@ -817,6 +817,7 @@ class XmlExchange extends Component
|
||||
'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,
|
||||
@@ -887,7 +888,7 @@ class XmlExchange extends Component
|
||||
'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', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', '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']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1388,6 +1389,7 @@ class XmlExchange extends Component
|
||||
$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'] ?? ''));
|
||||
|
||||
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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class Assegnazione extends Model
|
||||
'link_sent',
|
||||
'link_da_inviare',
|
||||
'link_canale',
|
||||
'cartaceo',
|
||||
'note',
|
||||
'created_by',
|
||||
'returned_by',
|
||||
@@ -43,6 +44,8 @@ class Assegnazione extends Model
|
||||
'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',
|
||||
];
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ class GruppiServizio
|
||||
* 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): Assegnazione
|
||||
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data, bool $cartaceo = false): Assegnazione
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
@@ -238,7 +238,7 @@ class GruppiServizio
|
||||
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
|
||||
}
|
||||
|
||||
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) {
|
||||
$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.";
|
||||
@@ -259,6 +259,7 @@ class GruppiServizio
|
||||
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
|
||||
'gruppo_nome' => $gruppo->nome,
|
||||
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||
'cartaceo' => $cartaceo,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
154
app/Services/RichiesteServizio.php
Normal file
154
app/Services/RichiesteServizio.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?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)
|
||||
->sortBy([[fn ($coppia) => $coppia[1], 'desc'], [fn ($coppia) => mb_strtolower($coppia[0]->nome_completo), 'asc']])
|
||||
->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user