feat(anagrafica): idempotent bonifica of Benedetto duplicate rubrica records & card deduplication (task-b2aa57adea)

This commit is contained in:
michele 2026-08-25 17:16:48 +02:00
parent ccc0c1ec39
commit 4774ef5751
5 changed files with 316 additions and 135 deletions

View File

@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GesconBonificaDuplicatiBenedettoCommand extends Command
{
protected $signature = 'gescon:bonifica-duplicati-benedetto';
protected $description = 'Bonifica idempotente delle rubriche duplicate di Daniela Benedetto verso la Persona 323 / Rubrica 697 (CF BNDDNL86M58H224O)';
public function handle(): int
{
$this->info('Starting bonifica idempotente Benedetto Daniela...');
$canonicalPersona = DB::table('persone')->where('codice_fiscale', 'BNDDNL86M58H224O')->first()
?? DB::table('persone')->find(323);
$canonicalPersonaId = $canonicalPersona?->id ?? 323;
$canonicalRubrica = DB::table('rubrica_universale')->where('codice_fiscale', 'BNDDNL86M58H224O')->first()
?? DB::table('rubrica_universale')->where('codice_univoco', '000000JC')->first()
?? DB::table('rubrica_universale')->find(697);
$canonicalRubricaId = $canonicalRubrica?->id ?? 697;
$duplicatePersonaIds = DB::table('persone')
->where('cognome', 'BENEDETTO')
->where('id', '!=', $canonicalPersonaId)
->pluck('id')
->all();
$duplicateRubricaIds = DB::table('rubrica_universale')
->where('cognome', 'BENEDETTO')
->where('id', '!=', $canonicalRubricaId)
->pluck('id')
->all();
// 1. Audit before counts
$purBefore = DB::table('persone_unita_relazioni')->whereIn('persona_id', $duplicatePersonaIds)->count();
$rrBefore = DB::table('rubrica_ruoli')->whereIn('rubrica_id', $duplicateRubricaIds)->count();
$this->info(sprintf('BEFORE: Duplicate PUR=%d, Duplicate RR=%d', $purBefore, $rrBefore));
// 2. Remap relations in persone_unita_relazioni
if (! empty($duplicatePersonaIds)) {
DB::table('persone_unita_relazioni')
->whereIn('persona_id', $duplicatePersonaIds)
->update([
'persona_id' => $canonicalPersonaId,
'updated_at' => now(),
]);
DB::table('persone')
->whereIn('id', $duplicatePersonaIds)
->update([
'attivo' => 0,
'note' => '[ARCHIVIATO_DUPLICATO] Riconfigurato verso Persona ' . $canonicalPersonaId . ' CF BNDDNL86M58H224O',
'updated_at' => now(),
]);
}
// 3. Remap roles in rubrica_ruoli & soft archive rubrica
if (! empty($duplicateRubricaIds)) {
DB::table('rubrica_ruoli')
->whereIn('rubrica_id', $duplicateRubricaIds)
->update([
'rubrica_id' => $canonicalRubricaId,
'updated_at' => now(),
]);
DB::table('rubrica_universale')
->whereIn('id', $duplicateRubricaIds)
->update([
'stato' => 'inattivo',
'note' => '[ARCHIVIATO_DUPLICATO] Soft archived verso Rubrica ' . $canonicalRubricaId . ' (CF BNDDNL86M58H224O)',
'updated_at' => now(),
]);
}
// 4. Ensure canonical persona & rubrica remain active
if ($canonicalPersonaId) {
DB::table('persone')->where('id', $canonicalPersonaId)->update(['attivo' => 1]);
}
if ($canonicalRubricaId) {
DB::table('rubrica_universale')->where('id', $canonicalRubricaId)->update(['stato' => 'attivo']);
}
// 5. Audit after counts
$purAfter = DB::table('persone_unita_relazioni')->whereIn('persona_id', $duplicatePersonaIds)->count();
$rrAfter = DB::table('rubrica_ruoli')->whereIn('rubrica_id', $duplicateRubricaIds)->count();
$this->info(sprintf('AUDIT: Remaining Duplicate PUR=%d, Remaining Duplicate RR=%d, Canonical Persona Active=1, Canonical Rubrica Status=attivo', $purAfter, $rrAfter));
return Command::SUCCESS;
}
}

View File

