Files
termanager2/app/Services/GruppiServizio.php
Francesco Picone aaffeb7de7 ++ gruppi: il gruppo sciolto si elimina; il sorvegliante entra con un nome utente dal nome del gruppo
- Sciolto, il gruppo non resta nello storico: si elimina (via sciolto_at;
  la migrazione elimina anche quelli gia' sciolti). I territori solo nel
  pacchetto tornano in reparto; quelli gia' dati restano ai membri, e
  l'assegnazione conserva il nome del gruppo (assegnazioni.gruppo_nome).
- Creando il gruppo non si chiede l'email: users.username dal nome del
  gruppo ("Gruppo Zisa" -> gruppo.zisa), users.email facoltativa. Login con
  "Email o nome utente". Nome del gruppo unico.
- Sciolto il gruppo il nome utente si libera (l'utenza disattivata prende
  ex.sorvegliante.<id>). XML: nome utente e gruppo_nome.
- Profilo, testata, cambio password e Utenti mostrano il nome utente dove
  manca l'email. Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:45:00 +02:00

303 lines
13 KiB
PHP

<?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
{
/**
* Crea il gruppo e l'utenza del sorvegliante: nome utente ricavato dal
* nome del gruppo (niente email), ruolo "sorvegliante", password
* temporanea da cambiare al primo accesso. Restituisce il gruppo e la
* password temporanea, da mostrare una volta sola all'amministratore
* insieme al nome utente ($gruppo->sorvegliante->utente->username).
*
* @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.");
}
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza,
// disattivata: si riattiva quella (un proclamatore, un'utenza).
$esistente = $sorvegliante->utente()->first();
if ($esistente && ! $esistente->isDisattivato()) {
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, $esistente) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
'name' => $sorvegliante->nome_completo,
'username' => self::nomeUtente($nome),
'password' => $password,
'password_change_required' => true,
'disattivato_at' => null,
])->save();
$utente->syncRoles(['sorvegliante']);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
->log('gruppo_creato');
return $gruppo;
});
return [$gruppo, $password];
}
/**
* Il nome utente dal nome del gruppo: minuscole, senza accenti, parole
* unite da un punto ("Gruppo Noce" → "gruppo.noce"); se è già preso, un
* numero in coda ("zisa2"). Mai una chiocciola: al login distingue il
* nome utente dall'email.
*/
public static function nomeUtente(string $nomeGruppo): string
{
$base = Str::limit(Str::slug($nomeGruppo, '.') ?: 'gruppo', 50, '');
$nome = $base;
for ($n = 2; User::where('username', $nome)->exists(); $n++) {
$nome = $base . $n;
}
return $nome;
}
/** @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): 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) {
$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,
]);
});
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 disattiva e libera il nome utente; resta
* perché è l'autrice delle assegnazioni che ha fatto.
*/
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();
if ($utente) {
$utente->removeRole('sorvegliante');
// Il nome utente del gruppo si libera; al suo posto uno che
// identifica l'utenza (backup XML) e con cui non si entra.
$utente->forceFill([
'disattivato_at' => now(),
'remember_token' => null,
'username' => "ex.sorvegliante.{$utente->id}",
])->save();
}
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_disattivato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto');
$gruppo->delete();
});
}
private function attivo(GruppoServizio $gruppo): void
{
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
}
}
}