S-13 sul modulo ufficiale e correzioni all'integrità dei dati
Modulo S-13 - Il PDF scrive sopra il modulo ufficiale (FPDI, matrice convertita in PDF 1.4) invece di ricostruirlo in HTML: 20 righe per foglio come l'originale. - Una riga per ogni territorio attivo; riporto dell'ultimo rientro prima dell'anno; assegnazioni a cavallo d'anno su entrambi i fogli; scelta per data e non per anno registrato; nomi come "Cognome I.". - Logica spostata in App\Services\Registro\ModuloS13. Integrità dei dati - Export/import XML (e quindi backup/ripristino): le date non slittano più di un giorno; i file vecchi in UTC tornano al giorno giusto. - Import XML: rifiuta file estranei o incompleti senza toccare i dati, errore con riga e motivo, territori senza zona importati con il loro storico, caratteri di controllo rimossi in export, chi importa conserva password e ruolo di amministratore. - Assegnazioni: assegnazioneCorrente corretta (filtro dentro ofMany), periodi sovrapposti rifiutati in Assegna e Registro sotto lock, anno di servizio ricavato dalla data, niente date future nel Registro. - Utenti: non si declassa sé stessi né l'ultimo amministratore. - Entrypoint: non sovrascrive più la copia persistente di APP_KEY con un valore diverso; serve APP_KEY_ROTATE=1 per un cambio voluto. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,10 @@ APP_URL=http://192.0.2.10:8080
|
|||||||
# I dati personali dei proclamatori sono cifrati con questa chiave: se la
|
# I dati personali dei proclamatori sono cifrati con questa chiave: se la
|
||||||
# perdi non sono piu' leggibili nemmeno avendo il backup del database.
|
# perdi non sono piu' leggibili nemmeno avendo il backup del database.
|
||||||
# Conservala in un password manager.
|
# 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=
|
APP_KEY=
|
||||||
|
|
||||||
LOG_CHANNEL=stderr
|
LOG_CHANNEL=stderr
|
||||||
|
|||||||
41
HANDOFF.md
41
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
|
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.
|
la chiave di produzione, che vive nell'ambiente e nel volume.
|
||||||
|
|
||||||
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è
|
- **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e
|
||||||
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto:
|
PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano
|
||||||
renderla pubblica solo per poterla testare avrebbe allargato la superficie
|
direttamente; il componente `Registro` si limita a validare l'anno e
|
||||||
del componente per comodità del test.
|
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
|
### 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
|
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
|
||||||
provider **solleva un'eccezione** se manca, invece di rigenerarla.
|
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-<data>`) e poi la si toglie.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug corretti — i più significativi
|
## 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` |
|
| 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` |
|
| 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` |
|
| 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 `<termanager2>` 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` |
|
| `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 `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
|
| `@disabled` dentro `<x-btn>` | 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()` |
|
| 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/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/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/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 `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
| `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 `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
||||||
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
||||||
@@ -1073,15 +1089,14 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
|
|||||||
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
||||||
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
||||||
|
|
||||||
### 2. Race su doppia assegnazione
|
### 2. Race su doppia assegnazione — chiusa il 09/10/2026
|
||||||
|
|
||||||
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma
|
`Assegna::save()` e il salvataggio del Registro controllano e scrivono dentro
|
||||||
nulla a livello di database impedisce due assegnazioni aperte sullo stesso
|
una transazione con `lockForUpdate()` sulla riga del territorio, e rifiutano
|
||||||
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`,
|
ogni periodo che si accavalla a un altro (`Assegnazione::sovrappostaA()`). Non
|
||||||
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi.
|
c'è ancora un vincolo nel database: l'import XML e i dati storici possono
|
||||||
|
contenere sovrapposizioni, che il controllo segnala solo quando si modifica
|
||||||
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna
|
una di quelle righe.
|
||||||
generata che imponga una sola assegnazione aperta per territorio.
|
|
||||||
|
|
||||||
### 3. Scrittura durante il rendering
|
### 3. Scrittura durante il rendering
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Territorio;
|
|||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class Assegna extends Component
|
class Assegna extends Component
|
||||||
@@ -43,34 +44,58 @@ class Assegna extends Component
|
|||||||
|
|
||||||
$territorio = Territorio::findOrFail($this->territorio_id);
|
$territorio = Territorio::findOrFail($this->territorio_id);
|
||||||
|
|
||||||
// Check territory is available (not currently assigned)
|
// Gli errori vanno sul campo e non in sessione: il messaggio flash
|
||||||
|
// sta nel telaio della pagina, che Livewire non ridisegna, e il
|
||||||
|
// rifiuto passerebbe inosservato.
|
||||||
if ($territorio->assegnazioneCorrente) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check territory is active
|
|
||||||
if (!$territorio->attivo) {
|
if (!$territorio->attivo) {
|
||||||
session()->flash('error', "Il territorio {$territorio->numero} è inattivo.");
|
$this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||||
if (!$proclamatore->attivo) {
|
if (!$proclamatore->attivo) {
|
||||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
|
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
|
||||||
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
|
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
|
||||||
|
|
||||||
$assegnazione = Assegnazione::create([
|
// Controllo e inserimento sotto lo stesso lock sulla riga del
|
||||||
'territorio_id' => $this->territorio_id,
|
// territorio: un doppio clic o due schede aperte non possono più
|
||||||
'proclamatore_id' => $this->proclamatore_id,
|
// superare entrambi il controllo e creare due assegnazioni aperte.
|
||||||
'anno_teocratico_id' => $annoTeocratico->id,
|
$esito = DB::transaction(function () use ($territorio, $assignedDate, $annoTeocratico) {
|
||||||
'assigned_at' => $assignedDate,
|
Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||||
'created_by' => auth()->id(),
|
|
||||||
]);
|
$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())
|
activity()->causedBy(auth()->user())
|
||||||
->performedOn($assegnazione)
|
->performedOn($assegnazione)
|
||||||
|
|||||||
@@ -11,18 +11,14 @@ use App\Models\Proclamatore;
|
|||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
use App\Models\Tipologia;
|
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
|
class Registro extends Component
|
||||||
{
|
{
|
||||||
use WithPagination;
|
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 $search = '';
|
||||||
public string $filtroAnno = '';
|
public string $filtroAnno = '';
|
||||||
public string $filtroZona = '';
|
public string $filtroZona = '';
|
||||||
@@ -37,7 +33,6 @@ class Registro extends Component
|
|||||||
|
|
||||||
public string $form_territorio_id = '';
|
public string $form_territorio_id = '';
|
||||||
public string $form_proclamatore_id = '';
|
public string $form_proclamatore_id = '';
|
||||||
public string $form_anno_id = '';
|
|
||||||
public string $form_assigned_at = '';
|
public string $form_assigned_at = '';
|
||||||
public string $form_returned_at = '';
|
public string $form_returned_at = '';
|
||||||
public bool $form_counted_in_campaign = false;
|
public bool $form_counted_in_campaign = false;
|
||||||
@@ -101,7 +96,6 @@ class Registro extends Component
|
|||||||
$this->editingId = null;
|
$this->editingId = null;
|
||||||
$this->form_territorio_id = '';
|
$this->form_territorio_id = '';
|
||||||
$this->form_proclamatore_id = '';
|
$this->form_proclamatore_id = '';
|
||||||
$this->form_anno_id = '';
|
|
||||||
$this->form_assigned_at = now()->format('Y-m-d');
|
$this->form_assigned_at = now()->format('Y-m-d');
|
||||||
$this->form_returned_at = '';
|
$this->form_returned_at = '';
|
||||||
$this->form_counted_in_campaign = false;
|
$this->form_counted_in_campaign = false;
|
||||||
@@ -118,7 +112,6 @@ class Registro extends Component
|
|||||||
$this->editingId = $id;
|
$this->editingId = $id;
|
||||||
$this->form_territorio_id = (string) $a->territorio_id;
|
$this->form_territorio_id = (string) $a->territorio_id;
|
||||||
$this->form_proclamatore_id = (string) $a->proclamatore_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_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
|
||||||
$this->form_returned_at = $a->returned_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;
|
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
|
||||||
@@ -134,28 +127,54 @@ class Registro extends Component
|
|||||||
$this->validate([
|
$this->validate([
|
||||||
'form_territorio_id' => 'required|exists:territori,id',
|
'form_territorio_id' => 'required|exists:territori,id',
|
||||||
'form_proclamatore_id' => 'required|exists:proclamatori,id',
|
'form_proclamatore_id' => 'required|exists:proclamatori,id',
|
||||||
'form_anno_id' => 'required|exists:anni_teocratici,id',
|
'form_assigned_at' => 'required|date|before_or_equal:today',
|
||||||
'form_assigned_at' => 'required|date',
|
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
|
||||||
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
|
|
||||||
'form_campaign_id' => 'nullable|exists:campagne,id',
|
'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 = [
|
$data = [
|
||||||
'territorio_id' => $this->form_territorio_id,
|
'territorio_id' => $this->form_territorio_id,
|
||||||
'proclamatore_id' => $this->form_proclamatore_id,
|
'proclamatore_id' => $this->form_proclamatore_id,
|
||||||
'anno_teocratico_id' => $this->form_anno_id,
|
'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
|
||||||
'assigned_at' => $this->form_assigned_at,
|
'assigned_at' => $uscita,
|
||||||
'returned_at' => $this->form_returned_at ?: null,
|
'returned_at' => $rientro,
|
||||||
'counted_in_campaign' => $this->form_counted_in_campaign,
|
// "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,
|
'campaign_id' => $this->form_campaign_id ?: null,
|
||||||
'note' => $this->form_note ?: null,
|
'note' => $this->form_note ?: null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->editingId) {
|
$conflitto = DB::transaction(function () use ($data, $uscita, $rientro) {
|
||||||
Assegnazione::findOrFail($this->editingId)->update($data);
|
Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first();
|
||||||
} else {
|
|
||||||
$data['created_by'] = auth()->id();
|
$conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
|
||||||
Assegnazione::create($data);
|
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;
|
$this->showModal = false;
|
||||||
@@ -199,16 +218,15 @@ class Registro extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del
|
* Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
|
||||||
* territorio") compilato con le assegnazioni dell'anno di servizio scelto
|
* modale; le regole di compilazione stanno in ModuloS13.
|
||||||
* nella modale.
|
|
||||||
*
|
*
|
||||||
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo
|
* 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
|
* espone in un unico file l'anagrafica completa di chi ha avuto quale
|
||||||
* territorio, dati personali che il ruolo assegnatario non deve poter
|
* territorio, dati personali che il ruolo assegnatario non deve poter
|
||||||
* esportare in blocco.
|
* esportare in blocco.
|
||||||
*/
|
*/
|
||||||
public function downloadS13()
|
public function downloadS13(ModuloS13 $modulo)
|
||||||
{
|
{
|
||||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||||
|
|
||||||
@@ -224,134 +242,15 @@ class Registro extends Component
|
|||||||
|
|
||||||
$this->showS13Modal = false;
|
$this->showS13Modal = false;
|
||||||
|
|
||||||
$pdf = Pdf::loadView('pdf.registro-s13', [
|
$pdf = $modulo->genera($anno);
|
||||||
'annoLabel' => $anno->label,
|
|
||||||
'pagine' => $this->impaginaS13($this->righeS13($anno)),
|
|
||||||
])->setPaper('a4', 'portrait');
|
|
||||||
|
|
||||||
return response()->streamDownload(
|
return response()->streamDownload(
|
||||||
fn () => print($pdf->output()),
|
fn () => print($pdf),
|
||||||
'S-13-registro-assegnazioni-' . $anno->label . '.pdf'
|
'S-13-registro-assegnazioni-' . $anno->label . '.pdf',
|
||||||
|
['Content-Type' => 'application/pdf']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Impagina le righe in fogli da 21 blocchi, completando l'ultimo con
|
|
||||||
* blocchi vuoti: il foglio stampato resta identico al modulo cartaceo e le
|
|
||||||
* righe libere restano disponibili per la compilazione a mano.
|
|
||||||
*
|
|
||||||
* Senza righe da stampare si restituisce comunque un foglio vuoto, così il
|
|
||||||
* modulo scaricato è un S-13 in bianco anziché un PDF senza pagine.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Support\Collection<int, array> $righe
|
|
||||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array>>
|
|
||||||
*/
|
|
||||||
private function impaginaS13(\Illuminate\Support\Collection $righe): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
$vuota = [
|
|
||||||
'numero' => '',
|
|
||||||
'riporto' => '',
|
|
||||||
'assegnazioni' => $this->completaCelleS13([]),
|
|
||||||
];
|
|
||||||
|
|
||||||
$pagine = $righe->isEmpty()
|
|
||||||
? collect([collect()])
|
|
||||||
: $righe->chunk(self::S13_RIGHE_PER_PAGINA)->map->values()->values();
|
|
||||||
|
|
||||||
return $pagine->map(fn ($pagina) => collect(
|
|
||||||
array_pad($pagina->all(), self::S13_RIGHE_PER_PAGINA, $vuota)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Righe del modulo: un blocco per ogni territorio attivo con almeno
|
|
||||||
* un'assegnazione (chiusa o in corso) nell'anno indicato, in ordine di
|
|
||||||
* numero crescente.
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Support\Collection<int, array>
|
|
||||||
*/
|
|
||||||
private function righeS13(AnnoTeocratico $anno): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
// territorio() è definita withTrashed(), quindi la condizione sui
|
|
||||||
// cestinati va scritta a mano: senza, i territori eliminati
|
|
||||||
// rientrerebbero nel modulo.
|
|
||||||
$assegnazioni = Assegnazione::with(['proclamatore', 'territorio'])
|
|
||||||
->where('anno_teocratico_id', $anno->id)
|
|
||||||
->whereHas('territorio', fn ($q) => $q
|
|
||||||
->where('attivo', true)
|
|
||||||
->whereNull('territori.deleted_at'))
|
|
||||||
->orderBy('assigned_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get()
|
|
||||||
->groupBy('territorio_id');
|
|
||||||
|
|
||||||
return $assegnazioni
|
|
||||||
->map(function ($gruppo) {
|
|
||||||
$gruppo = $gruppo->values();
|
|
||||||
|
|
||||||
// Il ciclo di registrazione è lungo quanto i gruppi "Assegnato
|
|
||||||
// a": riempito il quarto si ricomincia da capo, come sul modulo
|
|
||||||
// cartaceo si aprirebbe una riga nuova per quel territorio.
|
|
||||||
// Cinque assegnazioni non mostrano quindi le ultime quattro, ma
|
|
||||||
// solo la quinta, in prima colonna.
|
|
||||||
$inizioCiclo = intdiv($gruppo->count() - 1, self::S13_ASSEGNAZIONI_PER_RIGA)
|
|
||||||
* self::S13_ASSEGNAZIONI_PER_RIGA;
|
|
||||||
|
|
||||||
$celle = $gruppo
|
|
||||||
->slice($inizioCiclo, self::S13_ASSEGNAZIONI_PER_RIGA)
|
|
||||||
->map(fn (Assegnazione $a) => [
|
|
||||||
'nome' => $a->proclamatore?->nome_completo ?? '',
|
|
||||||
'assegnato_il' => $a->assigned_at?->format('d/m/Y') ?? '',
|
|
||||||
'restituito_il' => $a->returned_at?->format('d/m/Y') ?? '',
|
|
||||||
])->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'numero' => $gruppo->first()->territorio?->numero ?? '',
|
|
||||||
'riporto' => $this->riportoS13($gruppo, $inizioCiclo),
|
|
||||||
'assegnazioni' => $this->completaCelleS13($celle),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->sort(fn (array $a, array $b) => strnatcasecmp((string) $a['numero'], (string) $b['numero']))
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contenuto della colonna "Restituito l'ultima volta il": la data di
|
|
||||||
* rientro dell'ultima assegnazione *prima* che il ciclo ricominciasse,
|
|
||||||
* cioè quella che occupava il quarto gruppo del giro precedente.
|
|
||||||
*
|
|
||||||
* È il riporto che il modulo cartaceo chiede quando si apre una riga nuova
|
|
||||||
* per un territorio già registrato: la riga mostra da dove si riparte,
|
|
||||||
* senza il nome di chi lo aveva (la colonna ha solo la data).
|
|
||||||
*
|
|
||||||
* Finché il primo ciclo non è esaurito — quattro assegnazioni o meno
|
|
||||||
* nell'anno — non c'è niente da riportare e la colonna resta vuota.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Support\Collection<int, Assegnazione> $gruppo
|
|
||||||
*/
|
|
||||||
private function riportoS13(\Illuminate\Support\Collection $gruppo, int $inizioCiclo): string
|
|
||||||
{
|
|
||||||
if ($inizioCiclo === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $gruppo->get($inizioCiclo - 1)?->returned_at?->format('d/m/Y') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Porta le celle a quattro riempiendo con voci vuote: le colonne
|
|
||||||
* inutilizzate restano stampate e a disposizione della compilazione a mano.
|
|
||||||
*
|
|
||||||
* @param array<int, array> $celle
|
|
||||||
* @return array<int, array>
|
|
||||||
*/
|
|
||||||
private function completaCelleS13(array $celle): array
|
|
||||||
{
|
|
||||||
$vuota = ['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''];
|
|
||||||
|
|
||||||
return array_pad($celle, self::S13_ASSEGNAZIONI_PER_RIGA, $vuota);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
if ($this->filtroAnno === '') {
|
if ($this->filtroAnno === '') {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\Assegnazione;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Activitylog\Models\Activity;
|
use Spatie\Activitylog\Models\Activity;
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
@@ -17,6 +18,9 @@ class UsersIndex extends Component
|
|||||||
public string $password = '';
|
public string $password = '';
|
||||||
public string $password_confirmation = '';
|
public string $password_confirmation = '';
|
||||||
public string $selectedRole = '';
|
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 array $availableRoles = [];
|
||||||
|
|
||||||
public ?int $editingUserId = null;
|
public ?int $editingUserId = null;
|
||||||
@@ -115,6 +119,23 @@ class UsersIndex extends Component
|
|||||||
$validated = $this->validate($this->editRules());
|
$validated = $this->validate($this->editRules());
|
||||||
$user = User::query()->findOrFail($this->editingUserId);
|
$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->name = $validated['editName'];
|
||||||
$user->email = $validated['editEmail'];
|
$user->email = $validated['editEmail'];
|
||||||
|
|
||||||
|
|||||||
@@ -241,11 +241,33 @@ class XmlExchange extends Component
|
|||||||
$this->importStats = [];
|
$this->importStats = [];
|
||||||
$this->importIssues = [];
|
$this->importIssues = [];
|
||||||
|
|
||||||
$xml = @simplexml_load_string($content);
|
$xml = $this->leggiXml($content);
|
||||||
if (! $xml) {
|
if (is_string($xml)) {
|
||||||
return ['error' => 'Impossibile leggere il file XML.'];
|
return ['error' => $xml];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
|
||||||
|
// (un sitemap, l'export di un altro programma) lascerebbe
|
||||||
|
// l'installazione vuota con un "completato con successo". Si accetta
|
||||||
|
// solo un file prodotto da questa applicazione, con le sue sezioni.
|
||||||
|
if ($xml->getName() !== 'termanager2') {
|
||||||
|
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <termanager2>. Nessun dato è stato modificato.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mancanti = array_values(array_filter(
|
||||||
|
['territori', 'proclamatori', 'assegnazioni'],
|
||||||
|
fn (string $sezione) => ! isset($xml->{$sezione})
|
||||||
|
));
|
||||||
|
if ($mancanti !== []) {
|
||||||
|
return ['error' => 'Il file è incompleto: mancano le sezioni ' . implode(', ', $mancanti) . '. Nessun dato è stato modificato.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chi esegue l'import (o il ripristino, che imposta l'utente a mano)
|
||||||
|
// non deve uscirne estromesso: il file può contenere il suo account
|
||||||
|
// con una password vecchia o senza il ruolo di amministratore.
|
||||||
|
$attore = auth()->user();
|
||||||
|
$attoreEraAmministratore = $attore?->hasRole('amministratore') ?? false;
|
||||||
|
|
||||||
$actorId = auth()->id() ?? User::query()->value('id');
|
$actorId = auth()->id() ?? User::query()->value('id');
|
||||||
if (! $actorId) {
|
if (! $actorId) {
|
||||||
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
||||||
@@ -264,7 +286,8 @@ class XmlExchange extends Component
|
|||||||
'assegnazioni_saltate' => 0,
|
'assegnazioni_saltate' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
DB::transaction(function () use ($xml, $actorId, &$stats) {
|
try {
|
||||||
|
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||||
Assegnazione::query()->delete();
|
Assegnazione::query()->delete();
|
||||||
Campagna::query()->delete();
|
Campagna::query()->delete();
|
||||||
Territorio::query()->withTrashed()->forceDelete();
|
Territorio::query()->withTrashed()->forceDelete();
|
||||||
@@ -334,6 +357,10 @@ class XmlExchange extends Component
|
|||||||
$roleNames[] = $roleName;
|
$roleNames[] = $roleName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$eAttore = $attore !== null && $user->id === $attore->id;
|
||||||
|
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
|
||||||
|
$roleNames[] = 'amministratore';
|
||||||
|
}
|
||||||
$user->syncRoles($roleNames);
|
$user->syncRoles($roleNames);
|
||||||
|
|
||||||
// Password hash, remember_token, verification flag and
|
// 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
|
// hash is preserved exactly (the 'hashed' cast would re-validate
|
||||||
// the bcrypt cost and could reject a hash made with a different
|
// the bcrypt cost and could reject a hash made with a different
|
||||||
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
|
// 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 = [];
|
$meta = [];
|
||||||
$passwordHash = (string) ($userNode->password ?? '');
|
$passwordHash = (string) ($userNode->password ?? '');
|
||||||
if ($passwordHash !== '') {
|
if ($passwordHash !== '' && ! $eAttore) {
|
||||||
$meta['password'] = $passwordHash;
|
$meta['password'] = $passwordHash;
|
||||||
}
|
}
|
||||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
if (! $eAttore) {
|
||||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
$rememberToken = (string) ($userNode->remember_token ?? '');
|
||||||
|
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
||||||
|
}
|
||||||
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
||||||
|
|
||||||
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
||||||
@@ -415,8 +446,8 @@ class XmlExchange extends Component
|
|||||||
$label = (string) $annoNode->label;
|
$label = (string) $annoNode->label;
|
||||||
$anno = AnnoTeocratico::create([
|
$anno = AnnoTeocratico::create([
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'start_date' => (string) $annoNode->start_date,
|
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
|
||||||
'end_date' => (string) $annoNode->end_date,
|
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
|
||||||
]);
|
]);
|
||||||
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
||||||
$stats['anni_importati']++;
|
$stats['anni_importati']++;
|
||||||
@@ -430,8 +461,8 @@ class XmlExchange extends Component
|
|||||||
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
||||||
$campagna = Campagna::create([
|
$campagna = Campagna::create([
|
||||||
'descrizione' => (string) $campagnaNode->descrizione,
|
'descrizione' => (string) $campagnaNode->descrizione,
|
||||||
'start_date' => (string) $campagnaNode->start_date,
|
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
|
||||||
'end_date' => (string) $campagnaNode->end_date,
|
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
|
||||||
]);
|
]);
|
||||||
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
||||||
$stats['campagne_importate']++;
|
$stats['campagne_importate']++;
|
||||||
@@ -455,9 +486,14 @@ class XmlExchange extends Component
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
|
// Zona e tipologia sono facoltative (le colonne sono nullable e
|
||||||
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
|
// un territorio resta senza quando la sua zona viene
|
||||||
continue;
|
// 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 ?? '');
|
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
|
||||||
@@ -465,8 +501,8 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
$territorio = Territorio::create([
|
$territorio = Territorio::create([
|
||||||
'numero' => $numero,
|
'numero' => $numero,
|
||||||
'zona_id' => $zoneMap[$legacyZonaId],
|
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
|
||||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
|
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
|
||||||
'confini' => (string) ($territorioNode->confini ?? ''),
|
'confini' => (string) ($territorioNode->confini ?? ''),
|
||||||
'note' => (string) ($territorioNode->note ?? ''),
|
'note' => (string) ($territorioNode->note ?? ''),
|
||||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||||
@@ -540,7 +576,15 @@ class XmlExchange extends Component
|
|||||||
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
|
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
|
||||||
$stats['assegnazioni_importate']++;
|
$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 [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
@@ -581,16 +625,75 @@ class XmlExchange extends Component
|
|||||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||||
])->all(),
|
])->all(),
|
||||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
|
||||||
'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(),
|
'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' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||||
'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(),
|
'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<int, string> $colonne
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function righeEsportate(\Illuminate\Database\Eloquent\Builder $query, array $colonne): array
|
||||||
|
{
|
||||||
|
return $query->get($colonne)->map(function (\Illuminate\Database\Eloquent\Model $riga) use ($colonne) {
|
||||||
|
$dati = [];
|
||||||
|
foreach ($colonne as $colonna) {
|
||||||
|
$valore = $riga->getAttribute($colonna);
|
||||||
|
|
||||||
|
if ($valore instanceof \Carbon\CarbonInterface) {
|
||||||
|
$valore = $riga->hasCast($colonna, ['date', 'immutable_date'])
|
||||||
|
? $valore->format('Y-m-d')
|
||||||
|
: $valore->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
$dati[$colonna] = $valore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dati;
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il file come SimpleXMLElement, oppure il messaggio d'errore con riga e
|
||||||
|
* motivo: "impossibile leggere il file" da solo non aiuta a correggerlo.
|
||||||
|
*/
|
||||||
|
private function leggiXml(string $content): \SimpleXMLElement|string
|
||||||
|
{
|
||||||
|
$precedente = libxml_use_internal_errors(true);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = simplexml_load_string($content);
|
||||||
|
if ($xml !== false) {
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errore = libxml_get_errors()[0] ?? null;
|
||||||
|
|
||||||
|
return $errore
|
||||||
|
? sprintf('Impossibile leggere il file XML (riga %d): %s.', $errore->line, trim($errore->message))
|
||||||
|
: 'Impossibile leggere il file XML.';
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($precedente);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function legacySqlToDataset(string $sql): array
|
private function legacySqlToDataset(string $sql): array
|
||||||
{
|
{
|
||||||
$rows = $this->extractInsertRows($sql);
|
$rows = $this->extractInsertRows($sql);
|
||||||
@@ -880,8 +983,12 @@ class XmlExchange extends Component
|
|||||||
return $nullable ? null : null;
|
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 {
|
try {
|
||||||
return Carbon::parse($raw)->toDateString();
|
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return $nullable ? null : null;
|
return $nullable ? null : null;
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1129,13 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
|
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);
|
$child = $parent->addChild($name);
|
||||||
$dom = dom_import_simplexml($child);
|
$dom = dom_import_simplexml($child);
|
||||||
$dom->textContent = $value;
|
$dom->textContent = $value;
|
||||||
@@ -1062,7 +1176,7 @@ class XmlExchange extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return Carbon::parse($raw)->toDateTimeString();
|
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────
|
// ─── Relationships ─────────────────────────────────────────
|
||||||
|
|
||||||
public function territorio()
|
public function territorio()
|
||||||
|
|||||||
@@ -61,9 +61,13 @@ class Territorio extends Model
|
|||||||
|
|
||||||
public function assegnazioneCorrente()
|
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')
|
return $this->hasOne(Assegnazione::class, 'territorio_id')
|
||||||
->whereNull('returned_at')
|
->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
|
||||||
->latestOfMany('assigned_at');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ultimaAssegnazione()
|
public function ultimaAssegnazione()
|
||||||
|
|||||||
329
app/Services/Registro/ModuloS13.php
Normal file
329
app/Services/Registro/ModuloS13.php
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registro;
|
||||||
|
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use setasign\Fpdi\Fpdi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del territorio")
|
||||||
|
* compilato per un anno di servizio.
|
||||||
|
*
|
||||||
|
* Il PDF è il modulo originale (resources/pdf/S-13_I.pdf) importato com'è, con
|
||||||
|
* i dati scritti sopra alle coordinate della sua griglia: il foglio stampato è
|
||||||
|
* quello ufficiale, non una sua ricostruzione. La matrice è salvata in PDF 1.4
|
||||||
|
* senza object stream, l'unico formato che il parser gratuito di FPDI legge;
|
||||||
|
* se il modulo viene aggiornato va riconvertito allo stesso modo (qpdf
|
||||||
|
* --object-streams=disable --force-version=1.4, o pikepdf).
|
||||||
|
*
|
||||||
|
* Le regole di compilazione, come sul cartaceo:
|
||||||
|
*
|
||||||
|
* - Una riga per ogni territorio attivo, anche se nell'anno non è mai uscito:
|
||||||
|
* il registro serve proprio a vedere quali territori restano fermi.
|
||||||
|
*
|
||||||
|
* - Quattro gruppi "Assegnato a" per riga. Alla quinta assegnazione la riga
|
||||||
|
* si cancella: la data di rientro della quarta passa nella colonna
|
||||||
|
* "Restituito l'ultima volta il" e la quinta va nel primo gruppo. Sul foglio
|
||||||
|
* c'è quindi solo il ciclo in corso; lo storico completo resta nel database.
|
||||||
|
*
|
||||||
|
* - Nel primo ciclo dell'anno il riporto è l'ultima restituzione avvenuta
|
||||||
|
* prima dell'inizio dell'anno, come chiede la nota a piè del modulo.
|
||||||
|
*
|
||||||
|
* - Un'assegnazione a cavallo d'anno (uscita prima dell'inizio, rientrata
|
||||||
|
* dopo o ancora fuori) compare su entrambi i fogli: su quello vecchio senza
|
||||||
|
* data di rientro, perché a fine anno il territorio era ancora fuori; su
|
||||||
|
* quello nuovo nel primo gruppo, con la data di assegnazione originale e
|
||||||
|
* quella di rientro.
|
||||||
|
*/
|
||||||
|
class ModuloS13
|
||||||
|
{
|
||||||
|
/** Blocchi-territorio per foglio, come sul modulo. */
|
||||||
|
public const RIGHE_PER_PAGINA = 20;
|
||||||
|
|
||||||
|
/** Gruppi "Assegnato a" per riga. */
|
||||||
|
public const ASSEGNAZIONI_PER_RIGA = 4;
|
||||||
|
|
||||||
|
// Geometria della griglia in mm, misurata sulla matrice. Le y sono il
|
||||||
|
// bordo superiore delle linee: quelle spesse separano i territori, quella
|
||||||
|
// sottile divide la riga dei nomi da quella delle date.
|
||||||
|
|
||||||
|
/** Linea spessa sotto l'intestazione, cioè sopra il primo territorio. */
|
||||||
|
private const Y_PRIMA_RIGA = 49.28;
|
||||||
|
|
||||||
|
/** Distanza fra due territori. */
|
||||||
|
private const PASSO_RIGA = 11.042;
|
||||||
|
|
||||||
|
/** Dalla linea spessa alla sottile che separa nomi e date. */
|
||||||
|
private const Y_META_RIGA = 5.68;
|
||||||
|
|
||||||
|
/** Spessore delle linee spesse (1,56pt) e di quelle sottili (0,48pt). */
|
||||||
|
private const LINEA_SPESSA = 0.55;
|
||||||
|
private const LINEA_SOTTILE = 0.17;
|
||||||
|
|
||||||
|
/** Colonne fisse: [x iniziale, x finale] al netto dei bordi. */
|
||||||
|
private const COL_NUMERO = [12.14, 24.06];
|
||||||
|
private const COL_RIPORTO = [24.23, 46.11];
|
||||||
|
|
||||||
|
/** Inizio di ciascun gruppo "Assegnato a", divisione interna e fine. */
|
||||||
|
private const GRUPPI = [
|
||||||
|
[46.62, 65.41, 84.03],
|
||||||
|
[84.54, 103.05, 121.74],
|
||||||
|
[122.25, 140.69, 159.38],
|
||||||
|
[159.89, 178.40, 196.74],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Riga "Anno di servizio: ____": estremi della linea e sua quota. */
|
||||||
|
private const ANNO = [49.96, 72.74, 34.38];
|
||||||
|
|
||||||
|
/** Corpo massimo e minimo dei nomi: sotto il minimo si tronca. */
|
||||||
|
private const CORPO_NOME_MAX = 8.0;
|
||||||
|
private const CORPO_NOME_MIN = 5.0;
|
||||||
|
|
||||||
|
/** Il PDF compilato, come stringa binaria. */
|
||||||
|
public function genera(AnnoTeocratico $anno): string
|
||||||
|
{
|
||||||
|
$pdf = new Fpdi();
|
||||||
|
$pdf->SetAutoPageBreak(false);
|
||||||
|
$pdf->SetMargins(0, 0, 0);
|
||||||
|
$pdf->setSourceFile(resource_path('pdf/S-13_I.pdf'));
|
||||||
|
$modulo = $pdf->importPage(1);
|
||||||
|
$pdf->SetTextColor(0, 0, 0);
|
||||||
|
|
||||||
|
foreach ($this->pagine($this->righe($anno)) as $righe) {
|
||||||
|
$pdf->AddPage('P', 'A4');
|
||||||
|
$pdf->useTemplate($modulo);
|
||||||
|
|
||||||
|
[$x0, $x1, $y] = self::ANNO;
|
||||||
|
$pdf->SetFont('Helvetica', '', 10);
|
||||||
|
$pdf->SetXY($x0, $y - 4.6);
|
||||||
|
$pdf->Cell($x1 - $x0, 4.4, $this->latin($anno->label), 0, 0, 'C');
|
||||||
|
|
||||||
|
foreach ($righe as $indice => $riga) {
|
||||||
|
$this->scriviRiga($pdf, $indice, $riga);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdf->Output('S');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Righe del modulo: una per territorio attivo, in ordine naturale di
|
||||||
|
* numero ("1", "1 A", "2", "10").
|
||||||
|
*
|
||||||
|
* Ogni riga: ['numero' => string, 'riporto' => string, 'assegnazioni' =>
|
||||||
|
* quattro celle ['nome', 'assegnato_il', 'restituito_il'], le libere vuote].
|
||||||
|
*
|
||||||
|
* @return Collection<int, array>
|
||||||
|
*/
|
||||||
|
public function righe(AnnoTeocratico $anno): Collection
|
||||||
|
{
|
||||||
|
$inizio = $anno->start_date->toDateString();
|
||||||
|
$fine = $anno->end_date->copy()->endOfDay();
|
||||||
|
|
||||||
|
$territori = Territorio::where('attivo', true)->get(['id', 'numero']);
|
||||||
|
|
||||||
|
// Le assegnazioni del foglio, scelte per data e non per
|
||||||
|
// anno_teocratico_id (che una correzione a mano o un import possono
|
||||||
|
// lasciare incoerente): quelle uscite nell'anno, più quelle uscite
|
||||||
|
// prima e ancora fuori al suo inizio. Confronti con stringhe Y-m-d e
|
||||||
|
// solo con < e >=: valgono sia sulle colonne DATE di MariaDB sia sulle
|
||||||
|
// "Y-m-d H:i:s" di SQLite, e usano l'indice (whereDate non lo userebbe).
|
||||||
|
$assegnazioni = Assegnazione::with('proclamatore')
|
||||||
|
->whereIn('territorio_id', $territori->pluck('id'))
|
||||||
|
->where('assigned_at', '<', $anno->end_date->copy()->addDay()->toDateString())
|
||||||
|
->where(fn ($q) => $q
|
||||||
|
->where('assigned_at', '>=', $inizio)
|
||||||
|
->orWhereNull('returned_at')
|
||||||
|
->orWhere('returned_at', '>=', $inizio))
|
||||||
|
->orderBy('assigned_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->groupBy('territorio_id');
|
||||||
|
|
||||||
|
// Riporto del primo ciclo: l'ultima restituzione prima dell'anno.
|
||||||
|
$rientriPrecedenti = Assegnazione::query()
|
||||||
|
->whereIn('territorio_id', $territori->pluck('id'))
|
||||||
|
->where('returned_at', '<', $inizio)
|
||||||
|
->groupBy('territorio_id')
|
||||||
|
->selectRaw('territorio_id, MAX(returned_at) as ultimo')
|
||||||
|
->pluck('ultimo', 'territorio_id');
|
||||||
|
|
||||||
|
return $territori
|
||||||
|
->map(function (Territorio $territorio) use ($assegnazioni, $rientriPrecedenti, $fine) {
|
||||||
|
$gruppo = ($assegnazioni->get($territorio->id) ?? collect())->values();
|
||||||
|
|
||||||
|
$inizioCiclo = $gruppo->isEmpty()
|
||||||
|
? 0
|
||||||
|
: intdiv($gruppo->count() - 1, self::ASSEGNAZIONI_PER_RIGA) * self::ASSEGNAZIONI_PER_RIGA;
|
||||||
|
|
||||||
|
$riporto = $inizioCiclo === 0
|
||||||
|
? $this->data($rientriPrecedenti->get($territorio->id))
|
||||||
|
: $this->rientroEntro($gruppo->get($inizioCiclo - 1), $fine);
|
||||||
|
|
||||||
|
$celle = $gruppo
|
||||||
|
->slice($inizioCiclo, self::ASSEGNAZIONI_PER_RIGA)
|
||||||
|
->map(fn (Assegnazione $a) => [
|
||||||
|
'nome' => $this->nomeBreve($a->proclamatore),
|
||||||
|
'assegnato_il' => $this->data($a->assigned_at),
|
||||||
|
'restituito_il' => $this->rientroEntro($a, $fine),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'numero' => (string) $territorio->numero,
|
||||||
|
'riporto' => $riporto,
|
||||||
|
'assegnazioni' => $this->completaCelle($celle),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(fn (array $a, array $b) => strnatcasecmp($a['numero'], $b['numero']))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fogli da venti righe, l'ultimo completato con righe vuote: sul modulo le
|
||||||
|
* righe libere restano disponibili per la compilazione a mano. Senza
|
||||||
|
* territori esce comunque un foglio, in bianco.
|
||||||
|
*
|
||||||
|
* @return Collection<int, Collection<int, array>>
|
||||||
|
*/
|
||||||
|
public function pagine(Collection $righe): Collection
|
||||||
|
{
|
||||||
|
$vuota = ['numero' => '', 'riporto' => '', 'assegnazioni' => $this->completaCelle([])];
|
||||||
|
|
||||||
|
$pagine = $righe->isEmpty()
|
||||||
|
? collect([collect()])
|
||||||
|
: $righe->chunk(self::RIGHE_PER_PAGINA)->map->values()->values();
|
||||||
|
|
||||||
|
return $pagine->map(fn (Collection $pagina) => collect(
|
||||||
|
array_pad($pagina->all(), self::RIGHE_PER_PAGINA, $vuota)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scriviRiga(Fpdi $pdf, int $indice, array $riga): void
|
||||||
|
{
|
||||||
|
$alto = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::LINEA_SPESSA;
|
||||||
|
$meta = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::Y_META_RIGA;
|
||||||
|
$basso = self::Y_PRIMA_RIGA + ($indice + 1) * self::PASSO_RIGA;
|
||||||
|
|
||||||
|
// Numero e riporto occupano il blocco intero, nomi e date mezzo.
|
||||||
|
$pdf->SetFont('Helvetica', 'B', 10);
|
||||||
|
$this->cella($pdf, self::COL_NUMERO, $alto, $basso, $riga['numero']);
|
||||||
|
|
||||||
|
$pdf->SetFont('Helvetica', '', 8);
|
||||||
|
$this->cella($pdf, self::COL_RIPORTO, $alto, $basso, $riga['riporto']);
|
||||||
|
|
||||||
|
foreach ($riga['assegnazioni'] as $g => $a) {
|
||||||
|
[$inizio, $divisione, $fine] = self::GRUPPI[$g];
|
||||||
|
|
||||||
|
$this->scriviNome($pdf, [$inizio, $fine], $alto, $meta, $a['nome']);
|
||||||
|
|
||||||
|
$pdf->SetFont('Helvetica', '', 7.5);
|
||||||
|
$this->cella($pdf, [$inizio, $divisione], $meta + self::LINEA_SOTTILE, $basso, $a['assegnato_il']);
|
||||||
|
$this->cella($pdf, [$divisione, $fine], $meta + self::LINEA_SOTTILE, $basso, $a['restituito_il']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I nomi stanno su una riga sola: il corpo scende finché il nome entra
|
||||||
|
* nella cella, e sotto il minimo leggibile si tronca con i puntini, che
|
||||||
|
* rendono visibile l'accorciamento invece di sbordare sulla griglia.
|
||||||
|
*/
|
||||||
|
private function scriviNome(Fpdi $pdf, array $colonna, float $alto, float $basso, string $nome): void
|
||||||
|
{
|
||||||
|
if ($nome === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$testo = $this->latin($nome);
|
||||||
|
$spazio = $colonna[1] - $colonna[0] - 1.2;
|
||||||
|
|
||||||
|
$pdf->SetFont('Helvetica', '', self::CORPO_NOME_MAX);
|
||||||
|
$larghezza = $pdf->GetStringWidth($testo);
|
||||||
|
|
||||||
|
if ($larghezza > $spazio) {
|
||||||
|
$corpo = max(self::CORPO_NOME_MIN, floor(self::CORPO_NOME_MAX * $spazio / $larghezza * 10) / 10);
|
||||||
|
$pdf->SetFont('Helvetica', '', $corpo);
|
||||||
|
|
||||||
|
while ($pdf->GetStringWidth($testo) > $spazio && strlen($testo) > 1) {
|
||||||
|
$testo = rtrim(substr(rtrim($testo, "\x85"), 0, -1)) . "\x85";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cella($pdf, $colonna, $alto, $basso, $testo, giaConvertito: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Testo centrato in un rettangolo della griglia. */
|
||||||
|
private function cella(Fpdi $pdf, array $colonna, float $alto, float $basso, string $testo, bool $giaConvertito = false): void
|
||||||
|
{
|
||||||
|
if ($testo === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf->SetXY($colonna[0], $alto);
|
||||||
|
$pdf->Cell(
|
||||||
|
$colonna[1] - $colonna[0],
|
||||||
|
$basso - $alto,
|
||||||
|
$giaConvertito ? $testo : $this->latin($testo),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'C'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data di rientro così come la si leggeva alla fine dell'anno: se il
|
||||||
|
* territorio è rientrato dopo, sul foglio di quell'anno risulta ancora
|
||||||
|
* fuori.
|
||||||
|
*/
|
||||||
|
private function rientroEntro(?Assegnazione $assegnazione, CarbonInterface $fine): string
|
||||||
|
{
|
||||||
|
$rientro = $assegnazione?->returned_at;
|
||||||
|
|
||||||
|
return $rientro && $rientro->lte($fine) ? $this->data($rientro) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cognome e iniziale del nome ("Rossi M."): la cella è stretta, e col nome
|
||||||
|
* per intero i nomi lunghi scendevano a un corpo che su carta non si legge.
|
||||||
|
*/
|
||||||
|
private function nomeBreve(?Proclamatore $proclamatore): string
|
||||||
|
{
|
||||||
|
if (! $proclamatore) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$nome = trim((string) $proclamatore->nome);
|
||||||
|
$iniziale = $nome !== '' ? mb_strtoupper(mb_substr($nome, 0, 1)) . '.' : '';
|
||||||
|
|
||||||
|
return trim(trim((string) $proclamatore->cognome) . ' ' . $iniziale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function data(mixed $valore): string
|
||||||
|
{
|
||||||
|
if (! $valore) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($valore instanceof CarbonInterface ? $valore : \Illuminate\Support\Carbon::parse($valore))
|
||||||
|
->format('d/m/Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completaCelle(array $celle): array
|
||||||
|
{
|
||||||
|
return array_pad($celle, self::ASSEGNAZIONI_PER_RIGA, ['nome' => '', 'assegnato_il' => '', 'restituito_il' => '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FPDF ragiona in windows-1252: gli accenti UTF-8 vanno convertiti. */
|
||||||
|
private function latin(string $testo): string
|
||||||
|
{
|
||||||
|
$convertito = @iconv('UTF-8', 'windows-1252//TRANSLIT', $testo);
|
||||||
|
|
||||||
|
return $convertito !== false ? $convertito : $testo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,21 @@ ok "Storage directories ready."
|
|||||||
KEY_FILE="/var/www/html/storage/app/.app_key"
|
KEY_FILE="/var/www/html/storage/app/.app_key"
|
||||||
|
|
||||||
if [ -n "${APP_KEY:-}" ]; then
|
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."
|
ok "APP_KEY provided via environment."
|
||||||
printf '%s' "$APP_KEY" > "$KEY_FILE"
|
printf '%s' "$APP_KEY" > "$KEY_FILE"
|
||||||
elif [ -s "$KEY_FILE" ]; then
|
elif [ -s "$KEY_FILE" ]; then
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@
|
|||||||
Nuova voce
|
Nuova voce
|
||||||
</x-btn>
|
</x-btn>
|
||||||
<x-btn variante="secondary" wire:click="openS13"
|
<x-btn variante="secondary" wire:click="openS13"
|
||||||
title="Scarica il modulo S-13 compilato con le ultime 4 assegnazioni di un anno di servizio">
|
title="Scarica il modulo S-13 compilato per un anno di servizio">
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
||||||
Modulo S-13
|
Modulo S-13
|
||||||
</x-btn>
|
</x-btn>
|
||||||
@@ -210,18 +210,8 @@
|
|||||||
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Anno teocratico --}}
|
{{-- L'anno di servizio non si sceglie: lo ricava il salvataggio
|
||||||
<div>
|
dalla data di assegnazione. --}}
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Anno teocratico *</label>
|
|
||||||
<select wire:model="form_anno_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
|
||||||
<option value="">-- seleziona --</option>
|
|
||||||
@foreach($anni as $anno)
|
|
||||||
<option value="{{ $anno->id }}">{{ $anno->label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@error('form_anno_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Campagna --}}
|
{{-- Campagna --}}
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Campagna</label>
|
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Campagna</label>
|
||||||
|
|||||||
@@ -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 <colgroup>
|
|
||||||
(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
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="it">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>S-13 — Registrazione delle assegnazioni del territorio</title>
|
|
||||||
<style>
|
|
||||||
/* dompdf riporta i margini di @page sull'elemento body, quindi il
|
|
||||||
reset universale qui sotto li azzererebbe: vanno ridichiarati su
|
|
||||||
body, che per specificità vince su "*". Tenere i due valori
|
|
||||||
allineati — è @page a definire l'area utile, body a farla
|
|
||||||
sopravvivere al reset.
|
|
||||||
I margini laterali sono i più larghi perché sono quelli che si
|
|
||||||
vedono; in verticale si sta stretti, il foglio pieno misura ~266mm
|
|
||||||
e ne restano 273. Alzando questi valori, o gli spazi sotto il
|
|
||||||
titolo e sopra il codice, il piè di pagina scivola su una pagina
|
|
||||||
nuova e ogni foglio ne occupa due: rimisurare dopo ogni modifica. */
|
|
||||||
@page { margin: 14mm 16mm 10mm 16mm; }
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 14mm 16mm 10mm 16mm;
|
|
||||||
font-family: "DejaVu Sans", sans-serif;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interrompi { page-break-after: always; }
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 13pt;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.2pt;
|
|
||||||
margin-bottom: 6mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anno {
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 3mm;
|
|
||||||
}
|
|
||||||
.anno .valore {
|
|
||||||
display: inline-block;
|
|
||||||
width: 32mm;
|
|
||||||
border-bottom: 1px solid #000;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: normal;
|
|
||||||
padding-bottom: 0.4mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: 1.6pt solid #000;
|
|
||||||
/* Obbligatorio, non cosmetico: in layout automatico le larghezze
|
|
||||||
del <colgroup> sono solo un suggerimento e una cella con del
|
|
||||||
testo lungo (un nome di proclamatore) si allarga schiacciando le
|
|
||||||
altre. I corpi dei nomi sono calcolati su larghezze fisse,
|
|
||||||
quindi a quel punto il testo deborda. */
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
td, th {
|
|
||||||
border: 0.5pt solid #000;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Intestazione */
|
|
||||||
thead th { font-weight: normal; padding: 0.5mm 0.5mm; }
|
|
||||||
th.terr { font-size: 7.5pt; line-height: 1.15; }
|
|
||||||
/* 6.2pt e non di più: a 6.6pt "l'ultima volta il*" non entra nei
|
|
||||||
~21mm utili della colonna e l'intestazione passa da due righe a tre,
|
|
||||||
allontanandosi dal modulo originale. */
|
|
||||||
th.restituito { font-size: 6.2pt; line-height: 1.15; white-space: nowrap; }
|
|
||||||
th.assegnato-a { font-size: 8pt; }
|
|
||||||
th.sotto { font-size: 6.4pt; }
|
|
||||||
|
|
||||||
/* Corpo */
|
|
||||||
/* 9.4mm a blocco: 21 blocchi devono stare in un foglio come sul modulo
|
|
||||||
cartaceo. Alzando queste altezze l'ultimo territorio scivola su una
|
|
||||||
pagina nuova e ogni foglio ne occupa due. */
|
|
||||||
tr.riga-nome td { height: 5.6mm; }
|
|
||||||
tr.riga-date td { height: 3.8mm; }
|
|
||||||
|
|
||||||
td.numero { font-size: 9.5pt; font-weight: bold; }
|
|
||||||
td.data { font-size: 7.5pt; }
|
|
||||||
|
|
||||||
/* La colonna del riporto è alta due righe e ospita solo la data di
|
|
||||||
rientro con cui si chiudeva il ciclo precedente. */
|
|
||||||
td.riporto { font-size: 7.5pt; padding: 0 0.5mm; }
|
|
||||||
td.nome { line-height: 1.1; padding: 0 0.6mm; white-space: nowrap; }
|
|
||||||
|
|
||||||
.nota { font-size: 7.4pt; margin-top: 1.6mm; }
|
|
||||||
.codice { font-size: 8pt; margin-top: 3mm; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
@foreach($pagine as $indice => $righe)
|
|
||||||
<div @if($indice < $ultimaPagina) class="interrompi" @endif>
|
|
||||||
<h1>REGISTRAZIONE DELLE ASSEGNAZIONI DEL TERRITORIO</h1>
|
|
||||||
|
|
||||||
<div class="anno">Anno di servizio: <span class="valore">{{ $annoLabel }}</span></div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<colgroup>
|
|
||||||
<col style="width:6.5%">
|
|
||||||
<col style="width:12.5%">
|
|
||||||
@for($c = 0; $c < 8; $c++)
|
|
||||||
<col style="width:10.125%">
|
|
||||||
@endfor
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
{{-- Le larghezze stanno sia nel <colgroup> 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. --}}
|
|
||||||
<tr>
|
|
||||||
<th class="terr" rowspan="2" style="width:6.5%">Terr.<br>N.</th>
|
|
||||||
<th class="restituito" rowspan="2" style="width:12.5%">Restituito<br>l’ultima volta il*</th>
|
|
||||||
@for($g = 0; $g < 4; $g++)
|
|
||||||
<th class="assegnato-a" colspan="2" style="width:20.25%">Assegnato a</th>
|
|
||||||
@endfor
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
@for($g = 0; $g < 4; $g++)
|
|
||||||
<th class="sotto">Assegnato il</th>
|
|
||||||
<th class="sotto">Restituito il</th>
|
|
||||||
@endfor
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach($righe as $riga)
|
|
||||||
<tr class="riga-nome">
|
|
||||||
<td class="numero" rowspan="2">{{ $riga['numero'] }}</td>
|
|
||||||
<td class="riporto" rowspan="2">{{ $riga['riporto'] }}</td>
|
|
||||||
@foreach($riga['assegnazioni'] as $a)
|
|
||||||
@php($nome = $troncaPer($a['nome'], $LARGHEZZA_NOME_MM))
|
|
||||||
<td class="nome" colspan="2" style="font-size:{{ $corpoPer($nome, $LARGHEZZA_NOME_MM, 8) }}">{{ $nome }}</td>
|
|
||||||
@endforeach
|
|
||||||
</tr>
|
|
||||||
<tr class="riga-date">
|
|
||||||
@foreach($riga['assegnazioni'] as $a)
|
|
||||||
<td class="data">{{ $a['assegnato_il'] }}</td>
|
|
||||||
<td class="data">{{ $a['restituito_il'] }}</td>
|
|
||||||
@endforeach
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="nota">*Quando si inizia un nuovo foglio, registrare in questa colonna l’ultima data in cui è stato restituito ogni territorio.</div>
|
|
||||||
<div class="codice">S-13-I 1/22</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
211
tests/Feature/AssegnazioneSovrapposizioneTest.php
Normal file
211
tests/Feature/AssegnazioneSovrapposizioneTest.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Assegnazioni\Assegna;
|
||||||
|
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 Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un territorio è fuori al massimo una volta: nessuna doppia assegnazione
|
||||||
|
* aperta, nessuna uscita retrodatata a prima dell'ultimo rientro, nessun
|
||||||
|
* periodo accavallato dalle correzioni nel Registro.
|
||||||
|
*/
|
||||||
|
class AssegnazioneSovrapposizioneTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
private Territorio $territorio;
|
||||||
|
private Proclamatore $proclamatore;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Carbon::setTestNow('2026-10-09 10:00:00');
|
||||||
|
|
||||||
|
foreach (['settings.manage', 'territori.assign', 'territori.return', 'registro.view'] as $permesso) {
|
||||||
|
Permission::create(['name' => $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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,27 +2,23 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Livewire\Registro;
|
|
||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use App\Services\Registro\ModuloS13;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use ReflectionMethod;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a" e la colonna
|
* Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a", la colonna
|
||||||
* "Restituito l'ultima volta il".
|
* "Restituito l'ultima volta il" e le assegnazioni a cavallo d'anno.
|
||||||
*
|
*
|
||||||
* E' la regola meno intuitiva del Registro — la quinta assegnazione non si
|
* E' la regola meno intuitiva del Registro — la quinta assegnazione non si
|
||||||
* aggiunge alle quattro precedenti, le sostituisce — e finisce su un modulo che
|
* aggiunge alle quattro precedenti, le sostituisce — e finisce su un modulo che
|
||||||
* viene firmato e archiviato: se sbaglia, sbaglia su carta.
|
* 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
|
class RegistroS13Test extends TestCase
|
||||||
{
|
{
|
||||||
@@ -68,7 +64,7 @@ class RegistroS13Test extends TestCase
|
|||||||
$this->assertSame('', $riga['riporto']);
|
$this->assertSame('', $riga['riporto']);
|
||||||
$this->assertCount(4, $riga['assegnazioni']);
|
$this->assertCount(4, $riga['assegnazioni']);
|
||||||
$this->assertSame(
|
$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]
|
$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
|
public function test_un_assegnazione_ancora_aperta_ha_la_data_di_rientro_vuota(): void
|
||||||
{
|
{
|
||||||
$territorio = $this->territorio('1');
|
$territorio = $this->territorio('1');
|
||||||
@@ -255,17 +264,11 @@ class RegistroS13Test extends TestCase
|
|||||||
$this->assertSame(['1'], $this->righe()->pluck('numero')->all());
|
$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');
|
$territorio = $this->territorio('1');
|
||||||
$this->assegna($territorio, '2025-09-01', '2025-09-20');
|
$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();
|
$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->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
|
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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
tests/Feature/UltimoAmministratoreTest.php
Normal file
78
tests/Feature/UltimoAmministratoreTest.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Settings\UsersIndex;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/** L'installazione non deve mai restare senza un amministratore. */
|
||||||
|
class UltimoAmministratoreTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Role::create(['name' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
216
tests/Feature/XmlExchangeImportTest.php
Normal file
216
tests/Feature/XmlExchangeImportTest.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Campagna;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Zona;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export e import XML: lo stesso percorso del data.xml dei backup, quindi
|
||||||
|
* ogni difetto qui è un difetto del ripristino.
|
||||||
|
*
|
||||||
|
* L'applicazione gira su Europe/Rome (default di config/app.php, nessun
|
||||||
|
* override in phpunit.xml): è la condizione in cui le date slittavano.
|
||||||
|
*/
|
||||||
|
class XmlExchangeImportTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Role::create(['name' => '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: '<anno legacy_id="1"><label>2025-2026</label><start_date>2025-08-31T22:00:00.000000Z</start_date><end_date>2026-08-30T22:00:00.000000Z</end_date></anno>',
|
||||||
|
assegnazioni: '<assegnazione legacy_id="1"><legacy_territorio_id>1</legacy_territorio_id><legacy_proclamatore_id>1</legacy_proclamatore_id><legacy_anno_id>1</legacy_anno_id><assigned_at>2026-03-14T23:00:00.000000Z</assigned_at><returned_at></returned_at></assegnazione>',
|
||||||
|
);
|
||||||
|
|
||||||
|
$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('<?xml version="1.0"?><urlset><url><loc>https://example.org</loc></url></urlset>');
|
||||||
|
|
||||||
|
$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('<?xml version="1.0"?><termanager2><settings/></termanager2>');
|
||||||
|
|
||||||
|
$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("<?xml version=\"1.0\"?>\n<termanager2>\n<territori>\n</termanager2>");
|
||||||
|
|
||||||
|
$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: '<user legacy_id="9"><name>Amministratore</name><email>admin@example.test</email><password>' . $hashVecchio . '</password><roles></roles></user>'
|
||||||
|
. '<user legacy_id="10"><name>Altro</name><email>altro@example.test</email><password>' . $hashVecchio . '</password><roles><role>assistente</role></roles></user>',
|
||||||
|
);
|
||||||
|
|
||||||
|
$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 '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
|
||||||
|
. '<settings/><users>' . $utenti . '</users><zones/><tipologie/>'
|
||||||
|
. '<proclamatori><proclamatore legacy_id="1"><nome>Mario</nome><cognome>Rossi</cognome><attivo>1</attivo></proclamatore></proclamatori>'
|
||||||
|
. '<territori><territorio legacy_id="1"><numero>1</numero><legacy_zona_id></legacy_zona_id><legacy_tipologia_id></legacy_tipologia_id><attivo>1</attivo></territorio></territori>'
|
||||||
|
. '<anni_teocratici>' . $anni . '</anni_teocratici><campagne/>'
|
||||||
|
. '<assegnazioni>' . $assegnazioni . '</assegnazioni>'
|
||||||
|
. '</termanager2>';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user