++ 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:
Francesco Picone
2026-10-21 17:32:09 +02:00
parent 5e40f0fe74
commit 7904c96db8
36 changed files with 1563 additions and 24 deletions

View 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;
}
}