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:
@@ -8,6 +8,7 @@ use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Assegna extends Component
|
||||
@@ -43,34 +44,58 @@ class Assegna extends Component
|
||||
|
||||
$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) {
|
||||
session()->flash('error', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
|
||||
$this->addError('territorio_id', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check territory is active
|
||||
if (!$territorio->attivo) {
|
||||
session()->flash('error', "Il territorio {$territorio->numero} è inattivo.");
|
||||
$this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
|
||||
return;
|
||||
}
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||
if (!$proclamatore->attivo) {
|
||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||
return;
|
||||
}
|
||||
|
||||
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
|
||||
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
|
||||
|
||||
$assegnazione = Assegnazione::create([
|
||||
'territorio_id' => $this->territorio_id,
|
||||
'proclamatore_id' => $this->proclamatore_id,
|
||||
'anno_teocratico_id' => $annoTeocratico->id,
|
||||
'assigned_at' => $assignedDate,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
// Controllo e inserimento sotto lo stesso lock sulla riga del
|
||||
// territorio: un doppio clic o due schede aperte non possono più
|
||||
// superare entrambi il controllo e creare due assegnazioni aperte.
|
||||
$esito = DB::transaction(function () use ($territorio, $assignedDate, $annoTeocratico) {
|
||||
Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||
|
||||
$conflitto = Assegnazione::sovrappostaA($territorio->id, $assignedDate);
|
||||
if ($conflitto) {
|
||||
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}: "
|
||||
. ($conflitto->returned_at
|
||||
? 'la data di assegnazione non può essere precedente al suo rientro.'
|
||||
: 'registrane prima il rientro.');
|
||||
}
|
||||
|
||||
return Assegnazione::create([
|
||||
'territorio_id' => $this->territorio_id,
|
||||
'proclamatore_id' => $this->proclamatore_id,
|
||||
'anno_teocratico_id' => $annoTeocratico->id,
|
||||
'assigned_at' => $assignedDate,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
});
|
||||
|
||||
if (is_string($esito)) {
|
||||
$this->addError('assigned_at', $esito);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$assegnazione = $esito;
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($assegnazione)
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Assegnazione;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Permission\Models\Role;
|
||||
@@ -17,6 +18,9 @@ class UsersIndex extends Component
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
public string $selectedRole = '';
|
||||
// Locked: è la lista che le regole di validazione usano per accettare un
|
||||
// ruolo; modificabile dal client, ammetterebbe qualsiasi nome.
|
||||
#[Locked]
|
||||
public array $availableRoles = [];
|
||||
|
||||
public ?int $editingUserId = null;
|
||||
@@ -115,6 +119,23 @@ class UsersIndex extends Component
|
||||
$validated = $this->validate($this->editRules());
|
||||
$user = User::query()->findOrFail($this->editingUserId);
|
||||
|
||||
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
||||
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
||||
// utenti, impostazioni e backup.
|
||||
if ($user->hasRole('amministratore') && $validated['editSelectedRole'] !== 'amministratore') {
|
||||
if ($user->id === auth()->id()) {
|
||||
$this->addError('editSelectedRole', 'Non puoi togliere a te stesso il ruolo di amministratore: chiedilo a un altro amministratore.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (User::role('amministratore')->count() <= 1) {
|
||||
$this->addError('editSelectedRole', "È l'ultimo amministratore: assegna prima il ruolo a un altro utente.");
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$user->name = $validated['editName'];
|
||||
$user->email = $validated['editEmail'];
|
||||
|
||||
|
||||
@@ -241,11 +241,33 @@ class XmlExchange extends Component
|
||||
$this->importStats = [];
|
||||
$this->importIssues = [];
|
||||
|
||||
$xml = @simplexml_load_string($content);
|
||||
if (! $xml) {
|
||||
return ['error' => 'Impossibile leggere il file XML.'];
|
||||
$xml = $this->leggiXml($content);
|
||||
if (is_string($xml)) {
|
||||
return ['error' => $xml];
|
||||
}
|
||||
|
||||
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
|
||||
// (un sitemap, l'export di un altro programma) lascerebbe
|
||||
// l'installazione vuota con un "completato con successo". Si accetta
|
||||
// solo un file prodotto da questa applicazione, con le sue sezioni.
|
||||
if ($xml->getName() !== 'termanager2') {
|
||||
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <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');
|
||||
if (! $actorId) {
|
||||
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
||||
@@ -264,7 +286,8 @@ class XmlExchange extends Component
|
||||
'assegnazioni_saltate' => 0,
|
||||
];
|
||||
|
||||
DB::transaction(function () use ($xml, $actorId, &$stats) {
|
||||
try {
|
||||
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||
Assegnazione::query()->delete();
|
||||
Campagna::query()->delete();
|
||||
Territorio::query()->withTrashed()->forceDelete();
|
||||
@@ -334,6 +357,10 @@ class XmlExchange extends Component
|
||||
$roleNames[] = $roleName;
|
||||
}
|
||||
}
|
||||
$eAttore = $attore !== null && $user->id === $attore->id;
|
||||
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
|
||||
$roleNames[] = 'amministratore';
|
||||
}
|
||||
$user->syncRoles($roleNames);
|
||||
|
||||
// Password hash, remember_token, verification flag and
|
||||
@@ -341,13 +368,17 @@ class XmlExchange extends Component
|
||||
// hash is preserved exactly (the 'hashed' cast would re-validate
|
||||
// the bcrypt cost and could reject a hash made with a different
|
||||
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
|
||||
// Per chi sta importando password e token restano i suoi:
|
||||
// quelli del file possono essere di mesi fa.
|
||||
$meta = [];
|
||||
$passwordHash = (string) ($userNode->password ?? '');
|
||||
if ($passwordHash !== '') {
|
||||
if ($passwordHash !== '' && ! $eAttore) {
|
||||
$meta['password'] = $passwordHash;
|
||||
}
|
||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
||||
if (! $eAttore) {
|
||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
||||
}
|
||||
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
||||
|
||||
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
||||
@@ -415,8 +446,8 @@ class XmlExchange extends Component
|
||||
$label = (string) $annoNode->label;
|
||||
$anno = AnnoTeocratico::create([
|
||||
'label' => $label,
|
||||
'start_date' => (string) $annoNode->start_date,
|
||||
'end_date' => (string) $annoNode->end_date,
|
||||
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
|
||||
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
|
||||
]);
|
||||
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
||||
$stats['anni_importati']++;
|
||||
@@ -430,8 +461,8 @@ class XmlExchange extends Component
|
||||
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
||||
$campagna = Campagna::create([
|
||||
'descrizione' => (string) $campagnaNode->descrizione,
|
||||
'start_date' => (string) $campagnaNode->start_date,
|
||||
'end_date' => (string) $campagnaNode->end_date,
|
||||
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
|
||||
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
|
||||
]);
|
||||
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
||||
$stats['campagne_importate']++;
|
||||
@@ -455,9 +486,14 @@ class XmlExchange extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
|
||||
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
|
||||
continue;
|
||||
// Zona e tipologia sono facoltative (le colonne sono nullable e
|
||||
// un territorio resta senza quando la sua zona viene
|
||||
// eliminata): un riferimento vuoto o sconosciuto non è motivo
|
||||
// per scartare il territorio, che si porterebbe dietro tutto
|
||||
// il suo storico di assegnazioni.
|
||||
if (($legacyZonaId !== '' && ! isset($zoneMap[$legacyZonaId]))
|
||||
|| ($legacyTipologiaId !== '' && ! isset($tipologiaMap[$legacyTipologiaId]))) {
|
||||
$this->pushImportIssue('territorio', $legacyId, 'Zona o tipologia non trovata (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . '): territorio importato senza');
|
||||
}
|
||||
|
||||
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
|
||||
@@ -465,8 +501,8 @@ class XmlExchange extends Component
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => $numero,
|
||||
'zona_id' => $zoneMap[$legacyZonaId],
|
||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
|
||||
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
|
||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
|
||||
'confini' => (string) ($territorioNode->confini ?? ''),
|
||||
'note' => (string) ($territorioNode->note ?? ''),
|
||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||
@@ -540,7 +576,15 @@ class XmlExchange extends Component
|
||||
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
|
||||
$stats['assegnazioni_importate']++;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// La transazione è già stata annullata: il database è com'era.
|
||||
// Senza questo l'amministratore vedrebbe un "Server Error" senza
|
||||
// sapere quale dato del file ha violato un vincolo.
|
||||
report($e);
|
||||
|
||||
return ['error' => 'Import annullato, nessun dato è stato modificato: il file contiene un dato che il database rifiuta (' . Str::limit($e->getPrevious()?->getMessage() ?? $e->getMessage(), 240) . ').'];
|
||||
}
|
||||
|
||||
return [
|
||||
'stats' => $stats,
|
||||
@@ -581,16 +625,75 @@ class XmlExchange extends Component
|
||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||
])->all(),
|
||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
||||
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
||||
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
||||
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at'])->toArray(),
|
||||
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
|
||||
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
|
||||
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Righe da esportare, con le date scritte in modo che il reimport le
|
||||
* ritrovi identiche.
|
||||
*
|
||||
* toArray() serializza ogni data in UTC: con l'applicazione su
|
||||
* Europe/Rome il 15/03 a mezzanotte diventava "2024-03-14T23:00:00Z" e
|
||||
* al reimport tornava 14/03, un giorno perso a ogni backup e ripristino.
|
||||
* Le colonne con cast date escono come Y-m-d, che non ha fuso; i
|
||||
* timestamp come ISO 8601 con lo scostamento esplicito.
|
||||
*
|
||||
* @param array<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
|
||||
{
|
||||
$rows = $this->extractInsertRows($sql);
|
||||
@@ -880,8 +983,12 @@ class XmlExchange extends Component
|
||||
return $nullable ? null : null;
|
||||
}
|
||||
|
||||
// Riportata nel fuso dell'applicazione prima di tenere solo il giorno:
|
||||
// i backup fatti prima della correzione dell'export hanno le date in
|
||||
// UTC ("2024-03-14T23:00:00Z" per il 15/03), e così tornano giuste.
|
||||
// Una data senza orario e senza fuso resta com'è.
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateString();
|
||||
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
|
||||
} catch (\Throwable $e) {
|
||||
return $nullable ? null : null;
|
||||
}
|
||||
@@ -1022,6 +1129,13 @@ class XmlExchange extends Component
|
||||
|
||||
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
|
||||
{
|
||||
// XML 1.0 non ammette i caratteri di controllo (tranne tab e a capo)
|
||||
// né l'UTF-8 non valido: uno solo, magari incollato da Word in una
|
||||
// nota, rende l'intero file illeggibile al reimport — e quel file è
|
||||
// anche il data.xml dei backup.
|
||||
$value = mb_scrub($value, 'UTF-8');
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
|
||||
|
||||
$child = $parent->addChild($name);
|
||||
$dom = dom_import_simplexml($child);
|
||||
$dom->textContent = $value;
|
||||
@@ -1062,7 +1176,7 @@ class XmlExchange extends Component
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateTimeString();
|
||||
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user