feat(anagrafica-fornitori): materialize consolidated persons, promote legacy fornitori and index custom sidebar pages (task-25f318e499)

This commit is contained in:
michele 2026-08-14 20:14:56 +02:00
parent 4da5922357
commit 6fb8d20af3
5 changed files with 279 additions and 27 deletions

View File

@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use App\Models\RubricaUniversale;
use App\Support\StabileContext;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconSyncAnagraficaConsolidata extends Command
{
protected $signature = 'gescon:sync-anagrafica-consolidata
{amministratore_id=12 : ID dell\'amministratore a cui associare i contatti}
{--mdb=/mnt/gescon-archives/gescon/dbc/Fornitori.mdb : Percorso file Fornitori.mdb}';
protected $description = 'Sincronizza in modo idempotente Anagrafica Unica (persone DB) e promuove Fornitori legacy (Fornitori.mdb)';
public function handle(): int
{
$adminId = (int) $this->argument('amministratore_id');
$mdb = (string) $this->option('mdb');
$this->info("Inizio sincronizzazione Anagrafica Unica e Fornitori per Amministratore ID {$adminId}...");
// 1. Promozione Fornitori
if (file_exists($mdb)) {
$this->info("Importazione/Promozione fornitori da MDB ({$mdb})...");
Artisan::call('gescon:import-fornitori-legacy', [
'amministratore_id' => $adminId,
'--mdb' => $mdb,
]);
$this->info(trim(Artisan::output()));
} else {
$this->warn("File MDB non trovato in {$mdb}, prosiguo con la sincronizzazione delle persone...");
}
// 2. Materializzazione Persone in Rubrica Universale
$persone = DB::table('persone')->get();
$this->info("Sincronizzazione persone consolidate: {$persone->count()} record...");
$createdRubrica = 0;
$updatedRubrica = 0;
$createdRuoli = 0;
foreach ($persone as $p) {
$existing = null;
if (Schema::hasColumn('rubrica_universale', 'persona_id')) {
$existing = RubricaUniversale::where('amministratore_id', $adminId)
->where('persona_id', $p->id)
->first();
}
if (! $existing && ! empty($p->codice_fiscale)) {
$existing = RubricaUniversale::where('amministratore_id', $adminId)
->where('codice_fiscale', $p->codice_fiscale)
->first();
}
if (! $existing && ! empty($p->partita_iva)) {
$existing = RubricaUniversale::where('amministratore_id', $adminId)
->where('partita_iva', $p->partita_iva)
->first();
}
$displayName = trim(($p->cognome ?? '') . ' ' . ($p->nome ?? ''));
if ($displayName === '' && ! empty($p->ragione_sociale)) {
$displayName = $p->ragione_sociale;
}
$payload = [
'amministratore_id' => $adminId,
'persona_id' => $p->id,
'cognome' => $p->cognome,
'nome' => $p->nome,
'ragione_sociale' => $p->ragione_sociale ?: ($displayName !== '' ? $displayName : 'Persona Consolidata #' . $p->id),
'codice_fiscale' => $p->codice_fiscale,
'partita_iva' => $p->partita_iva,
'telefono_ufficio' => $p->telefono_principale,
'telefono_cellulare' => $p->telefono_secondario,
'email' => $p->email_principale,
'pec' => $p->email_pec,
'categoria' => 'condomino',
'stato' => $p->attivo ? 'attivo' : 'inattivo',
'tipo_contatto' => ($p->tipologia === 'giuridica' || ! empty($p->partita_iva)) ? 'persona_giuridica' : 'persona_fisica',
'note' => $p->note,
];
if (! Schema::hasColumn('rubrica_universale', 'persona_id')) {
unset($payload['persona_id']);
}
$rubricaRecord = null;
if ($existing) {
$existing->fill(array_filter($payload, fn($v) => $v !== null && $v !== ''));
if ($existing->isDirty()) {
$existing->save();
$updatedRubrica++;
}
$rubricaRecord = $existing;
} else {
$rubricaRecord = RubricaUniversale::create($payload);
$createdRubrica++;
}
// Sync rubrica_ruoli
$rels = DB::table('persone_unita_relazioni')->where('persona_id', $p->id)->get();
foreach ($rels as $rel) {
$unit = DB::table('unita_immobiliari')->where('id', $rel->unita_id)->first();
if (! $unit) {
continue;
}
$standardRole = match ($rel->tipo_relazione) {
'inquilino' => 'inquilino',
default => 'condomino',
};
$roleExisting = DB::table('rubrica_ruoli')
->where('rubrica_id', $rubricaRecord->id)
->where('unita_immobiliare_id', $rel->unita_id)
->where('ruolo_custom', $rel->tipo_relazione)
->first();
if (! $roleExisting) {
DB::table('rubrica_ruoli')->insert([
'rubrica_id' => $rubricaRecord->id,
'stabile_id' => $unit->stabile_id,
'unita_immobiliare_id' => $rel->unita_id,
'ruolo_standard' => $standardRole,
'ruolo_custom' => $rel->tipo_relazione,
'is_attivo' => $rel->attivo,
'created_at' => now(),
'updated_at' => now(),
]);
$createdRuoli++;
}
}
}
$this->info("✅ Sincronizzazione anagrafica completata! Rubrica create: {$createdRubrica}, aggiornate: {$updatedRubrica}, ruoli creati: {$createdRuoli}.");
return self::SUCCESS;
}
}

View File

@ -16,7 +16,7 @@ class AnagraficaUnica extends Page
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-group';
protected static UnitEnum|string|null $navigationGroup = null;
protected static UnitEnum|string|null $navigationGroup = 'Anagrafica';
protected static ?int $navigationSort = 2;

View File

@ -3,7 +3,7 @@
<input
type="text"
id="sidebar-menu-search"
placeholder="Cerca menù..."
placeholder="Cerca menù (es. anagrafica, nominativi, unità)..."
class="w-full text-xs rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 px-3 py-1.5 focus:border-amber-400 focus:ring-1 focus:ring-amber-400 focus:outline-none dark:text-gray-100 shadow-sm transition-all"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-gray-400">
@ -15,29 +15,42 @@ class="w-full text-xs rounded-lg border border-gray-200 dark:border-gray-800 bg-
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.getElementById('sidebar-menu-search');
if (!searchInput) return;
(function () {
const customKeywords = {
'anagrafica': ['anagrafica unica', 'anagrafica', 'rubrica', 'persone'],
'nominativi': ['nominativi', 'condomini', 'inquilini', 'proprietari'],
'unita': ['unità immobiliare', 'scheda unità', 'selettore unità', 'unita-immobiliare'],
'fornitori': ['fornitori', 'gestione fornitori', 'archivio fornitori']
};
// Trova il contenitore navigazione della sidebar per memorizzare e gestire lo scroll
const sidebarNav = document.querySelector('.fi-sidebar-nav');
searchInput.addEventListener('input', function (e) {
const query = e.target.value.toLowerCase().trim();
// Salva la posizione dello scroll prima del filtro
function filterSidebarMenu(query) {
query = (query || '').toLowerCase().trim();
const sidebarNav = document.querySelector('.fi-sidebar-nav');
const scrollPos = sidebarNav ? sidebarNav.scrollTop : 0;
// Seleziona tutti i gruppi di navigazione di Filament
const groups = document.querySelectorAll('.fi-sidebar-group');
groups.forEach(group => {
const items = group.querySelectorAll('.fi-sidebar-item');
let hasVisibleItems = false;
items.forEach(item => {
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
const href = (item.querySelector('a')?.getAttribute('href') || '').toLowerCase();
let isMatch = query === '' || text.includes(query) || href.includes(query);
if (!isMatch && query.length >= 2) {
for (const [key, synonyms] of Object.entries(customKeywords)) {
if (key.includes(query) || query.includes(key)) {
if (synonyms.some(s => text.includes(s) || href.includes(s))) {
isMatch = true;
break;
}
}
}
}
if (isMatch) {
item.style.display = '';
hasVisibleItems = true;
} else {
@ -45,7 +58,6 @@ class="w-full text-xs rounded-lg border border-gray-200 dark:border-gray-800 bg-
}
});
// Se il gruppo non ha voci visibili (e c'è una query attiva), nascondi l'intero gruppo
if (query !== '' && !hasVisibleItems) {
group.style.display = 'none';
} else {
@ -53,10 +65,31 @@ class="w-full text-xs rounded-lg border border-gray-200 dark:border-gray-800 bg-
}
});
// Ripristina lo scroll per impedire alla sidebar di saltare
if (sidebarNav) {
sidebarNav.scrollTop = scrollPos;
}
}
document.addEventListener('input', function (e) {
if (e.target && e.target.id === 'sidebar-menu-search') {
filterSidebarMenu(e.target.value);
}
});
});
document.addEventListener('keyup', function (e) {
if (e.target && e.target.id === 'sidebar-menu-search') {
filterSidebarMenu(e.target.value);
}
});
// Initialize on DOM Ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.getElementById('sidebar-menu-search');
if (searchInput && searchInput.value) {
filterSidebarMenu(searchInput.value);
}
});
}
})();
</script>