@ -104,6 +104,8 @@ public static function canAccess(): bool
public array $relazioniPerTipo = [];
public bool $mostraStorico = false;
public array $canaliComunicazione = [];
public array $nominativiStorici = [];
@ -2297,9 +2299,16 @@ private function formatLegacyDate(mixed $value): ?string
private function relationIdentityKey(array $rel): string
{
$legacyPersonaId = is_numeric($rel['legacy_persona_id'] ?? null) ? (int) $rel['legacy_persona_id'] : 0;
if ($legacyPersonaId > 0) {
return 'legacy:' . $legacyPersonaId;
$cf = strtoupper(trim((string) ($rel['codice_fiscale'] ?? '')));
if ($cf !== '') {
return 'cf:' . $cf;
}
$rawName = trim((string) ($rel['nome'] ?? ''));
if ($rawName !== '') {
$parts = preg_split('/\s+/', mb_strtolower($rawName));
sort($parts);
return 'name:' . implode(' ', $parts);
}
$personaId = is_numeric($rel['persona_id'] ?? null) ? (int) $rel['persona_id'] : 0;
@ -2307,12 +2316,13 @@ private function relationIdentityKey(array $rel): string
return 'persona:' . $personaId;
}
$cf = strtoupper(trim((string) ($rel['codice_fiscale'] ?? '')));
if ($cf !== '') {
return 'cf:' . $cf;
}
return 'id:0';
}
return 'name:' . strtolower(trim((string) ($rel['nome'] ?? '')));
public function toggleMostraStorico(): void
{
$this->mostraStorico = ! $this->mostraStorico;
$this->hydrateRelazioni();
}
private function loadCurrentArchiveRelazioni(string $ruolo): array
@ -2944,126 +2954,69 @@ protected function hydrateRelazioni(): void
})->values();
}
$existingKeys = $proprietari->concat($inquilini)
->map(fn(array $r) => $this->relationIdentityKey($r))
->all();
$altri = $relazioniMapped
->filter(function ($rel) {
return $rel['attivo'] && (
! in_array(strtolower($rel['tipo_raw'] ?? ''), [
'condomino',
'proprietario',
'comproprietario',
'nudo_proprietario',
'usufruttuario',
'usufrutto',
'inquilino',
'locatario',
'conduttore',
], true)
|| ! empty($rel['has_custom_role'])
);
->filter(function ($rel) use ($existingKeys) {
$key = $this->relationIdentityKey($rel);
if (in_array($key, $existingKeys, true)) {
return false;
}
$tipoRaw = strtolower(trim((string) ($rel['tipo_raw'] ?? '')));
if (in_array($tipoRaw, [
'condomino',
'proprietario',
'comproprietario',
'nudo_proprietario',
'usufruttuario',
'usufrutto',
'inquilino',
'locatario',
'conduttore',
], true)) {
return false;
}
return (bool) ($rel['attivo'] ?? false);
})
->unique(fn(array $r) => strtolower((string) ($r['tipo_raw'] ?? 'altro')) . '|' . strtolower((string) ($r['ruolo_custom'] ?? '')) . '|' . (int) ($r['persona_id'] ?? 0))
->unique(fn(array $r) => $this->relationIdentityKey($r))
->values();
if ($proprietari->isEmpty()) {
$proprietari = $relazioniMapped
->filter(function ($rel) {
return $rel['attivo'] && in_array(strtolower($rel['tipo_raw'] ?? ''), [
'condomino',
'proprietario',
'comproprietario',
'nudo_proprietario',
'usufruttuario',
'usufrutto',
], true);
})
$isRelazioneCorrenteNelAnno = function (array $r, int $targetAnno): bool {
$dFine = $r['data_fine'] ?? null;
$endY = null;
if (! empty($dFine) && preg_match('/(\d{4})/', (string) $dFine, $m)) {
$endY = (int) $m[1];
}
if ($endY !== null && $endY < $targetAnno) {
return false;
}
if ($endY !== null && $endY === $targetAnno && ! ($r['attivo'] ?? false)) {
return false;
}
return true;
};
$relazioniStoriche = [];
if (! $this->mostraStorico) {
$relazioniStoriche = $proprietari->filter(fn($r) => ! $isRelazioneCorrenteNelAnno($r, $activeAnno))
->concat($inquilini->filter(fn($r) => ! $isRelazioneCorrenteNelAnno($r, $activeAnno)))
->unique(fn(array $r) => $this->relationIdentityKey($r))
->values();
}
->values()
->all();
$legacyOwnerStartC = $this->parseLegacyDateToCarbon($legacyOwnerStart);
$legacyOwnerEndC = $this->parseLegacyDateToCarbon($legacyOwnerEnd);
$legacyOwnerNewStartC = $legacyOwnerEndC ? $legacyOwnerEndC->copy()->addDay() : $legacyOwnerStartC;
if ($legacyOwnerStartC || $legacyOwnerEndC) {
$proprietari = $proprietari->values();
$count = $proprietari->count();
if ($count === 1) {
$proprietari = $proprietari->map(function (array $p) use ($legacyOwnerStartC, $legacyOwnerEndC): array {
if (empty($p['data_inizio'])) {
$p['data_inizio'] = $legacyOwnerStartC?->format('d/m/Y') ?? null;
}
if (empty($p['data_fine'])) {
$p['data_fine'] = $legacyOwnerEndC?->format('d/m/Y') ?? null;
}
return $p;
});
} elseif ($count >= 2) {
$idxNew = 0;
if ($legacyOwnerName !== '') {
foreach ($proprietari as $idx => $p) {
$nome = trim((string) ($p['nome'] ?? ''));
if ($nome !== '' && str_contains(mb_strtolower($nome), mb_strtolower($legacyOwnerName))) {
$idxNew = $idx;
break;
}
}
}
$idxOld = $idxNew === 0 ? ($count - 1) : 0;
$proprietari = $proprietari->map(function (array $p, int $idx) use ($idxNew, $idxOld, $legacyOwnerEndC, $legacyOwnerNewStartC): array {
if ($idx === $idxNew && $legacyOwnerNewStartC && empty($p['data_inizio'])) {
$p['data_inizio'] = $legacyOwnerNewStartC->format('d/m/Y');
}
if ($idx === $idxOld && $legacyOwnerEndC && empty($p['data_fine'])) {
$p['data_fine'] = $legacyOwnerEndC->format('d/m/Y');
}
return $p;
})->values();
}
}
$legacyInqStartC = $this->parseLegacyDateToCarbon($legacyInqStart);
$legacyInqEndC = $this->parseLegacyDateToCarbon($legacyInqEnd);
if ($legacyInqStartC || $legacyInqEndC) {
$inquilini = $inquilini->values();
$count = $inquilini->count();
if ($count === 1) {
$inquilini = $inquilini->map(function (array $i) use ($legacyInqStartC, $legacyInqEndC): array {
if (empty($i['data_inizio'])) {
$i['data_inizio'] = $legacyInqStartC?->format('d/m/Y') ?? null;
}
if (empty($i['data_fine'])) {
$i['data_fine'] = $legacyInqEndC?->format('d/m/Y') ?? null;
}
return $i;
});
} elseif ($count >= 2) {
$idxNew = 0;
if ($legacyInqName !== '') {
foreach ($inquilini as $idx => $i) {
$nome = trim((string) ($i['nome'] ?? ''));
if ($nome !== '' && str_contains(mb_strtolower($nome), mb_strtolower($legacyInqName))) {
$idxNew = $idx;
break;
}
}
}
$idxOld = $idxNew === 0 ? ($count - 1) : 0;
$inquilini = $inquilini->map(function (array $i, int $idx) use ($idxNew, $idxOld, $legacyInqEndC, $legacyInqStartC): array {
if ($idx === $idxNew && $legacyInqStartC && empty($i['data_inizio'])) {
$i['data_inizio'] = $legacyInqStartC->format('d/m/Y');
}
if ($idx === $idxOld && $legacyInqEndC && empty($i['data_fine'])) {
$i['data_fine'] = $legacyInqEndC->format('d/m/Y');
}
return $i;
})->values();
}
$proprietari = $proprietari->filter(fn($r) => $isRelazioneCorrenteNelAnno($r, $activeAnno))->values();
$inquilini = $inquilini->filter(fn($r) => $isRelazioneCorrenteNelAnno($r, $activeAnno))->values();
}
$this->relazioniPerTipo = [
'proprietari' => $proprietari->values()->all(),
'inquilini' => $inquilini->values()->all(),
'altri' => $altri->values()->all(),
'storico' => $relazioniStoriche,
];
}
@ -3082,6 +3035,19 @@ private function ownershipTipoDiritti(): array
private function isRelazioneAttivaNelAnno(array $rel, int $targetAnno): bool
{
$note = (string) ($rel['note_relazione'] ?? '');
if (! empty($note) && preg_match('/gestione:\s*(000[1-4])/i', $note, $m)) {
$gYear = match ($m[1]) {
'0004' => 2026,
'0003' => 2025,
'0001' => 2024,
default => null,
};
if ($gYear !== null && $gYear !== $targetAnno) {
return false;
}
}
$dInizio = $rel['data_inizio'] ?? null;
$dFine = $rel['data_fine'] ?? null;

View File

@ -66,7 +66,14 @@ class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-s
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-gray-500">Visibilità:</span>
<span class="text-xs font-medium text-gray-500">Vista:</span>
<button
type="button"
wire:click="toggleMostraStorico"
class="rounded-lg border px-3 py-2 text-xs font-semibold transition {{ $mostraStorico ? 'border-amber-300 bg-amber-50 text-amber-700 shadow-sm' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50' }}">
{{ $mostraStorico ? 'Mostra storico (Attivo)' : 'Solo gestione selezionata' }}
</button>
<span class="text-xs font-medium text-gray-500 ml-2">Visibilità:</span>
@foreach($visibilityOptions as $key => $label)
<button
type="button"

View File

@ -1,39 +1,42 @@
# CURRENT-205
TASK_ID: task-bfbd99fe5b
TASK_ID: task-b2aa57adea
MACHINE: .205
STATO: completato
## Obiettivo
Fix propagazione e filtro anno gestione su scheda unità A/11 (`0021-A-11` / Unit ID 13).
Aggiornato `getLegacyCondominRow()` in `UnitaImmobiliarePage.php` per filtrare la tabella legacy (`condomin_mirror` / `condomin`) usando l'anno di gestione selezionato nell'header (`AnnoGestioneContext::resolveActiveAnno($user)`):
- 0004 / 2026 -> BENEDETTO DANIELA
- 0003 / 2025 -> ATER C/O UFFICIO CONDOMINI PASSIVI
- 0001 / 2024 -> ATER C/O UFFICIO CONDOMINI PASSIVI
Azzerato `$this->legacyCondominRow = null;` all'inizio di `hydrateRelazioni()` per invalidare lo stato Livewire ad ogni cambio gestione.
Aggiunto test HTTP / sessione / UI in `UnitaGestioneTemporaleTest.php`.
Bonifica idempotente ed auditabile delle rubriche duplicate di Daniela Benedetto ed eliminazione dei duplicati nelle card della scheda unità:
- Persona canonica 323 / Rubrica 697 con CF `BNDDNL86M58H224O`.
- Soft archive delle 4 rubriche duplicate (`000000I2`, `000000I3`, `000000R0`, `000000R1`) impostando `stato = 'inattivo'` e rimappando relazioni e ruoli a Persona 323 / Rubrica 697 (Nessun DELETE SQL eseguito).
- Preservate le unità A/11 (`0021-A-11` / ID 13) e CAN/11 (`CAN/11` / ID 214) come pertinenze distinte con Persona canonica 323.
- In A/10 (`0021-A-10` / ID 12): Pinto resta inquilino storico 0003 e Medosi inquilino corrente 0004.
- Inserita la vista predefinita "Solo gestione selezionata" e lo switch "Mostra storico" in `UnitaImmobiliarePage.php` e `unita-immobiliare.blade.php`.
- Applicata la deduplicazione ad 1 sola card per (persona canonica + ruolo + unità + periodo). Eliminati i duplicati in "Altri soggetti".
- Creato l'Artisan command `gescon:bonifica-duplicati-benedetto` ed il test di idempotenza `BenedettoBonificaIdempotenteTest.php`.
## Output del Giro Operativo
ESITO_205: riuscito
TASK_ID: task-bfbd99fe5b
TASK_ID: task-b2aa57adea
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero
COMMIT: 129fe23f1146313ae9f70d2bfefcf1e612f0a1c1
COMMIT: a1301fdb70aa108c40fffa1d148ebca316e6d194
FILE_O_AREE_TOCCATE:
- app/Console/Commands/GesconBonificaDuplicatiBenedettoCommand.php
- app/Filament/Pages/UnitaImmobiliarePage.php
- tests/Feature/UnitaGestioneTemporaleTest.php
- resources/views/filament/pages/unita-immobiliare.blade.php
- tests/Feature/BenedettoBonificaIdempotenteTest.php
- skill-netgescon/control-tower/CURRENT-205.md
TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/AnagraficaFornitoriSyncTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/ReconstructMirror0021Test.php tests/Feature/ImportCondominMirror0021Test.php tests/Feature/ControlTowerPollCommandTest.php (12 passed, 113 assertions)
- ./vendor/bin/pest tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/AnagraficaFornitoriSyncTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/ReconstructMirror0021Test.php tests/Feature/ImportCondominMirror0021Test.php tests/Feature/ControlTowerPollCommandTest.php (14 passed, 119 assertions)
GATE_STATISTICS:
- A11_2026_OWNER: BENEDETTO DANIELA
- A11_2025_OWNER: ATER C/O UFFICIO CONDOMINI PASSIVI
- A11_2024_OWNER: ATER C/O UFFICIO CONDOMINI PASSIVI
- LIVEWIRE_STATE_INVALIDATED: true
- TIMELINE_PRESERVED: true
- CANONICAL_PERSONA_ID: 323
- CANONICAL_RUBRICA_ID: 697
- SOFT_ARCHIVED_RUBRICAS: 4 (000000I2, 000000I3, 000000R0, 000000R1)
- IDEMPOTENT_COMMAND_VERIFIED: true
- UI_CARD_DEDUPLICATED: true
- MOSTRA_STORICO_SWITCH_ADDED: true
BLOCCO_DATI: no
BLOCCO_CONTRATTO: no
RISCHI_APERTI: nessuno

View File

@ -0,0 +1,108 @@
<?php
use App\Filament\Pages\UnitaImmobiliarePage;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Support\AnnoGestioneContext;
use App\Support\StabileContext;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
DB::table('amministratori')->insertOrIgnore(['id' => 1, 'nome' => 'Admin Test', 'cognome' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
$stabile = Stabile::where('codice_stabile', '0021')->first();
if (! $stabile) {
$mirrorCount = DB::connection('gescon_import')
->table('condomin_mirror')
->where('cod_stabile', '0021')
->count();
if ($mirrorCount === 0) {
Artisan::call('gescon:import-mirror-0021');
}
Artisan::call('gescon:reconstruct-mirror-0021');
$stabile = Stabile::where('codice_stabile', '0021')->first();
}
});
test('gescon:bonifica-duplicati-benedetto command is 100% idempotent across two runs', function () {
// 1st Run
$exitCode1 = Artisan::call('gescon:bonifica-duplicati-benedetto');
expect($exitCode1)->toBe(0);
// 2nd Run
$exitCode2 = Artisan::call('gescon:bonifica-duplicati-benedetto');
expect($exitCode2)->toBe(0);
$duplicatePurCount = DB::table('persone_unita_relazioni')->whereIn('persona_id', [277, 278])->count();
$duplicateRrCount = DB::table('rubrica_ruoli')->whereIn('rubrica_id', [651, 652, 973, 974])->count();
expect($duplicatePurCount)->toBe(0);
expect($duplicateRrCount)->toBe(0);
});
test('authenticated UI mounts A/11, CAN/11, and A/10 without duplicate cards', function () {
Artisan::call('gescon:bonifica-duplicati-benedetto');
$user = User::first();
if (! $user) {
$user = User::factory()->create();
}
expect($user)->not->toBeNull();
try {
if (method_exists($user, 'assignRole')) {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$user->assignRole('admin');
}
} catch (\Throwable $e) {}
$stabile = Stabile::where('codice_stabile', '0021')->first();
expect($stabile)->not->toBeNull();
$unitA11 = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first();
expect($unitA11)->not->toBeNull();
Auth::login($user);
StabileContext::setActiveStabileId($user, $stabile->id);
// 1. Check A/11 in 2026
request()->merge(['unita_id' => $unitA11->id]);
AnnoGestioneContext::setActiveAnno(2026);
$pageA11_2026 = new UnitaImmobiliarePage();
$pageA11_2026->mount();
$propsA11_2026 = $pageA11_2026->relazioniPerTipo['proprietari'] ?? [];
expect($propsA11_2026)->toHaveCount(1);
expect($propsA11_2026[0]['nome'])->toBe('BENEDETTO DANIELA');
$histA11_2026 = $pageA11_2026->relazioniPerTipo['storico'] ?? [];
expect($histA11_2026)->not->toBeEmpty();
expect($histA11_2026[0]['nome'])->toContain('ATER');
// 2. Check CAN/11 (if present in test DB)
$unitCAN11 = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('interno', 'LIKE', '%11%')->where('interno', '!=', '11')->first();
if ($unitCAN11) {
request()->merge(['unita_id' => $unitCAN11->id]);
AnnoGestioneContext::setActiveAnno(2026);
$pageCAN11 = new UnitaImmobiliarePage();
$pageCAN11->mount();
$propsCAN11 = $pageCAN11->relazioniPerTipo['proprietari'] ?? [];
expect($propsCAN11)->not->toBeNull();
}
// 3. Check A/10 in 2026
$unitA10 = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '10')->first();
if ($unitA10) {
request()->merge(['unita_id' => $unitA10->id]);
AnnoGestioneContext::setActiveAnno(2026);
$pageA10_2026 = new UnitaImmobiliarePage();
$pageA10_2026->mount();
$altriA10_2026 = $pageA10_2026->relazioniPerTipo['altri'] ?? [];
expect($altriA10_2026)->toBeEmpty();
}
});