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:
2026-10-09 16:35:51 +02:00
parent 9c37c1ad7b
commit d433c9392d
17 changed files with 1361 additions and 457 deletions

View File

@@ -11,18 +11,14 @@ use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
use Barryvdh\DomPDF\Facade\Pdf;
use App\Services\Registro\ModuloS13;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class Registro extends Component
{
use WithPagination;
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */
private const S13_RIGHE_PER_PAGINA = 21;
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
public string $search = '';
public string $filtroAnno = '';
public string $filtroZona = '';
@@ -37,7 +33,6 @@ class Registro extends Component
public string $form_territorio_id = '';
public string $form_proclamatore_id = '';
public string $form_anno_id = '';
public string $form_assigned_at = '';
public string $form_returned_at = '';
public bool $form_counted_in_campaign = false;
@@ -101,7 +96,6 @@ class Registro extends Component
$this->editingId = null;
$this->form_territorio_id = '';
$this->form_proclamatore_id = '';
$this->form_anno_id = '';
$this->form_assigned_at = now()->format('Y-m-d');
$this->form_returned_at = '';
$this->form_counted_in_campaign = false;
@@ -118,7 +112,6 @@ class Registro extends Component
$this->editingId = $id;
$this->form_territorio_id = (string) $a->territorio_id;
$this->form_proclamatore_id = (string) $a->proclamatore_id;
$this->form_anno_id = (string) $a->anno_teocratico_id;
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
@@ -134,28 +127,54 @@ class Registro extends Component
$this->validate([
'form_territorio_id' => 'required|exists:territori,id',
'form_proclamatore_id' => 'required|exists:proclamatori,id',
'form_anno_id' => 'required|exists:anni_teocratici,id',
'form_assigned_at' => 'required|date',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
'form_assigned_at' => 'required|date|before_or_equal:today',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
'form_campaign_id' => 'nullable|exists:campagne,id',
], [
'form_assigned_at.before_or_equal' => 'La data di assegnazione non può essere futura.',
'form_returned_at.before_or_equal' => 'La data di rientro non può essere futura.',
'form_returned_at.after_or_equal' => 'Il rientro non può precedere l\'assegnazione.',
]);
$uscita = Carbon::parse($this->form_assigned_at);
$rientro = $this->form_returned_at ? Carbon::parse($this->form_returned_at) : null;
// L'anno di servizio non si sceglie: è quello della data di uscita.
// Scelto a mano poteva restare incoerente con la data, e l'assegnazione
// finiva contata nell'anno sbagliato da Home, filtri e modulo S-13.
$data = [
'territorio_id' => $this->form_territorio_id,
'proclamatore_id' => $this->form_proclamatore_id,
'anno_teocratico_id' => $this->form_anno_id,
'assigned_at' => $this->form_assigned_at,
'returned_at' => $this->form_returned_at ?: null,
'counted_in_campaign' => $this->form_counted_in_campaign,
'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
'assigned_at' => $uscita,
'returned_at' => $rientro,
// "Contata nella campagna" senza campagna non ha significato.
'counted_in_campaign' => $this->form_counted_in_campaign && $this->form_campaign_id !== '',
'campaign_id' => $this->form_campaign_id ?: null,
'note' => $this->form_note ?: null,
];
if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data);
} else {
$data['created_by'] = auth()->id();
Assegnazione::create($data);
$conflitto = DB::transaction(function () use ($data, $uscita, $rientro) {
Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first();
$conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
if ($conflitto) {
return $conflitto;
}
if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data);
} else {
Assegnazione::create($data + ['created_by' => auth()->id()]);
}
return null;
});
if ($conflitto) {
$this->addError('form_assigned_at', "Il periodo si sovrappone all'assegnazione {$conflitto->descrizionePeriodo()}.");
return;
}
$this->showModal = false;
@@ -199,16 +218,15 @@ class Registro extends Component
}
/**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del
* territorio") compilato con le assegnazioni dell'anno di servizio scelto
* nella modale.
* Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
* modale; le regole di compilazione stanno in ModuloS13.
*
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo
* espone in un unico file l'anagrafica completa di chi ha avuto quale
* territorio, dati personali che il ruolo assegnatario non deve poter
* esportare in blocco.
*/
public function downloadS13()
public function downloadS13(ModuloS13 $modulo)
{
abort_if(!auth()->user()->can('settings.manage'), 403);
@@ -224,134 +242,15 @@ class Registro extends Component
$this->showS13Modal = false;
$pdf = Pdf::loadView('pdf.registro-s13', [
'annoLabel' => $anno->label,
'pagine' => $this->impaginaS13($this->righeS13($anno)),
])->setPaper('a4', 'portrait');
$pdf = $modulo->genera($anno);
return response()->streamDownload(
fn () => print($pdf->output()),
'S-13-registro-assegnazioni-' . $anno->label . '.pdf'
fn () => print($pdf),
'S-13-registro-assegnazioni-' . $anno->label . '.pdf',
['Content-Type' => 'application/pdf']
);
}
/**
* Impagina le righe in fogli da 21 blocchi, completando l'ultimo con
* blocchi vuoti: il foglio stampato resta identico al modulo cartaceo e le
* righe libere restano disponibili per la compilazione a mano.
*
* Senza righe da stampare si restituisce comunque un foglio vuoto, così il
* modulo scaricato è un S-13 in bianco anziché un PDF senza pagine.
*
* @param \Illuminate\Support\Collection<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
{
if ($this->filtroAnno === '') {