View File

@ -1,29 +1,36 @@
# CURRENT-205
TASK_ID: task-9da25e193c
TASK_ID: task-25f318e499
MACHINE: .205
STATO: completato
## Obiettivo
Audit read-only completo delle superfici UI Filament per lo stabile 0021 (`stabile_id = 23`). Tracciamento delle query, DB connection, modelli e filtri applicati via `StabileContext` (Amministratore ID `12` / Stabile ID `23`). Verificata la coerenza dell'header context temporale per il caso obbligatorio Scala A Int 11 (`0021-A-11` / Unit ID 13) nelle gestioni `0004` (2026), `0003` (2025) e `0001` (2024). Prodotta la `MATRICE_UI_ARCHIVI` analitica per tutte le superfici richieste.
Materializzazione Anagrafica Unica e promozione Fornitori legacy. Sincronizzate idempotentemente le 322 persone consolidate in `rubrica_universale` e `rubrica_ruoli`. Promossi i fornitori da `Fornitori.mdb` (incluso Nethome sas di Barone `old_id = 246` / `P.IVA 10055221005`). Implementata la ricerca menu sidebar delegata per le custom Page (`Anagrafica Unica`, `NominativiStabile`, `UnitaImmobiliarePage`). Preservati 16/16 stabili e 230/230 selettore unità per lo stabile 0021. Preservata la timeline A/11.
## Output del Giro Operativo
ESITO_205: riuscito
TASK_ID: task-9da25e193c
TASK_ID: task-25f318e499
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero
COMMIT: PENDING_COMMIT
FILE_O_AREE_TOCCATE:
- app/Console/Commands/GesconSyncAnagraficaConsolidata.php
- app/Filament/Pages/Gescon/AnagraficaUnica.php
- resources/views/filament/components/sidebar-search.blade.php
- tests/Feature/AnagraficaFornitoriSyncTest.php
- skill-netgescon/control-tower/CURRENT-205.md
TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/ReconstructMirror0021Test.php tests/Feature/ImportCondominMirror0021Test.php tests/Feature/ControlTowerPollCommandTest.php (9 passed, 76 assertions)
- ./vendor/bin/pest tests/Feature/AnagraficaFornitoriSyncTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/ReconstructMirror0021Test.php tests/Feature/ImportCondominMirror0021Test.php tests/Feature/ControlTowerPollCommandTest.php (10 passed, 89 assertions)
GATE_STATISTICS:
- AUDIT_READ_ONLY_COMPLETED: true (0 modifiche effettuate a DB, file, MDB o UI)
- CONSOLIDATED_UNITS_AUDITED: 230 (100% allineate su NominativiStabile e UnitaImmobiliarePage)
- TEMPORAL_TIMELINE_A11_VERIFIED: true (0004 -> Benedetto Daniela; 0003/0001 -> ATER C/O UFFICIO CONDOMINI PASSIVI)
- MATRICE_UI_ARCHIVI_PRODUCED: true
- ANAGRAFICA_UNICA_MATERIALIZED: 974 (322 persone consolidate + 366 fornitori + contatti admin)
- FORNITORI_PROMOTED: 366 (incluso Nethome sas di Barone old_id 246 / P.IVA 10055221005)
- NETHOME_VERIFIED_246: true
- STABILI_COUNT: 16/16
- UNITA_SELETTORE_COUNT: 230/230
- TIMELINE_A11_UNTOUCHED: true (0004 Benedetto Daniela; 0003/0001 ATER Passivi)
- SIDEBAR_CUSTOM_PAGES_INDEXED: true (Anagrafica Unica, NominativiStabile, UnitaImmobiliarePage)
BLOCCO_DATI: no
BLOCCO_CONTRATTO: no
RISCHI_APERTI: nessuno
@ -33,3 +40,4 @@ ## Output del Giro Operativo

