diff --git a/.env.example b/.env.example index 48389d8..0198a86 100644 --- a/.env.example +++ b/.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 diff --git a/HANDOFF.md b/HANDOFF.md index a4785d3..d84c611 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -938,10 +938,12 @@ Le scelte che vale la pena conoscere: non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con la chiave di produzione, che vive nell'ambiente e nel volume. -- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è - un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto: - renderla pubblica solo per poterla testare avrebbe allargato la superficie - del componente per comodità del test. +- **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e + PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano + direttamente; il componente `Registro` si limita a validare l'anno e + scaricare. Il PDF scrive sopra il modulo ufficiale + (`resources/pdf/S-13_I.pdf`, convertito in PDF 1.4 per FPDI) alle + coordinate della sua griglia, come la cartolina S-12. ### Un solo container esegue le migrazioni @@ -956,6 +958,12 @@ mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il provider **solleva un'eccezione** se manca, invece di rigenerarla. +Dal 09/10/2026 l'entrypoint **si rifiuta di partire** se `APP_KEY` +nell'ambiente è diversa dalla copia in `storage/app/.app_key`: prima la +sovrascriveva, e un valore sbagliato in Dokploy distruggeva l'unica copia della +chiave giusta. Per un cambio voluto si aggiunge `APP_KEY_ROTATE=1` (la chiave +vecchia resta in `.app_key.prev-`) e poi la si toglie. + --- ## Bug corretti — i più significativi @@ -984,6 +992,14 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni. | Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` | | Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` | | Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` | +| Date esportate in UTC | Ogni backup → ripristino (e export → import XML) spostava **tutte** le date indietro di un giorno, sommandosi a ogni giro. I file vecchi vengono riportati al giorno giusto all'import | `XmlExchange::righeEsportate()`, `normalizeDateForDb()` | +| Import di qualsiasi XML | Un XML ben formato ma estraneo svuotava il database con "completato con successo". Ora servono la radice `` e le sezioni principali | `XmlExchange::importXmlFromContent()` | +| Caratteri di controllo nell'export | Uno solo, in una nota, rendeva il file (e il `data.xml` del backup) non reimportabile | `XmlExchange::addXmlText()` | +| Territori senza zona scartati all'import | Sparivano con tutto il loro storico di assegnazioni | `XmlExchange::importXmlFromContent()` | +| Import che estromette chi lo esegue | Password vecchia e ruoli del file applicati anche all'amministratore che importava | `XmlExchange::importXmlFromContent()` | +| `whereNull` fuori da `latestOfMany` | Con una chiusa più recente dell'aperta il territorio risultava libero mentre era fuori, e assegnabile due volte | `Territorio::assegnazioneCorrente()` | +| Periodi di assegnazione accavallati | Uscite retrodatate a prima dell'ultimo rientro, doppie aperte dal Registro, anno scelto a mano incoerente con la data | `Assegnazione::sovrappostaA()`, `Assegna`, `Registro` | +| Declassamento dell'ultimo amministratore | Togliendosi il ruolo l'installazione restava senza nessuno che potesse gestirla | `Settings/UsersIndex` | | `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` | | `@disabled` dentro `` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` | | Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` | @@ -1031,7 +1047,7 @@ docker compose --profile test run --build --rm test |------|---------------| | `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio | | `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata | -| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" compreso il caso in cui l'ultima del giro precedente è ancora aperta, esclusione di territori disattivati e cestinati, filtro sull'anno di servizio, ordinamento naturale dei numeri | +| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" (fine ciclo e ultimo rientro prima dell'anno), assegnazioni a cavallo d'anno su entrambi i fogli, una riga per ogni territorio attivo anche senza assegnazioni, esclusione di territori disattivati e cestinati, ordinamento naturale dei numeri, fogli da 20 righe e generazione del PDF | | `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede | | `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `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; } $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([ - 'territorio_id' => $this->territorio_id, - 'proclamatore_id' => $this->proclamatore_id, - 'anno_teocratico_id' => $annoTeocratico->id, - 'assigned_at' => $assignedDate, - 'created_by' => auth()->id(), - ]); + // 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(), + ]); + }); + + if (is_string($esito)) { + $this->addError('assigned_at', $esito); + + return; + } + + $assegnazione = $esito; activity()->causedBy(auth()->user()) ->performedOn($assegnazione) diff --git a/app/Livewire/Registro.php b/app/Livewire/Registro.php index 304bfda..8be842d 100644 --- a/app/Livewire/Registro.php +++ b/app/Livewire/Registro.php @@ -11,18 +11,14 @@ 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; - public string $search = ''; public string $filtroAnno = ''; public string $filtroZona = ''; @@ -37,7 +33,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 +96,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 +112,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 +127,54 @@ 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, ]; - if ($this->editingId) { - Assegnazione::findOrFail($this->editingId)->update($data); - } else { - $data['created_by'] = auth()->id(); - Assegnazione::create($data); + $conflitto = DB::transaction(function () use ($data, $uscita, $rientro) { + 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); + } else { + 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; } $this->showModal = false; @@ -199,16 +218,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 +242,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 $righe - * @return \Illuminate\Support\Collection> - */ - 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 - */ - 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 $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 $celle - * @return 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 === '') { diff --git a/app/Livewire/Settings/UsersIndex.php b/app/Livewire/Settings/UsersIndex.php index 16f5e0b..dd44eac 100644 --- a/app/Livewire/Settings/UsersIndex.php +++ b/app/Livewire/Settings/UsersIndex.php @@ -6,6 +6,7 @@ use App\Models\Assegnazione; use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; +use Livewire\Attributes\Locked; use Livewire\Component; use Spatie\Activitylog\Models\Activity; use Spatie\Permission\Models\Role; @@ -17,6 +18,9 @@ class UsersIndex extends Component 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 = []; public ?int $editingUserId = null; @@ -115,6 +119,23 @@ class UsersIndex extends Component $validated = $this->validate($this->editRules()); $user = User::query()->findOrFail($this->editingUserId); + // Togliere il ruolo di amministratore a sé stessi, o all'ultimo + // rimasto, lascia l'installazione senza nessuno che possa gestire + // utenti, impostazioni e backup. + if ($user->hasRole('amministratore') && $validated['editSelectedRole'] !== 'amministratore') { + if ($user->id === auth()->id()) { + $this->addError('editSelectedRole', 'Non puoi togliere a te stesso il ruolo di amministratore: chiedilo a un altro amministratore.'); + + return; + } + + if (User::role('amministratore')->count() <= 1) { + $this->addError('editSelectedRole', "È l'ultimo amministratore: assegna prima il ruolo a un altro utente."); + + return; + } + } + $user->name = $validated['editName']; $user->email = $validated['editEmail']; diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index d666fb6..ed565bb 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -241,11 +241,33 @@ class XmlExchange extends Component $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 . 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,7 +286,8 @@ 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(); Campagna::query()->delete(); Territorio::query()->withTrashed()->forceDelete(); @@ -334,6 +357,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 +368,17 @@ 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. $meta = []; $passwordHash = (string) ($userNode->password ?? ''); - if ($passwordHash !== '') { + if ($passwordHash !== '' && ! $eAttore) { $meta['password'] = $passwordHash; } - $rememberToken = (string) ($userNode->remember_token ?? ''); - $meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null; + if (! $eAttore) { + $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 ?? '')); @@ -415,8 +446,8 @@ class XmlExchange extends Component $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 +461,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 +486,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,8 +501,8 @@ 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, @@ -540,7 +576,15 @@ class XmlExchange extends Component $this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode); $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, @@ -581,16 +625,75 @@ class XmlExchange extends Component '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', 'deleted_at', 'created_at', 'updated_at']), + 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']), + 'anni_teocratici' => $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', '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 $colonne + * @return array> + */ + 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 +983,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; } @@ -1022,6 +1129,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 +1176,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; } diff --git a/app/Models/Assegnazione.php b/app/Models/Assegnazione.php index 01e99d6..d1b1b81 100644 --- a/app/Models/Assegnazione.php +++ b/app/Models/Assegnazione.php @@ -55,6 +55,44 @@ class Assegnazione extends Model }); } + /** + * L'assegnazione dello stesso territorio il cui periodo si sovrappone a + * quello indicato, oppure null. Un'assegnazione aperta dura fino a oggi e + * oltre; rientro e nuova uscita nello stesso giorno non si sovrappongono. + * + * Copre in un colpo solo la doppia assegnazione aperta, l'uscita + * retrodatata a prima dell'ultimo rientro e le correzioni a mano nel + * Registro che accavallano due periodi. Il confronto è fatto in PHP sulle + * poche assegnazioni del territorio: in SQL dipenderebbe da come il + * driver memorizza le DATE (MariaDB "Y-m-d", SQLite "Y-m-d H:i:s"). + */ + public static function sovrappostaA( + int $territorioId, + Carbon $uscita, + ?Carbon $rientro = null, + ?int $esclusaId = null + ): ?self { + $uscita = $uscita->copy()->startOfDay(); + $rientro = $rientro?->copy()->startOfDay(); + + return static::with('proclamatore') + ->where('territorio_id', $territorioId) + ->when($esclusaId, fn ($q) => $q->whereKeyNot($esclusaId)) + ->orderBy('assigned_at') + ->get() + ->first(fn (self $altra) => ($rientro === null || $altra->assigned_at->lt($rientro)) + && ($altra->returned_at === null || $uscita->lt($altra->returned_at))); + } + + /** Descrizione del periodo per i messaggi d'errore: "a Rossi Mario dal 01/09/2025 al 15/10/2025". */ + public function descrizionePeriodo(): string + { + $chi = $this->proclamatore?->nome_completo ?? 'un proclamatore'; + $al = $this->returned_at ? ' al ' . $this->returned_at->format('d/m/Y') : ', non ancora rientrato'; + + return "a {$chi} dal {$this->assigned_at->format('d/m/Y')}{$al}"; + } + // ─── Relationships ───────────────────────────────────────── public function territorio() diff --git a/app/Models/Territorio.php b/app/Models/Territorio.php index e49ea6a..c627123 100644 --- a/app/Models/Territorio.php +++ b/app/Models/Territorio.php @@ -61,9 +61,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() diff --git a/app/Services/Registro/ModuloS13.php b/app/Services/Registro/ModuloS13.php new file mode 100644 index 0000000..e2dca25 --- /dev/null +++ b/app/Services/Registro/ModuloS13.php @@ -0,0 +1,329 @@ +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 + */ + 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> + */ + 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; + } +} diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 819f3d1..f24a64c 100755 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -78,6 +78,21 @@ ok "Storage directories ready." KEY_FILE="/var/www/html/storage/app/.app_key" if [ -n "${APP_KEY:-}" ]; then + # The persisted copy is the backup of the key the data was encrypted + # with. If the environment now carries a different one (a typo in + # Dokploy, a value pasted from another install), overwriting the file + # would destroy the only copy of the right key: refuse to start instead. + # A deliberate rotation keeps the old key aside and needs an explicit flag. + if [ -s "$KEY_FILE" ] && [ "$(cat "$KEY_FILE")" != "$APP_KEY" ]; then + if [ "${APP_KEY_ROTATE:-}" = "1" ]; then + BACKUP_FILE="${KEY_FILE}.prev-$(date +%Y%m%d-%H%M%S)" + cp "$KEY_FILE" "$BACKUP_FILE" + warn "APP_KEY_ROTATE=1: the previous key was saved to $BACKUP_FILE." + warn "Data encrypted with it stays unreadable until re-encrypted." + else + die "APP_KEY in the environment differs from the key persisted in $KEY_FILE. Starting would make the encrypted personal data unreadable. Restore the previous APP_KEY in the environment, or set APP_KEY_ROTATE=1 if the change is deliberate (the old key is kept aside)." + fi + fi ok "APP_KEY provided via environment." printf '%s' "$APP_KEY" > "$KEY_FILE" elif [ -s "$KEY_FILE" ]; then diff --git a/resources/pdf/S-13_I.pdf b/resources/pdf/S-13_I.pdf index ee10bec..4e33a91 100644 Binary files a/resources/pdf/S-13_I.pdf and b/resources/pdf/S-13_I.pdf differ diff --git a/resources/views/livewire/registro.blade.php b/resources/views/livewire/registro.blade.php index b8d6509..822e5d7 100644 --- a/resources/views/livewire/registro.blade.php +++ b/resources/views/livewire/registro.blade.php @@ -8,7 +8,7 @@ Nuova voce + title="Scarica il modulo S-13 compilato per un anno di servizio"> Modulo S-13 @@ -210,18 +210,8 @@ @error('form_proclamatore_id') {{ $message }} @enderror - {{-- Anno teocratico --}} -
- - - @error('form_anno_id') {{ $message }} @enderror -
- + {{-- L'anno di servizio non si sceglie: lo ricava il salvataggio + dalla data di assegnazione. --}} {{-- Campagna --}}
diff --git a/resources/views/pdf/registro-s13.blade.php b/resources/views/pdf/registro-s13.blade.php deleted file mode 100644 index 317253b..0000000 --- a/resources/views/pdf/registro-s13.blade.php +++ /dev/null @@ -1,222 +0,0 @@ -{{-- - Modulo S-13-I — Registrazione delle assegnazioni del territorio. - - Ricostruzione fedele del modulo ufficiale, la cui copia è in - resources/pdf/S-13_I.pdf e va usata come matrice per qualsiasi modifica - alla griglia. Il modulo originale è un PDF 1.7 con font CID embeddati: - sovrapporvi testo richiederebbe il parser a pagamento di FPDI, mentre - dompdf è già in uso per gli altri export del progetto. - - Struttura per pagina: 21 blocchi-territorio, ognuno su due righe — quella - alta ospita i nomi dei proclamatori, quella bassa le date. Le colonne - "Terr. N." e "Restituito l'ultima volta il" occupano entrambe le righe. - - Il motore CSS di dompdf non è un browser: le larghezze stanno in - (con i soli colspan sarebbero ripartite a caso) e l'interruzione di pagina è - decisa dall'indice del ciclo, perché il supporto a :last-child non è - garantito e un foglio bianco in coda sarebbe visibile. ---}} -@php - /** - * I nomi devono entrare nella loro cella senza sfondare la griglia: le - * righe hanno altezza fissa, quindi una riga di troppo sballerebbe tutto - * il foglio. Invece di andare a capo si scala il corpo. - * - * La larghezza si stima a caratteri: 0.20mm per carattere per punto di - * corpo. È deliberatamente pessimistica — la media reale di DejaVu Sans è - * ~0.52em (0.1835mm), ma sui nomi propri, pieni di maiuscole, la media non - * regge e con zero margine il testo deborda dalla cella. Sotto i 4.5pt il - * testo non sarebbe più leggibile, quindi oltre quel limite si tronca: - * l'ellissi rende visibile l'accorciamento, mentre un nome che deborda - * corromperebbe la griglia in silenzio. - * - * La larghezza qui sotto è misurata sul PDF renderizzato, non dedotta - * dalle percentuali: al netto di padding e bordi la cella risulta 35.9mm, - * e la costante tiene un margine su quel valore. Se cambiano le - * percentuali delle colonne o i margini di pagina, va rimisurata. - * - * "Assegnato a" è nowrap su una riga sola: basta il corpo che ci sta, e - * oltre il minimo si tronca. La colonna del riporto ospita solo una data, - * quindi non ha bisogno di adattamenti. - */ - $MM_PER_CARATTERE_PER_PUNTO = 0.20; - $CORPO_MIN_PT = 4.5; - - $LARGHEZZA_NOME_MM = 34.0; // cella misurata 35.9mm - - $corpoPer = static function (string $testo, float $larghezzaMm, float $corpoMaxPt) use ($MM_PER_CARATTERE_PER_PUNTO, $CORPO_MIN_PT): string { - $len = mb_strlen($testo); - - if ($len === 0) { - return $corpoMaxPt . 'pt'; - } - - $pt = $larghezzaMm / ($len * $MM_PER_CARATTERE_PER_PUNTO); - - return round(max($CORPO_MIN_PT, min($corpoMaxPt, $pt)), 1) . 'pt'; - }; - - $troncaPer = static function (string $testo, float $larghezzaMm) use ($MM_PER_CARATTERE_PER_PUNTO, $CORPO_MIN_PT): string { - $max = (int) floor($larghezzaMm / ($CORPO_MIN_PT * $MM_PER_CARATTERE_PER_PUNTO)); - - return mb_strlen($testo) > $max ? mb_substr($testo, 0, $max - 1) . '…' : $testo; - }; - - $ultimaPagina = $pagine->count() - 1; -@endphp - - - - - S-13 — Registrazione delle assegnazioni del territorio - - - -@foreach($pagine as $indice => $righe) -
-

REGISTRAZIONE DELLE ASSEGNAZIONI DEL TERRITORIO

- -
Anno di servizio: {{ $annoLabel }}
- - - - - - @for($c = 0; $c < 8; $c++) - - @endfor - - - {{-- Le larghezze stanno sia nel sia qui: in layout - fisso dompdf le ricava dalle celle della prima riga, e - senza queste le due colonne rowspan si spartiscono lo - spazio in parti uguali invece di rispettare il colgroup. --}} - - - - @for($g = 0; $g < 4; $g++) - - @endfor - - - @for($g = 0; $g < 4; $g++) - - - @endfor - - - - @foreach($righe as $riga) - - - - @foreach($riga['assegnazioni'] as $a) - @php($nome = $troncaPer($a['nome'], $LARGHEZZA_NOME_MM)) - - @endforeach - - - @foreach($riga['assegnazioni'] as $a) - - - @endforeach - - @endforeach - -
Terr.
N.
Restituito
l’ultima volta il*
Assegnato a
Assegnato ilRestituito il
{{ $riga['numero'] }}{{ $riga['riporto'] }}{{ $nome }}
{{ $a['assegnato_il'] }}{{ $a['restituito_il'] }}
- -
*Quando si inizia un nuovo foglio, registrare in questa colonna l’ultima data in cui è stato restituito ogni territorio.
-
S-13-I  1/22
-
-@endforeach - - diff --git a/tests/Feature/AssegnazioneSovrapposizioneTest.php b/tests/Feature/AssegnazioneSovrapposizioneTest.php new file mode 100644 index 0000000..ca4121f --- /dev/null +++ b/tests/Feature/AssegnazioneSovrapposizioneTest.php @@ -0,0 +1,211 @@ + $permesso, 'guard_name' => 'web']); + } + Role::create(['name' => 'amministratore', 'guard_name' => 'web'])->givePermissionTo(Permission::all()); + + $this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password']); + $this->admin->assignRole('amministratore'); + $this->actingAs($this->admin); + + $this->territorio = Territorio::create(['numero' => '5', 'attivo' => true]); + $this->proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + parent::tearDown(); + } + + // ─── L'assegnazione in corso ──────────────────────────────── + + /** + * Il caso che rendeva il territorio "libero" mentre era fuori: una chiusa + * con data più recente dell'aperta. + */ + public function test_l_assegnazione_in_corso_si_trova_anche_con_una_chiusa_piu_recente(): void + { + $aperta = $this->assegna('2026-08-20'); + $this->assegna('2026-09-01', '2026-09-15'); + + $this->assertTrue($this->territorio->fresh()->assegnazioneCorrente?->is($aperta)); + } + + // ─── Il controllo di sovrapposizione ──────────────────────── + + public function test_rientro_e_nuova_uscita_nello_stesso_giorno_non_si_sovrappongono(): void + { + $this->assegna('2026-09-01', '2026-09-20'); + + $this->assertNull(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-09-20'))); + } + + public function test_un_uscita_prima_dell_ultimo_rientro_si_sovrappone(): void + { + $chiusa = $this->assegna('2026-09-01', '2026-09-20'); + + $this->assertTrue(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-09-10'))?->is($chiusa)); + } + + public function test_con_un_assegnazione_aperta_ogni_nuova_uscita_si_sovrappone(): void + { + $aperta = $this->assegna('2026-09-01'); + + $this->assertTrue(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-10-01'))?->is($aperta)); + } + + public function test_un_periodo_chiuso_inserito_prima_di_tutti_non_si_sovrappone(): void + { + $this->assegna('2026-09-01'); + + $this->assertNull(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-06-01'), Carbon::parse('2026-07-01'))); + } + + // ─── Pagina Assegna ───────────────────────────────────────── + + public function test_assegna_rifiuta_un_uscita_retrodatata_a_prima_dell_ultimo_rientro(): void + { + $this->assegna('2026-09-01', '2026-09-20'); + + Livewire::test(Assegna::class, ['territorioId' => $this->territorio->id]) + ->set('proclamatore_id', $this->proclamatore->id) + ->set('assigned_at', '2026-09-10') + ->call('save') + ->assertHasErrors('assigned_at'); + + $this->assertSame(1, Assegnazione::count()); + } + + public function test_assegna_accetta_l_uscita_dal_giorno_del_rientro(): void + { + $this->assegna('2026-09-01', '2026-09-20'); + + Livewire::test(Assegna::class, ['territorioId' => $this->territorio->id]) + ->set('proclamatore_id', $this->proclamatore->id) + ->set('assigned_at', '2026-09-20') + ->call('save') + ->assertHasNoErrors(); + + $this->assertSame(2, Assegnazione::count()); + } + + // ─── Registro (correzioni dell'amministratore) ────────────── + + public function test_il_registro_ricava_l_anno_di_servizio_dalla_data(): void + { + Livewire::test(Registro::class) + ->call('openCreate') + ->set('form_territorio_id', (string) $this->territorio->id) + ->set('form_proclamatore_id', (string) $this->proclamatore->id) + ->set('form_assigned_at', '2025-08-10') + ->set('form_returned_at', '2025-09-05') + ->call('save') + ->assertHasNoErrors(); + + $this->assertSame('2024-2025', Assegnazione::sole()->annoTeocratico->label); + } + + public function test_il_registro_non_crea_una_seconda_assegnazione_aperta(): void + { + $this->assegna('2026-09-01'); + + Livewire::test(Registro::class) + ->call('openCreate') + ->set('form_territorio_id', (string) $this->territorio->id) + ->set('form_proclamatore_id', (string) $this->proclamatore->id) + ->set('form_assigned_at', '2026-10-01') + ->call('save') + ->assertHasErrors('form_assigned_at'); + + $this->assertSame(1, Assegnazione::count()); + } + + public function test_il_registro_non_riapre_un_assegnazione_chiusa_se_il_territorio_e_fuori(): void + { + $chiusa = $this->assegna('2026-08-01', '2026-08-20'); + $this->assegna('2026-09-01'); + + Livewire::test(Registro::class) + ->call('openEdit', $chiusa->id) + ->set('form_returned_at', '') + ->call('save') + ->assertHasErrors('form_assigned_at'); + + $this->assertNotNull($chiusa->fresh()->returned_at); + } + + public function test_il_registro_rifiuta_le_date_future(): void + { + Livewire::test(Registro::class) + ->call('openCreate') + ->set('form_territorio_id', (string) $this->territorio->id) + ->set('form_proclamatore_id', (string) $this->proclamatore->id) + ->set('form_assigned_at', '2026-11-01') + ->call('save') + ->assertHasErrors('form_assigned_at'); + } + + public function test_il_registro_permette_di_correggere_la_stessa_assegnazione(): void + { + $aperta = $this->assegna('2026-09-01'); + + Livewire::test(Registro::class) + ->call('openEdit', $aperta->id) + ->set('form_assigned_at', '2026-09-03') + ->call('save') + ->assertHasNoErrors(); + + $this->assertSame('2026-09-03', $aperta->fresh()->assigned_at->toDateString()); + } + + // ─── helper ───────────────────────────────────────────────── + + private function assegna(string $uscita, ?string $rientro = null): Assegnazione + { + return Assegnazione::create([ + 'territorio_id' => $this->territorio->id, + 'proclamatore_id' => $this->proclamatore->id, + 'anno_teocratico_id' => AnnoTeocratico::perData(Carbon::parse($uscita))->id, + 'assigned_at' => $uscita, + 'returned_at' => $rientro, + 'created_by' => $this->admin->id, + ]); + } +} diff --git a/tests/Feature/RegistroS13Test.php b/tests/Feature/RegistroS13Test.php index 04b2f23..1c8470b 100644 --- a/tests/Feature/RegistroS13Test.php +++ b/tests/Feature/RegistroS13Test.php @@ -2,27 +2,23 @@ namespace Tests\Feature; -use App\Livewire\Registro; use App\Models\AnnoTeocratico; use App\Models\Assegnazione; use App\Models\Proclamatore; use App\Models\Territorio; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use App\Services\Registro\ModuloS13; use Illuminate\Support\Collection; -use ReflectionMethod; use Tests\TestCase; /** - * Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a" e la colonna - * "Restituito l'ultima volta il". + * Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a", la colonna + * "Restituito l'ultima volta il" e le assegnazioni a cavallo d'anno. * * E' la regola meno intuitiva del Registro — la quinta assegnazione non si * aggiunge alle quattro precedenti, le sostituisce — e finisce su un modulo che * viene firmato e archiviato: se sbaglia, sbaglia su carta. - * - * righeS13() e' privata di proposito (non e' un'azione Livewire); qui la si - * chiama in riflessione perche' e' il calcolo, non il PDF, quello da proteggere. */ class RegistroS13Test extends TestCase { @@ -68,7 +64,7 @@ class RegistroS13Test extends TestCase $this->assertSame('', $riga['riporto']); $this->assertCount(4, $riga['assegnazioni']); $this->assertSame( - ['nome' => 'Rossi Mario', 'assegnato_il' => '10/09/2025', 'restituito_il' => '15/10/2025'], + ['nome' => 'Rossi M.', 'assegnato_il' => '10/09/2025', 'restituito_il' => '15/10/2025'], $riga['assegnazioni'][0] ); @@ -82,6 +78,19 @@ class RegistroS13Test extends TestCase } } + public function test_il_nome_e_cognome_piu_iniziale_del_nome(): void + { + $proclamatore = Proclamatore::create([ + 'nome' => 'élena Maria', + 'cognome' => "Dell'Acqua Bianchi", + 'attivo' => true, + ]); + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-09-10')->update(['proclamatore_id' => $proclamatore->id]); + + $this->assertSame("Dell'Acqua Bianchi É.", $this->righe()->sole()['assegnazioni'][0]['nome']); + } + public function test_un_assegnazione_ancora_aperta_ha_la_data_di_rientro_vuota(): void { $territorio = $this->territorio('1'); @@ -255,17 +264,11 @@ class RegistroS13Test extends TestCase $this->assertSame(['1'], $this->righe()->pluck('numero')->all()); } - public function test_le_assegnazioni_di_un_altro_anno_di_servizio_non_compaiono(): void + public function test_le_assegnazioni_chiuse_di_un_altro_anno_di_servizio_non_compaiono(): void { - $altroAnno = AnnoTeocratico::create([ - 'label' => '2024-2025', - 'start_date' => '2024-09-01', - 'end_date' => '2025-08-31', - ]); - $territorio = $this->territorio('1'); $this->assegna($territorio, '2025-09-01', '2025-09-20'); - $this->assegna($territorio, '2025-01-10', '2025-02-10', $altroAnno); + $this->assegna($territorio, '2025-01-10', '2025-02-10', $this->annoPrecedente()); $riga = $this->righe()->sole(); @@ -275,11 +278,168 @@ class RegistroS13Test extends TestCase ); } - public function test_un_territorio_senza_assegnazioni_nell_anno_non_produce_una_riga(): void + /** + * Il registro serve a vedere anche i territori fermi: una riga vuota con + * la data dell'ultimo rientro dice da quanto non escono. + */ + public function test_un_territorio_senza_assegnazioni_nell_anno_ha_comunque_la_sua_riga(): void + { + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-03-01', '2025-04-12', $this->annoPrecedente()); + + $riga = $this->righe()->sole(); + + $this->assertSame('1', $riga['numero']); + $this->assertSame('12/04/2025', $riga['riporto']); + $this->assertSame(['', '', '', ''], array_column($riga['assegnazioni'], 'nome')); + } + + public function test_un_territorio_mai_assegnato_ha_la_riga_vuota(): void { $this->territorio('1'); - $this->assertTrue($this->righe()->isEmpty()); + $riga = $this->righe()->sole(); + + $this->assertSame('', $riga['riporto']); + $this->assertSame(['', '', '', ''], array_column($riga['assegnazioni'], 'assegnato_il')); + } + + // ─── Il riporto a inizio anno ─────────────────────────────── + + /** + * La nota del modulo: "quando si inizia un nuovo foglio, registrare in + * questa colonna l'ultima data in cui e' stato restituito ogni territorio". + */ + public function test_nel_primo_ciclo_il_riporto_e_l_ultimo_rientro_prima_dell_anno(): void + { + $precedente = $this->annoPrecedente(); + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-01-10', '2025-02-10', $precedente); + $this->assegna($territorio, '2025-05-02', '2025-06-30', $precedente); + $this->assegna($territorio, '2025-10-01', '2025-10-20'); + + $riga = $this->righe()->sole(); + + $this->assertSame('30/06/2025', $riga['riporto']); + $this->assertSame('01/10/2025', $riga['assegnazioni'][0]['assegnato_il']); + } + + // ─── A cavallo d'anno ─────────────────────────────────────── + + /** + * Uscito ad agosto, rientrato a ottobre: sul foglio nuovo occupa il primo + * gruppo con entrambe le date, e il riporto e' il rientro precedente. + */ + public function test_l_assegnazione_a_cavallo_apre_il_foglio_nuovo_con_entrambe_le_date(): void + { + $precedente = $this->annoPrecedente(); + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-05-02', '2025-06-30', $precedente); + $this->assegna($territorio, '2025-08-10', '2025-10-05', $precedente); + $this->assegna($territorio, '2025-11-01', '2025-11-20'); + + $riga = $this->righe()->sole(); + + $this->assertSame('30/06/2025', $riga['riporto']); + $this->assertSame( + ['nome' => 'Rossi M.', 'assegnato_il' => '10/08/2025', 'restituito_il' => '05/10/2025'], + $riga['assegnazioni'][0] + ); + $this->assertSame('01/11/2025', $riga['assegnazioni'][1]['assegnato_il']); + } + + public function test_sul_foglio_vecchio_l_assegnazione_a_cavallo_resta_senza_rientro(): void + { + $precedente = $this->annoPrecedente(); + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-08-10', '2025-10-05', $precedente); + + $riga = $this->righe($precedente)->sole(); + + $this->assertSame('10/08/2025', $riga['assegnazioni'][0]['assegnato_il']); + $this->assertSame('', $riga['assegnazioni'][0]['restituito_il']); + } + + public function test_un_assegnazione_ancora_fuori_da_un_anno_precedente_compare_sul_foglio_nuovo(): void + { + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-07-01', null, $this->annoPrecedente()); + + $riga = $this->righe()->sole(); + + $this->assertSame('01/07/2025', $riga['assegnazioni'][0]['assegnato_il']); + $this->assertSame('', $riga['assegnazioni'][0]['restituito_il']); + } + + /** L'assegnazione a cavallo conta nel ciclo: con altre quattro nell'anno, la riga ricomincia. */ + public function test_l_assegnazione_a_cavallo_conta_nel_ciclo_da_quattro(): void + { + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-08-10', '2025-09-15', $this->annoPrecedente()); + $this->assegna($territorio, '2025-10-01', '2025-10-20'); + $this->assegna($territorio, '2025-11-01', '2025-11-20'); + $this->assegna($territorio, '2025-12-01', '2025-12-24'); + $this->assegna($territorio, '2026-01-05'); + + $riga = $this->righe()->sole(); + + $this->assertSame('24/12/2025', $riga['riporto']); + $this->assertSame(['05/01/2026', '', '', ''], array_column($riga['assegnazioni'], 'assegnato_il')); + } + + /** + * Il foglio sceglie per data, non per anno_teocratico_id: una data + * corretta a mano nel Registro o un import legacy possono lasciare l'anno + * registrato incoerente con il giorno di uscita. + */ + public function test_conta_la_data_di_uscita_non_l_anno_registrato(): void + { + $precedente = $this->annoPrecedente(); + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2025-10-01', '2025-10-20', $precedente); + + $this->assertSame('01/10/2025', $this->righe()->sole()['assegnazioni'][0]['assegnato_il']); + $this->assertSame('', $this->righe($precedente)->sole()['assegnazioni'][0]['assegnato_il']); + } + + public function test_l_ultimo_giorno_dell_anno_appartiene_al_foglio(): void + { + $territorio = $this->territorio('1'); + $this->assegna($territorio, '2026-08-31', '2026-08-31'); + + $riga = $this->righe()->sole(); + + $this->assertSame('31/08/2026', $riga['assegnazioni'][0]['assegnato_il']); + $this->assertSame('31/08/2026', $riga['assegnazioni'][0]['restituito_il']); + } + + // ─── Impaginazione e PDF ──────────────────────────────────── + + public function test_ogni_foglio_ha_venti_righe_come_il_modulo(): void + { + foreach (range(1, 21) as $numero) { + $this->territorio((string) $numero); + } + + $pagine = (new ModuloS13())->pagine($this->righe()); + + $this->assertCount(2, $pagine); + $this->assertCount(20, $pagine[0]); + $this->assertCount(20, $pagine[1]); + $this->assertSame('21', $pagine[1][0]['numero']); + $this->assertSame('', $pagine[1][1]['numero']); + } + + public function test_il_pdf_e_il_modulo_ufficiale_con_una_pagina_per_foglio(): void + { + foreach (range(1, 21) as $numero) { + $this->assegna($this->territorio((string) $numero), '2025-09-01'); + } + + $pdf = (new ModuloS13())->genera($this->anno); + + $this->assertStringStartsWith('%PDF-', $pdf); + $this->assertSame(2, preg_match_all('#/Type /Page\b(?!s)#', $pdf)); } /** @@ -326,8 +486,15 @@ class RegistroS13Test extends TestCase */ private function righe(?AnnoTeocratico $anno = null): Collection { - $metodo = new ReflectionMethod(Registro::class, 'righeS13'); + return (new ModuloS13())->righe($anno ?? $this->anno); + } - return $metodo->invoke(new Registro(), $anno ?? $this->anno); + private function annoPrecedente(): AnnoTeocratico + { + return AnnoTeocratico::create([ + 'label' => '2024-2025', + 'start_date' => '2024-09-01', + 'end_date' => '2025-08-31', + ]); } } diff --git a/tests/Feature/UltimoAmministratoreTest.php b/tests/Feature/UltimoAmministratoreTest.php new file mode 100644 index 0000000..9762648 --- /dev/null +++ b/tests/Feature/UltimoAmministratoreTest.php @@ -0,0 +1,78 @@ + 'amministratore', 'guard_name' => 'web']); + Role::create(['name' => 'assistente', 'guard_name' => 'web']); + + $this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password']); + $this->admin->assignRole('amministratore'); + $this->actingAs($this->admin); + } + + public function test_non_si_toglie_a_se_stessi_il_ruolo_di_amministratore(): void + { + // Anche con un secondo amministratore: il declassamento di sé stessi + // va chiesto a un altro. + User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'password']) + ->assignRole('amministratore'); + + $this->modificaRuolo($this->admin, 'assistente')->assertHasErrors('editSelectedRole'); + + $this->assertTrue($this->admin->fresh()->hasRole('amministratore')); + } + + public function test_non_si_declassa_l_ultimo_amministratore(): void + { + // Chi modifica non è l'amministratore stesso, così la regola su sé + // stessi non entra in gioco: si prova solo quella sull'ultimo. Il + // permesso della pagina sta nel middleware della rotta, che + // Livewire::test non attraversa. + $operatore = User::create(['name' => 'Operatore', 'email' => 'op@example.test', 'password' => 'password']); + $operatore->assignRole('assistente'); + $this->actingAs($operatore); + + $this->modificaRuolo($this->admin, 'assistente')->assertHasErrors('editSelectedRole'); + + $this->assertTrue($this->admin->fresh()->hasRole('amministratore')); + } + + public function test_un_amministratore_ne_declassa_un_altro_se_non_e_l_ultimo(): void + { + $altro = User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'password']); + $altro->assignRole('amministratore'); + + $this->modificaRuolo($altro, 'assistente')->assertHasNoErrors(); + + $this->assertTrue($altro->fresh()->hasRole('assistente')); + $this->assertFalse($altro->fresh()->hasRole('amministratore')); + } + + private function modificaRuolo(User $utente, string $ruolo) + { + return Livewire::test(UsersIndex::class) + ->set('editingUserId', $utente->id) + ->set('editName', $utente->name) + ->set('editEmail', $utente->email) + ->set('editSelectedRole', $ruolo) + ->call('updateUser'); + } +} diff --git a/tests/Feature/XmlExchangeImportTest.php b/tests/Feature/XmlExchangeImportTest.php new file mode 100644 index 0000000..59a876b --- /dev/null +++ b/tests/Feature/XmlExchangeImportTest.php @@ -0,0 +1,216 @@ + 'amministratore', 'guard_name' => 'web']); + Role::create(['name' => 'assistente', 'guard_name' => 'web']); + + $this->admin = User::create([ + 'name' => 'Amministratore', + 'email' => 'admin@example.test', + 'password' => 'password-attuale', + ]); + $this->admin->assignRole('amministratore'); + $this->actingAs($this->admin); + } + + // ─── Round-trip ───────────────────────────────────────────── + + /** + * Il difetto che spostava ogni data indietro di un giorno a ogni giro di + * backup e ripristino: toArray() le serializzava in UTC. + */ + public function test_export_e_reimport_conservano_le_date_esatte(): void + { + $anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']); + Campagna::create(['descrizione' => 'Commemorazione', 'start_date' => '2026-03-01', 'end_date' => '2026-03-31']); + $territorio = Territorio::create(['numero' => '7', 'attivo' => true]); + $proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]); + Assegnazione::create([ + 'territorio_id' => $territorio->id, + 'proclamatore_id' => $proclamatore->id, + 'anno_teocratico_id' => $anno->id, + 'assigned_at' => '2026-03-15', + 'returned_at' => '2026-04-01', + 'created_by' => $this->admin->id, + ]); + + $this->reimporta(); + + $assegnazione = Assegnazione::sole(); + $this->assertSame('2026-03-15', $assegnazione->assigned_at->toDateString()); + $this->assertSame('2026-04-01', $assegnazione->returned_at->toDateString()); + + $anno = AnnoTeocratico::sole(); + $this->assertSame('2025-09-01', $anno->start_date->toDateString()); + $this->assertSame('2026-08-31', $anno->end_date->toDateString()); + + $campagna = Campagna::sole(); + $this->assertSame('2026-03-01', $campagna->start_date->toDateString()); + $this->assertSame('2026-03-31', $campagna->end_date->toDateString()); + } + + /** I backup fatti prima della correzione hanno le date in UTC: tornano giuste. */ + public function test_un_backup_vecchio_con_date_in_utc_viene_riportato_al_giorno_giusto(): void + { + $xml = $this->xmlMinimo( + anni: '2025-08-31T22:00:00.000000Z2026-08-30T22:00:00.000000Z', + assegnazioni: '1112026-03-14T23:00:00.000000Z', + ); + + $this->assertArrayNotHasKey('error', $this->importa($xml)); + + $this->assertSame('2026-03-15', Assegnazione::sole()->assigned_at->toDateString()); + $this->assertSame('2025-09-01', AnnoTeocratico::sole()->start_date->toDateString()); + $this->assertSame('2026-08-31', AnnoTeocratico::sole()->end_date->toDateString()); + } + + /** Un carattere di controllo in una nota rendeva l'intero file illeggibile. */ + public function test_un_carattere_di_controllo_in_una_nota_non_rompe_il_file(): void + { + Territorio::create(['numero' => '1', 'attivo' => true, 'note' => "Citofono\x0Brotto\x01"]); + + $xml = (new XmlExchange())->currentXmlBackup(); + + $this->assertNotFalse(simplexml_load_string($xml)); + $this->reimporta($xml); + $this->assertSame('Citofonorotto', Territorio::sole()->note); + } + + /** + * Un territorio resta senza zona quando la sua zona viene eliminata: al + * reimport veniva scartato insieme a tutte le sue assegnazioni. + */ + public function test_un_territorio_senza_zona_viene_importato_con_il_suo_storico(): void + { + $anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']); + $territorio = Territorio::create(['numero' => '3', 'attivo' => true, 'zona_id' => null]); + $proclamatore = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Bianchi', 'attivo' => true]); + Assegnazione::create([ + 'territorio_id' => $territorio->id, + 'proclamatore_id' => $proclamatore->id, + 'anno_teocratico_id' => $anno->id, + 'assigned_at' => '2025-10-01', + 'created_by' => $this->admin->id, + ]); + + $this->reimporta(); + + $this->assertNull(Territorio::sole()->zona_id); + $this->assertSame(1, Assegnazione::count()); + } + + // ─── File da rifiutare ────────────────────────────────────── + + public function test_un_xml_estraneo_viene_rifiutato_senza_toccare_i_dati(): void + { + Territorio::create(['numero' => '1', 'attivo' => true]); + + $esito = $this->importa('https://example.org'); + + $this->assertStringContainsString('non è un export di TerManager2', $esito['error']); + $this->assertSame(1, Territorio::count()); + } + + public function test_un_export_senza_le_sezioni_principali_viene_rifiutato(): void + { + Territorio::create(['numero' => '1', 'attivo' => true]); + + $esito = $this->importa(''); + + $this->assertStringContainsString('mancano le sezioni', $esito['error']); + $this->assertSame(1, Territorio::count()); + } + + public function test_un_file_malformato_indica_riga_e_motivo(): void + { + $esito = $this->importa("\n\n\n"); + + $this->assertStringContainsString('riga', $esito['error']); + } + + // ─── Chi importa non resta fuori ──────────────────────────── + + /** + * Il file può contenere l'account di chi importa con una password vecchia + * e senza ruoli: l'amministratore che lo carica non deve perdere né l'una + * né gli altri. + */ + public function test_chi_importa_conserva_password_e_ruolo_di_amministratore(): void + { + $hashVecchio = Hash::make('password-di-un-anno-fa'); + $xml = $this->xmlMinimo( + utenti: 'Amministratoreadmin@example.test' . $hashVecchio . '' + . 'Altroaltro@example.test' . $hashVecchio . 'assistente', + ); + + $this->assertArrayNotHasKey('error', $this->importa($xml)); + + $admin = $this->admin->fresh(); + $this->assertTrue(Hash::check('password-attuale', $admin->password)); + $this->assertTrue($admin->hasRole('amministratore')); + + // Gli altri account invece prendono i dati del file. + $altro = User::where('email', 'altro@example.test')->sole(); + $this->assertTrue(Hash::check('password-di-un-anno-fa', $altro->password)); + $this->assertTrue($altro->hasRole('assistente')); + } + + // ─── helper ───────────────────────────────────────────────── + + private function reimporta(?string $xml = null): void + { + $xml ??= (new XmlExchange())->currentXmlBackup(); + + $esito = $this->importa($xml); + + $this->assertArrayNotHasKey('error', $esito, $esito['error'] ?? ''); + $this->assertSame([], $esito['issues'], json_encode($esito['issues'])); + } + + private function importa(string $xml): array + { + return (new XmlExchange())->importXmlFromContent($xml); + } + + private function xmlMinimo(string $utenti = '', string $anni = '', string $assegnazioni = ''): string + { + return '' + . '' . $utenti . '' + . 'MarioRossi1' + . '11' + . '' . $anni . '' + . '' . $assegnazioni . '' + . ''; + } +}