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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user