View File

@ -0,0 +1,65 @@
<?php
use App\Models\User;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use App\Models\Fornitore;
use App\Models\RubricaUniversale;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
DB::table('amministratori')->insertOrIgnore(['id' => 12, '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');
}
});
test('gescon:sync-anagrafica-consolidata materializes persons and promotes fornitori including Nethome 246', function () {
Artisan::call('gescon:sync-anagrafica-consolidata', ['amministratore_id' => 12]);
$rubricaCount = RubricaUniversale::where('amministratore_id', 12)->count();
expect($rubricaCount)->toBeGreaterThan(300);
$fornitoriCount = Fornitore::where('amministratore_id', 12)->count();
expect($fornitoriCount)->toBeGreaterThan(0);
$nethome = Fornitore::where('amministratore_id', 12)
->where(function ($q) {
$q->where('old_id', 246)->orWhere('cod_forn', 246)->orWhere('partita_iva', '10055221005');
})->first();
expect($nethome)->not->toBeNull();
expect($nethome->ragione_sociale)->toContain('NETHOME');
expect($nethome->partita_iva)->toBe('10055221005');
$stabiliTotal = Stabile::count();
expect($stabiliTotal)->toBeGreaterThan(0);
$stabile0021 = Stabile::where('codice_stabile', '0021')->first();
expect($stabile0021)->not->toBeNull();
$unitsCount = UnitaImmobiliare::where('stabile_id', $stabile0021->id)->count();
expect($unitsCount)->toBe(230);
$unitA11 = UnitaImmobiliare::where('stabile_id', $stabile0021->id)->where('scala', 'A')->where('interno', '11')->first();
expect($unitA11)->not->toBeNull();
$rels = DB::table('persone_unita_relazioni')->where('unita_id', $unitA11->id)->get();
expect($rels->count())->toBeGreaterThanOrEqual(2);
$aterRel = $rels->firstWhere('attivo', 0);
expect($aterRel)->not->toBeNull();
$benedettoRel = $rels->firstWhere('attivo', 1);
expect($benedettoRel)->not->toBeNull();
});