Compare commits

...

15 Commits

Author SHA1 Message Date
df09bc67b2 docs(control-tower): update CURRENT-205.md for task-d095b0e23b 2026-08-26 18:15:27 +02:00
ae8f54dc36 fix(anagrafica): Anagrafica Unica canonica filtering, audit history filter, and protected CRUD guards (task-d095b0e23b) 2026-08-26 18:15:18 +02:00
4774ef5751 feat(anagrafica): idempotent bonifica of Benedetto duplicate rubrica records & card deduplication (task-b2aa57adea) 2026-08-25 17:17:02 +02:00
ccc0c1ec39 fix(unita-page): filter getLegacyCondominRow by active header year context and invalidate cache (task-bfbd99fe5b) 2026-08-17 13:32:05 +02:00
d7a4cd420d docs(control-tower): update CURRENT-205 for task-ade81ed21d audit 2026-08-16 11:11:52 +02:00
cf0eaae8af fix(unita-page): filter active owner by active anno gestione header context (task-c1f4a4c534) 2026-08-15 09:55:08 +02:00
6fb8d20af3 feat(anagrafica-fornitori): materialize consolidated persons, promote legacy fornitori and index custom sidebar pages (task-25f318e499) 2026-08-14 20:15:05 +02:00
4da5922357 docs(control-tower): publish audit report for task-9da25e193c 2026-08-14 13:48:34 +02:00
fb1ebdf505 fix(unita-page): resolve undefined property canale_convocazione with structured projection and regression test (task-c05327b8f1) 2026-08-14 13:17:58 +02:00
0984f27721 feat(nominativi): remove runtime legacy staging fallback and publish consolidated nominativi (task-e4ec3b8dc0) 2026-08-13 23:43:22 +02:00
d3658d32e5 feat(nominativi): switch NominativiStabile page to consolidated domain model source (task-d28345d744) 2026-08-13 11:25:26 +02:00
872b3a6b58 docs(control-tower): update CURRENT-205.md for task-11b2442cbc 2026-08-13 00:46:25 +02:00
45de2eaad2 docs(control-tower): update CURRENT-205.md for task-d1affac2c5 2026-08-12 20:11:57 +02:00
b54ea07f54 fix(importer): strictly use raw CFs, remove placeholder, support multi-gestione temporal tenant relations (task-856d2ae3f2) 2026-08-11 00:05:47 +02:00
f60898aae6 feat(importer): reconstruct Anagrafica Unica, Unità and Relazioni for 0021 from condomin_mirror (task-856d2ae3f2) 2026-08-10 23:45:44 +02:00
24 changed files with 1939 additions and 229 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

@ -0,0 +1,507 @@
<?php
namespace App\Console\Commands;
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconReconstructMirror0021Command extends Command
{
protected $signature = 'gescon:reconstruct-mirror-0021 {--stabile=0021 : Codice dello stabile pilot}';
protected $description = 'Ricostruisce Anagrafica Unica, Unità Immobiliari e Relazioni Temporali dal condomin_mirror 0021 con gestione rigorosa CF e subentri';
private array $resolvedPersonasByStableId = [];
public function handle(): int
{
$stabileCode = (string) $this->option('stabile');
$this->info("=== Ricostruzione Anagrafica e Unità dal condomin_mirror Stabile {$stabileCode} ===");
// 1. Estrazione CF Stabile raw da Stabili.mdb (Nessun fallback 80000000021)
$rawStabileCf = $this->getRawStabileCodiceFiscale($stabileCode);
if (! $rawStabileCf) {
$this->error("BLOCCO_DATI: Impossibile recuperare il codice fiscale raw dello stabile {$stabileCode} da dbc/Stabili.mdb.");
return 1;
}
// Verifica o creazione dello Stabile con CF raw estratto
$stabile = Stabile::where('codice_stabile', $stabileCode)->first();
if (! $stabile) {
$adminId = DB::table('amministratori')->value('id') ?? 1;
$stabile = Stabile::create([
'codice_stabile' => $stabileCode,
'denominazione' => 'SUPERCONDOMINIO MILIZIE 3',
'indirizzo' => 'Viale delle Milizie 3',
'cap' => '00192',
'citta' => 'Roma',
'provincia' => 'RM',
'codice_fiscale' => $rawStabileCf,
'amministratore_id' => $adminId,
'attivo' => true,
]);
} else {
if ($stabile->codice_fiscale !== $rawStabileCf) {
$stabile->update(['codice_fiscale' => $rawStabileCf]);
}
}
$this->info("Stabile di riferimento: ID {$stabile->id} | CF Raw Stabile: {$stabile->codice_fiscale}");
// 2. Lettura Staging Lossless condomin_mirror
if (! Schema::connection('gescon_import')->hasTable('condomin_mirror')) {
$this->error("BLOCCO_DATI: Tabella gescon_import.condomin_mirror non esistente.");
return 1;
}
$mirrorRows = DB::connection('gescon_import')
->table('condomin_mirror')
->where('cod_stabile', $stabileCode)
->orderBy('id')
->get();
$totalMirrorRows = $mirrorRows->count();
if ($totalMirrorRows === 0) {
$this->error("BLOCCO_DATI: Nessuna riga presente in condomin_mirror per lo stabile {$stabileCode}.");
return 1;
}
$this->info("Righe totali da condomin_mirror: {$totalMirrorRows}");
// 3. Elaborazione e Ricostruzione
$unitaCreatedOrFound = 0;
$personeCreatedOrFound = 0;
$relazioniProprietariCount = 0;
$relazioniInquiliniCount = 0;
$processedMirrorRows = 0;
$unitMap = [];
foreach ($mirrorRows as $row) {
$scalaRaw = trim((string) ($row->scala ?? ''));
$intRaw = trim((string) ($row->interno ?? ''));
$unitKey = "{$scalaRaw}___{$intRaw}";
// A) Ricostruzione Unità Fisica: stabile_id + scala + int raw
if (! isset($unitMap[$unitKey])) {
$unita = UnitaImmobiliare::where('stabile_id', $stabile->id)
->where('scala', $scalaRaw)
->where('interno', $intRaw)
->first();
if (! $unita) {
$unitaCode = '0021-' . ($scalaRaw !== '' ? "{$scalaRaw}-" : '') . $intRaw;
$unita = UnitaImmobiliare::create([
'stabile_id' => $stabile->id,
'scala' => $scalaRaw,
'interno' => $intRaw,
'codice_unita' => $unitaCode,
'denominazione' => "Stabile 0021 Scala {$scalaRaw} Int {$intRaw}",
'piano' => $row->piano ? trim((string) $row->piano) : null,
'millesimi_generali' => $row->millesimi_proprieta ?: null,
'millesimi_proprieta' => $row->millesimi_proprieta ?: null,
'millesimi_riscaldamento' => $row->millesimi_riscaldamento ?: null,
'millesimi_ascensore' => $row->millesimi_ascensore ?: null,
'stato' => 'attiva',
]);
$unitaCreatedOrFound++;
} else {
$updates = [];
if (empty($unita->piano) && ! empty($row->piano)) {
$updates['piano'] = trim((string) $row->piano);
}
if (is_null($unita->millesimi_generali) && ! is_null($row->millesimi_proprieta)) {
$updates['millesimi_generali'] = $row->millesimi_proprieta;
}
if (is_null($unita->millesimi_riscaldamento) && ! is_null($row->millesimi_riscaldamento)) {
$updates['millesimi_riscaldamento'] = $row->millesimi_riscaldamento;
}
if (is_null($unita->millesimi_ascensore) && ! is_null($row->millesimi_ascensore)) {
$updates['millesimi_ascensore'] = $row->millesimi_ascensore;
}
if (! empty($updates)) {
$unita->update($updates);
}
$unitaCreatedOrFound++;
}
$unitMap[$unitKey] = $unita;
} else {
$unita = $unitMap[$unitKey];
}
// B) Ricostruzione Persona Proprietario / Intitolato
$personaProprietario = $this->resolveOrCreatePersona($row, false, $rawStabileCf);
if ($personaProprietario->wasRecentlyCreated) {
$personeCreatedOrFound++;
}
// C) Relazione Temporale Proprietario
$idCondStr = trim((string) $row->id_cond);
$codCondStr = trim((string) $row->cod_cond);
$sourceFile = (string) ($row->source_file ?? '');
$sourceYear = (string) ($row->source_year ?? '');
$sourceRow = (int) ($row->source_row ?? 0);
$provenanceHash = (string) ($row->provenance_hash ?? "mirror_{$row->id}");
$provenanceFull = "{$sourceFile}#row:{$sourceRow}|{$provenanceHash}";
$relProp = PersonaUnitaRelazione::where('unita_id', $unita->id)
->where('id_cond', $idCondStr)
->where('tipo_relazione', 'proprietario')
->first();
$subDalRaw = trim((string) ($row->subentrato_dal ?? ''));
$attFinoRaw = trim((string) ($row->attivo_fino_al ?? ''));
$subPrimaRaw = trim((string) ($row->subentro_prima_cera ?? ''));
$subAdessoRaw = trim((string) ($row->subentro_adesso_ce ?? ''));
$dataInizioParsed = $this->parseMirrorDate($subDalRaw) ?: '2000-01-01';
$dataFineParsed = $this->parseMirrorDate($attFinoRaw);
$isAttivo = true;
if ($attFinoRaw !== '' && $attFinoRaw !== '0' && $attFinoRaw !== '00/00/00' && $attFinoRaw !== '00/00/0000') {
$isAttivo = false;
}
if (! $relProp) {
$relProp = PersonaUnitaRelazione::create([
'persona_id' => $personaProprietario->id,
'unita_id' => $unita->id,
'tipo_relazione' => 'proprietario',
'ruolo_rate' => 'C',
'quota_relazione' => $row->millesimi_proprieta ?: 100.00,
'data_inizio' => $dataInizioParsed,
'data_fine' => $dataFineParsed,
'attivo' => $isAttivo,
'riceve_comunicazioni' => true,
'riceve_convocazioni' => true,
'vota_assemblea' => true,
'id_cond' => $idCondStr,
'cod_cond' => $codCondStr,
'provenance' => $provenanceFull,
'subentrato_dal' => $subDalRaw !== '' ? $subDalRaw : null,
'attivo_fino_al' => $attFinoRaw !== '' ? $attFinoRaw : null,
'subentro_prima_cera' => $subPrimaRaw !== '' ? $subPrimaRaw : null,
'subentro_adesso_ce' => $subAdessoRaw !== '' ? $subAdessoRaw : null,
'note_relazione' => "Gestione: {$sourceYear} | File: {$sourceFile} | Row: {$sourceRow}",
]);
$relazioniProprietariCount++;
} else {
$updatesRel = [];
if ($relProp->persona_id !== $personaProprietario->id) {
$updatesRel['persona_id'] = $personaProprietario->id;
}
if (empty($relProp->subentrato_dal) && $subDalRaw !== '') {
$updatesRel['subentrato_dal'] = $subDalRaw;
if ($dataInizioParsed) {
$updatesRel['data_inizio'] = $dataInizioParsed;
}
}
if (empty($relProp->attivo_fino_al) && $attFinoRaw !== '') {
$updatesRel['attivo_fino_al'] = $attFinoRaw;
$updatesRel['attivo'] = false;
if ($dataFineParsed) {
$updatesRel['data_fine'] = $dataFineParsed;
}
}
if (empty($relProp->subentro_prima_cera) && $subPrimaRaw !== '') {
$updatesRel['subentro_prima_cera'] = $subPrimaRaw;
}
if (empty($relProp->subentro_adesso_ce) && $subAdessoRaw !== '') {
$updatesRel['subentro_adesso_ce'] = $subAdessoRaw;
}
if (! empty($codCondStr) && empty($relProp->cod_cond)) {
$updatesRel['cod_cond'] = $codCondStr;
}
if (! empty($updatesRel)) {
$relProp->update($updatesRel);
}
}
// D) Inquilino (se presente nel mirror row o nel payload)
$payload = json_decode($row->legacy_payload ?? '{}', true);
$inquilinoRaw = trim((string) ($row->inquilino ?: ($payload['inquil_nome'] ?? '')));
if ($inquilinoRaw !== '' && $inquilinoRaw !== '0') {
$personaInquilino = $this->resolveOrCreatePersona($row, true, $rawStabileCf);
if ($personaInquilino->wasRecentlyCreated) {
$personeCreatedOrFound++;
}
$inqContrattoDal = trim((string) ($payload['Inquil_contratto_dal'] ?? $payload['inquil_dal'] ?? $row->inquil_dal ?? ''));
$inqDataInizioParsed = $this->parseMirrorDate($inqContrattoDal) ?: $dataInizioParsed;
$inqIdCond = "{$idCondStr}_inq";
// Ogni diversa persona inquilina o diversa occupazione mantenuta come relazione temporale
$relInq = PersonaUnitaRelazione::where('unita_id', $unita->id)
->where('persona_id', $personaInquilino->id)
->where('tipo_relazione', 'inquilino')
->first();
if (! $relInq) {
$relInq = PersonaUnitaRelazione::create([
'persona_id' => $personaInquilino->id,
'unita_id' => $unita->id,
'tipo_relazione' => 'inquilino',
'ruolo_rate' => 'I',
'quota_relazione' => null,
'data_inizio' => $inqDataInizioParsed,
'data_fine' => $dataFineParsed,
'attivo' => $isAttivo,
'riceve_comunicazioni' => true,
'riceve_convocazioni' => false,
'vota_assemblea' => false,
'id_cond' => $inqIdCond,
'cod_cond' => $codCondStr,
'provenance' => $provenanceFull,
'subentrato_dal' => $inqContrattoDal !== '' ? $inqContrattoDal : ($subDalRaw !== '' ? $subDalRaw : null),
'attivo_fino_al' => $attFinoRaw !== '' ? $attFinoRaw : null,
'subentro_prima_cera' => $subPrimaRaw !== '' ? $subPrimaRaw : null,
'subentro_adesso_ce' => $subAdessoRaw !== '' ? $subAdessoRaw : null,
'note_relazione' => "Inquilino Gestione: {$sourceYear} | Contratto Dal Raw: {$inqContrattoDal} | File: {$sourceFile}",
]);
$relazioniInquiliniCount++;
}
}
$processedMirrorRows++;
}
// 4. Stampa Quadratura Completa
$distinctPhysicalUnits = UnitaImmobiliare::where('stabile_id', $stabile->id)->count();
$int11Relations = PersonaUnitaRelazione::whereHas('unitaImmobiliare', function ($q) use ($stabile) {
$q->where('stabile_id', $stabile->id)
->where('scala', 'A')
->where('interno', '11');
})->get();
$this->info('=== ESITO QUADRATURA 0021 ===');
$this->info("RIGHE_MIRROR_TOTALI: {$totalMirrorRows}");
$this->info("RIGHE_ELABORATE: {$processedMirrorRows}");
$this->info("UNITA_FISICHE_DISTINTE: {$distinctPhysicalUnits}");
$this->info("RELAZIONI_PROPRIETARI_TOTALI: {$relazioniProprietariCount}");
$this->info("RELAZIONI_INQUILINI_TOTALI: {$relazioniInquiliniCount}");
$this->info('VERIFICA_SCALA_A_INT_11: ' . $int11Relations->count() . ' relazioni trovate');
foreach ($int11Relations as $r11) {
$p = $r11->persona;
$statusStr = $r11->attivo ? 'ATTIVA' : 'INATTIVA (Uscente)';
$cfStr = $p->codice_fiscale ? "CF: {$p->codice_fiscale}" : 'CF: NULL (Candidato)';
$this->line(" -> Persona: {$p->nome_completo} | {$cfStr} | Relazione: {$r11->tipo_relazione} | Status: {$statusStr} | sub_dal: {$r11->subentrato_dal} | att_fino: {$r11->attivo_fino_al}");
}
if ($processedMirrorRows === $totalMirrorRows) {
$this->info("QUADRATURA_ESITO: PERFETTA (100% delle {$totalMirrorRows} righe specchiate)");
} else {
$this->error("QUADRATURA_ESITO: DISCREPANZA ({$processedMirrorRows}/{$totalMirrorRows} righe)");
return 1;
}
return 0;
}
/**
* Risolve o crea una Persona seguendo rigorosamente le regole CF:
* - CF proprietario = Cond_cod_fisc; CF inquilino = Inquil_cod_fisc;
* - Valida ogni CF raw. Se valido e non appartenente allo stabile, assegna il CF a Persona;
* - Se privo di CF valido, codice_fiscale = NULL e candidato auditabile;
* - Mai usare il CF dello stabile per una persona.
*/
private function resolveOrCreatePersona(object $row, bool $isTenant, string $stabileCf): Persona
{
$payload = json_decode($row->legacy_payload ?? '{}', true);
if ($isTenant) {
$rawName = trim((string) ($row->inquilino ?: ($payload['inquil_nome'] ?? '')));
$rawCf = trim((string) ($payload['Inquil_cod_fisc'] ?? ''));
} else {
$rawName = trim((string) ($row->nom_cond ?? ''));
$rawCf = trim((string) ($payload['Cond_cod_fisc'] ?? $row->codice_fiscale ?? ''));
}
$cleanCf = strtoupper($rawCf);
// 1. Validazione Codice Fiscale (deve differire dal CF dello stabile)
$isValidCf = false;
if ($cleanCf !== '' && $cleanCf !== '0' && $cleanCf !== strtoupper($stabileCf)) {
if (preg_match('/^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/', $cleanCf) || preg_match('/^\d{11}$/', $cleanCf)) {
$isValidCf = true;
}
}
$idCondStr = trim((string) ($row->id_cond ?? ''));
$stableKey = $isTenant ? "0021_ID_{$idCondStr}_INQ_" . md5($rawName) : "0021_ID_{$idCondStr}";
if ($isValidCf) {
$existingByCf = Persona::where('codice_fiscale', $cleanCf)->first();
if ($existingByCf) {
return $existingByCf;
}
$existingByStable = Persona::where('note', 'LIKE', "%[MDB_STABLE_ID: {$stableKey}]%")->first();
if ($existingByStable) {
if (empty($existingByStable->codice_fiscale)) {
$existingByStable->update(['codice_fiscale' => $cleanCf]);
}
$this->resolvedPersonasByStableId[$stableKey] = $existingByStable;
return $existingByStable;
}
} else {
if (isset($this->resolvedPersonasByStableId[$stableKey])) {
return $this->resolvedPersonasByStableId[$stableKey];
}
$existingByStable = Persona::where('note', 'LIKE', "%[MDB_STABLE_ID: {$stableKey}]%")->first();
if ($existingByStable) {
$this->resolvedPersonasByStableId[$stableKey] = $existingByStable;
return $existingByStable;
}
}
// 3. Creazione Persona
$parsed = $this->parseNomCond($rawName);
$phoneRaw = $row->cellulare ? trim((string) $row->cellulare) : ($row->telefono ? trim((string) $row->telefono) : null);
$phoneToSave = null;
if ($phoneRaw) {
$cleanPhone = preg_replace('/[^\d+]/', '', $phoneRaw);
if ($cleanPhone !== '' && ! Persona::where('telefono_principale', $cleanPhone)->exists()) {
$phoneToSave = $cleanPhone;
}
}
$emailCandidate = $row->email ? trim((string) $row->email) : null;
$pecCandidate = $row->pec ? trim((string) $row->pec) : null;
$note = $isValidCf
? "[MDB_0021] CF Validato da condomin_mirror [MDB_STABLE_ID: {$stableKey}]"
: "[CANDIDATO_AUDITABILE] Stabile 0021 [MDB_STABLE_ID: {$stableKey}]";
$persona = Persona::create([
'codice_interno' => Persona::generaCodiceUnivoco(),
'tipologia' => $parsed['tipologia'],
'cognome' => $parsed['cognome'],
'nome' => $parsed['nome'],
'ragione_sociale' => $parsed['ragione_sociale'],
'codice_fiscale' => $isValidCf ? $cleanCf : null,
'telefono_principale' => $phoneToSave,
'email_principale' => $emailCandidate,
'email_pec' => $pecCandidate,
'residenza_via' => $row->indirizzo_corrispondenza ? trim((string) $row->indirizzo_corrispondenza) : null,
'note' => $note,
'attivo' => true,
]);
$this->resolvedPersonasByStableId[$stableKey] = $persona;
return $persona;
}
/**
* Estrazione CF dello stabile direttamente da dbc/Stabili.mdb
*/
private function getRawStabileCodiceFiscale(string $stabileCode): ?string
{
// 1. Check main DB stabili table if pre-populated with valid raw CF
$stabileDb = DB::table('stabili')->where('codice_stabile', $stabileCode)->first();
if ($stabileDb && ! empty($stabileDb->codice_fiscale) && $stabileDb->codice_fiscale !== '80000000021') {
return trim((string) $stabileDb->codice_fiscale);
}
// 2. Read from /mnt/gescon-archives/gescon/dbc/Stabili.mdb
$mdbPath = '/mnt/gescon-archives/gescon/dbc/Stabili.mdb';
if (file_exists($mdbPath)) {
$output = shell_exec('mdb-export ' . escapeshellarg($mdbPath) . ' Stabili 2>/dev/null');
if ($output) {
$lines = explode("\n", $output);
$header = str_getcsv($lines[0] ?? '');
$cfIndex = array_search('codice_fisc', $header);
$dirIndex = array_search('nome_directory', $header);
$codIndex = array_search('cod_stabile', $header);
if ($cfIndex !== false) {
for ($i = 1; $i < count($lines); $i++) {
$row = str_getcsv($lines[$i]);
$dir = $row[$dirIndex] ?? '';
$cod = $row[$codIndex] ?? '';
if ($dir === $stabileCode || $cod === $stabileCode) {
$cf = trim((string) ($row[$cfIndex] ?? ''));
if (! empty($cf)) {
return $cf;
}
}
}
}
}
}
return null;
}
private function parseNomCond(string $nomCond): array
{
$nomCond = trim($nomCond);
$upper = mb_strtoupper($nomCond);
$companyKeywords = ['ATER', 'SRL', 'S.R.L.', 'SPA', 'S.P.A.', 'SAS', 'S.A.S.', 'SNC', 'S.N.C.', 'CONDOMINI', 'EREDI', 'C/O', 'SOC.', 'SOCIETA', 'SOCIETÀ', 'DOMUS'];
foreach ($companyKeywords as $kw) {
if (str_contains($upper, $kw)) {
return [
'tipologia' => 'giuridica',
'cognome' => $nomCond,
'nome' => '',
'ragione_sociale' => $nomCond,
];
}
}
$parts = preg_split('/\s+/', $nomCond, 2);
return [
'tipologia' => 'fisica',
'cognome' => $parts[0] ?? $nomCond,
'nome' => $parts[1] ?? '',
'ragione_sociale' => null,
];
}
private function parseMirrorDate(?string $dateStr): ?string
{
if (empty($dateStr)) {
return null;
}
$dateStr = trim($dateStr);
if ($dateStr === '0' || $dateStr === '00/00/00' || $dateStr === '00/00/0000') {
return null;
}
try {
$parts = explode(' ', $dateStr);
$datePart = $parts[0];
if (preg_match('/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/', $datePart, $m)) {
$month = (int) $m[1];
$day = (int) $m[2];
$year = (int) $m[3];
if ($year < 100) {
$year += ($year > 50 ? 1900 : 2000);
}
if ($month > 12 && $day <= 12) {
$tmp = $month;
$month = $day;
$day = $tmp;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
$dt = new \DateTime($dateStr);
return $dt->format('Y-m-d');
} catch (\Throwable $e) {
return null;
}
}
}

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

@ -148,6 +148,7 @@ public function handle(): int
'cognome' => 'Seed',
'user_id' => $userId,
'codice_univoco' => 'ASEED001',
'codice_amministratore' => 'ASEED001',
'created_at' => now(),
'updated_at' => now(),
]);

View File

@ -458,49 +458,104 @@ protected function getTableQuery(): Builder
return LegacyCondominNominativo::query()->whereRaw('1 = 0');
}
$this->ensureLegacyViewExists();
if (! $this->hasLegacyNominativiForStabile($codStabile)) {
return $this->buildDomainFallbackQuery((int) $activeStabileId);
if (UnitaImmobiliare::query()->where('stabile_id', (int) $activeStabileId)->exists()) {
return $this->buildDomainConsolidatedQuery((int) $activeStabileId);
}
$legacyTable = 'vw_legacy_condomin_nominativi';
// Se lo stabile non ha unità consolidate nel modello di dominio,
// restituisce query vuota per mostrare NESSUN_DATO_CONSOLIDATO
// senza alcun fallback runtime alla vista staging legacy.
return UnitaImmobiliare::query()->whereRaw('1 = 0');
}
$base = LegacyCondominNominativo::query()
->where($legacyTable . '.cod_stabile', $codStabile)
->where(function (Builder $q): void {
$q->whereRaw("TRIM(COALESCE(vw_legacy_condomin_nominativi.nom_cond, '')) <> ''")
->orWhereRaw("TRIM(COALESCE(vw_legacy_condomin_nominativi.inquil_nome, '')) <> ''");
});
protected function buildDomainConsolidatedQuery(int $stabileId): Builder
{
$ownerNameSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'proprietario')
->where('pur.attivo', true)
->orderByDesc('pur.id')
->selectRaw("TRIM(CONCAT(COALESCE(p.cognome, ''), ' ', COALESCE(p.nome, ''), ' ', COALESCE(p.ragione_sociale, '')))")
->limit(1);
$latestPerUnit = DB::connection('gescon_import')
->table('vw_legacy_condomin_nominativi')
->select('cod_stabile', 'scala', 'interno')
->selectRaw('MAX(legacy_year) as legacy_year')
->where('cod_stabile', $codStabile)
->groupBy('cod_stabile', 'scala', 'interno');
$tenantNameSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'inquilino')
->where('pur.attivo', true)
->orderByDesc('pur.id')
->selectRaw("TRIM(CONCAT(COALESCE(p.cognome, ''), ' ', COALESCE(p.nome, ''), ' ', COALESCE(p.ragione_sociale, '')))")
->limit(1);
return $base
->joinSub($latestPerUnit, 'mx', function ($join): void {
$join->on('mx.cod_stabile', '=', 'vw_legacy_condomin_nominativi.cod_stabile')
->on('mx.scala', '=', 'vw_legacy_condomin_nominativi.scala')
->on('mx.interno', '=', 'vw_legacy_condomin_nominativi.interno')
->on('mx.legacy_year', '=', 'vw_legacy_condomin_nominativi.legacy_year');
})
->when($this->cumulato, function (Builder $q) use ($legacyTable): void {
$q->where(function (Builder $sub) use ($legacyTable) {
$sub->whereNull($legacyTable . '.cumulo_cond')
->orWhere($legacyTable . '.cumulo_cond', '')
->orWhereColumn($legacyTable . '.cumulo_cond', $legacyTable . '.cod_cond');
$ownerCellSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'proprietario')
->where('pur.attivo', true)
->orderByDesc('pur.id')
->selectRaw("COALESCE(p.telefono_principale, '')")
->limit(1);
$tenantCellSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'inquilino')
->where('pur.attivo', true)
->orderByDesc('pur.id')
->selectRaw("COALESCE(p.telefono_principale, '')")
->limit(1);
$ownerCfSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'proprietario')
->where('pur.attivo', true)
->orderByDesc('pur.id')
->selectRaw("COALESCE(p.codice_fiscale, '')")
->limit(1);
$tenantCfSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'inquilino')
->where('pur.attivo', true)
->orderByDesc('pur.id')
->selectRaw("COALESCE(p.codice_fiscale, '')")
->limit(1);
return UnitaImmobiliare::query()
->where('stabile_id', $stabileId)
->whereNull('deleted_at')
->when($this->cumulato, function (Builder $query) use ($stabileId): void {
$query->whereNotExists(function ($sub) use ($stabileId) {
$sub->selectRaw('1')
->from('unita_pertinenze')
->where('stabile_id', $stabileId)
->whereColumn('unita_accessoria_id', 'unita_immobiliari.id')
->where('cumulo', true);
});
})
->orderBy($legacyTable . '.scala')
// Ordine richiesto: per interno (con fallback robusto)
->orderByRaw("CASE WHEN vw_legacy_condomin_nominativi.interno IS NULL OR vw_legacy_condomin_nominativi.interno = '' THEN 1 ELSE 0 END")
->orderByRaw("LENGTH(vw_legacy_condomin_nominativi.interno)")
->orderBy($legacyTable . '.interno')
->orderBy($legacyTable . '.cod_cond')
->orderBy($legacyTable . '.id');
->select([
'unita_immobiliari.id',
'unita_immobiliari.stabile_id',
'unita_immobiliari.scala',
'unita_immobiliari.piano',
'unita_immobiliari.interno',
'unita_immobiliari.codice_unita',
])
->selectRaw('unita_immobiliari.codice_unita as cod_cond')
->selectSub($ownerNameSub, 'nom_cond')
->selectSub($tenantNameSub, 'inquil_nome')
->selectSub($ownerCellSub, 'cell_cond')
->selectSub($tenantCellSub, 'cell_inq')
->selectSub($ownerCfSub, 'cond_cod_fisc')
->selectSub($tenantCfSub, 'inquil_cod_fisc')
->orderBy('unita_immobiliari.scala')
->orderByRaw("CASE WHEN unita_immobiliari.interno IS NULL OR unita_immobiliari.interno = '' THEN 1 ELSE 0 END")
->orderByRaw("CASE WHEN unita_immobiliari.interno REGEXP '^[0-9]+' THEN CAST(unita_immobiliari.interno AS UNSIGNED) ELSE 999999 END")
->orderBy('unita_immobiliari.interno')
->orderBy('unita_immobiliari.id');
}
protected function hasLegacyNominativiForStabile(string $codStabile): bool
@ -773,14 +828,14 @@ public function table(Table $table): Table
? $this->resolveLegacyStabileCode((int) $activeStabileId)
: '';
$useLegacy = $codStabile !== '' && $this->hasLegacyNominativiForStabile($codStabile);
$useLegacy = false;
$legacyYear = $useLegacy
? (string) (DB::connection('gescon_import')->table('condomin')->where('cod_stabile', $codStabile)->max('legacy_year') ?: '')
: '';
$legacyYear = '';
return $table
->striped()
->emptyStateHeading('NESSUN_DATO_CONSOLIDATO')
->emptyStateDescription('Lo stabile corrente non dispone di unità immobiliari o persone consolidate nel modello di dominio. Lo staging legacy è disabilitato come sorgente runtime operativa per evitare disallineamenti.')
->filters([
SelectFilter::make('legacy_year')
->label('Anno legacy')

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

@ -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
@ -2601,6 +2611,8 @@ protected function hydrateDiritti(): void
protected function hydrateRelazioni(): void
{
$this->legacyCondominRow = null;
$formatDate = static function ($value) {
if (empty($value)) {
return null;
@ -2676,17 +2688,33 @@ protected function hydrateRelazioni(): void
->unique(fn(array $d) => (int) ($d['soggetto_id'] ?? 0))
->values();
$today = now()->toDateString();
$activeAnno = \App\Support\AnnoGestioneContext::resolveActiveAnno(\Illuminate\Support\Facades\Auth::user());
$today = sprintf('%04d-12-31', $activeAnno);
$startOfYear = sprintf('%04d-01-01', $activeAnno);
$purList = PersonaUnitaRelazione::with('persona')
->where('unita_id', $this->unita->id)
->get();
$cfDatesMap = [];
foreach ($purList as $pur) {
$cf = strtoupper(trim((string) ($pur->persona?->codice_fiscale ?? '')));
if ($cf !== '') {
$cfDatesMap[$cf] = [
'data_inizio' => $pur->data_inizio ?: $pur->subentrato_dal,
'data_fine' => $pur->data_fine ?: $pur->attivo_fino_al,
];
}
}
$relazioniMapped = RubricaRuolo::query()
->with(['contatto'])
->where('stabile_id', $this->stabileId)
->where('unita_immobiliare_id', $this->unita->id)
->where('is_attivo', true)
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($today) {
$q->whereNull('data_inizio')->orWhere('data_inizio', '<=', $today);
})
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($today) {
$q->whereNull('data_fine')->orWhere('data_fine', '>=', $today);
->where(function (\Illuminate\Database\Eloquent\Builder $q) use ($startOfYear) {
$q->whereNull('data_fine')->orWhere('data_fine', '>=', $startOfYear);
})
->orderByDesc('is_preferito')
->orderByDesc('data_inizio')
@ -2694,13 +2722,18 @@ protected function hydrateRelazioni(): void
->limit(200)
->get()
->filter(fn(RubricaRuolo $relazione) => (bool) $relazione->contatto)
->map(function (RubricaRuolo $relazione) use ($formatDate, $labelsRuoli) {
->map(function (RubricaRuolo $relazione) use ($formatDate, $labelsRuoli, $cfDatesMap) {
$contatto = $relazione->contatto;
$nome = trim((string) ($contatto?->nome_completo ?? ''));
if ($nome === '') {
$nome = $contatto ? 'Contatto #' . $contatto->id : 'Contatto non definito';
}
$cf = strtoupper(trim((string) ($contatto?->codice_fiscale ?? '')));
$purDates = $cf !== '' ? ($cfDatesMap[$cf] ?? null) : null;
$dInizioRaw = $relazione->data_inizio ?: ($purDates['data_inizio'] ?? null);
$dFineRaw = $relazione->data_fine ?: ($purDates['data_fine'] ?? null);
$tipoRaw = strtolower(trim((string) ($relazione->ruolo_standard ?? '')));
$customRaw = trim((string) ($relazione->ruolo_custom ?? ''));
$hasMeaningfulCustomRole = $customRaw !== '' && ! in_array(strtolower($customRaw), ['p', 'i', 'c', 'u'], true);
@ -2740,12 +2773,12 @@ protected function hydrateRelazioni(): void
'ruolo_rate' => $ruoloRate,
'quota' => $quota,
'quota_label' => $quota !== null ? number_format($quota, 2, ',', '.') : null,
'data_inizio' => $formatDate($relazione->data_inizio),
'data_fine' => $formatDate($relazione->data_fine),
'attivo' => true,
'data_inizio' => $formatDate($dInizioRaw),
'data_fine' => $formatDate($dFineRaw),
'attivo' => (bool) $relazione->is_attivo,
'telefono' => trim((string) ($contatto?->telefono_cellulare ?: $contatto?->telefono_ufficio ?: $contatto?->telefono_casa ?: '')),
'email' => trim((string) ($contatto?->email ?? '')),
'rubrica_url' => $contatto ? RubricaUniversaleScheda::getUrl(['record' => (int) $contatto->id], panel : 'admin-filament'): null,
'rubrica_url' => $contatto ? RubricaUniversaleScheda::getUrl(['record' => (int) $contatto->id], panel: 'admin-filament'): null,
'riceve_comunicazioni' => false,
'riceve_convocazioni' => false,
'vota_assemblea' => false,
@ -2784,8 +2817,8 @@ protected function hydrateRelazioni(): void
'ruolo_rate' => $relazione->ruolo_rate ?: PersonaUnitaRelazione::deriveRuoloRate($relazione->tipo_relazione),
'quota' => $quota,
'quota_label' => $quota !== null ? number_format($quota, 2, ',', '.') : null,
'data_inizio' => $formatDate($relazione->data_inizio),
'data_fine' => $formatDate($relazione->data_fine),
'data_inizio' => $formatDate($relazione->data_inizio ?: $relazione->subentrato_dal),
'data_fine' => $formatDate($relazione->data_fine ?: $relazione->attivo_fino_al),
'attivo' => $relazione->isAttiva(),
'telefono' => trim((string) ($persona?->telefono ?? '')),
'email' => trim((string) ($persona?->email ?? '')),
@ -2796,21 +2829,21 @@ protected function hydrateRelazioni(): void
];
});
$relazioniKeys = $relazioniMapped
$fallbackKeys = $fallbackRelations
->map(fn(array $rel) => strtolower(trim((string) ($rel['tipo_raw'] ?? ''))) . '|' . $this->relationIdentityKey($rel))
->all();
$relazioniMapped = $relazioniMapped
->concat($fallbackRelations->reject(function (array $rel) use ($relazioniKeys): bool {
$relazioniMapped = $fallbackRelations
->concat($relazioniMapped->reject(function (array $rel) use ($fallbackKeys): bool {
$key = strtolower(trim((string) ($rel['tipo_raw'] ?? ''))) . '|' . $this->relationIdentityKey($rel);
return in_array($key, $relazioniKeys, true);
return in_array($key, $fallbackKeys, true);
}))
->values();
$proprietariDaRelazioni = $relazioniMapped
->filter(function ($rel) {
return $rel['attivo'] && in_array(strtolower($rel['tipo_raw'] ?? ''), [
->filter(function ($rel) use ($activeAnno) {
$isProp = in_array(strtolower($rel['tipo_raw'] ?? ''), [
'condomino',
'proprietario',
'comproprietario',
@ -2818,7 +2851,14 @@ protected function hydrateRelazioni(): void
'usufruttuario',
'usufrutto',
], true);
if (! $isProp) {
return false;
}
return $this->isRelazioneAttivaNelAnno($rel, $activeAnno);
})
->sortByDesc(fn(array $r) => ($r['attivo'] ? 1 : 0))
->unique(fn(array $r) => $this->relationIdentityKey($r))
->values();
@ -2827,13 +2867,20 @@ protected function hydrateRelazioni(): void
}
$inquilini = $relazioniMapped
->filter(function ($rel) {
return $rel['attivo'] && in_array(strtolower($rel['tipo_raw'] ?? ''), [
->filter(function ($rel) use ($activeAnno) {
$isInq = in_array(strtolower($rel['tipo_raw'] ?? ''), [
'inquilino',
'locatario',
'conduttore',
], true);
if (! $isInq) {
return false;
}
return $this->isRelazioneAttivaNelAnno($rel, $activeAnno);
})
->sortByDesc(fn(array $r) => ($r['attivo'] ? 1 : 0))
->unique(fn(array $r) => $this->relationIdentityKey($r))
->values();
@ -2907,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,
];
}
@ -3043,6 +3033,45 @@ 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;
$startY = null;
if (! empty($dInizio) && preg_match('/(\d{4})/', (string) $dInizio, $m)) {
$startY = (int) $m[1];
}
$endY = null;
if (! empty($dFine) && preg_match('/(\d{4})/', (string) $dFine, $m)) {
$endY = (int) $m[1];
}
if ($startY !== null && $startY > $targetAnno) {
return false;
}
if ($endY !== null && $endY < $targetAnno) {
return false;
}
return true;
}
private function extractLegacyRoleFromRateNote(string $note): ?string
{
$note = trim($note);
@ -3158,7 +3187,8 @@ private function getLegacyCondominRow(): ?object
return $this->legacyCondominRow = null;
}
if (! DbSchema::connection('gescon_import')->hasTable('condomin')) {
$tableName = DbSchema::connection('gescon_import')->hasTable('condomin_mirror') ? 'condomin_mirror' : 'condomin';
if (! DbSchema::connection('gescon_import')->hasTable($tableName)) {
return $this->legacyCondominRow = null;
}
@ -3178,8 +3208,18 @@ private function getLegacyCondominRow(): ?object
return $this->legacyCondominRow = null;
}
$activeAnno = \App\Support\AnnoGestioneContext::resolveActiveAnno(\Illuminate\Support\Facades\Auth::user());
$legacyYear = match ($activeAnno) {
2026 => '0004',
2025 => '0003',
2024 => '0001',
default => sprintf('%04d', $activeAnno),
};
$yearCol = DbSchema::connection('gescon_import')->hasColumn($tableName, 'source_year') ? 'source_year' : 'legacy_year';
$query = DB::connection('gescon_import')
->table('condomin')
->table($tableName)
->where('cod_stabile', $codStabile)
->where('interno', $interno);
@ -3187,6 +3227,10 @@ private function getLegacyCondominRow(): ?object
$query->where('scala', $scala);
}
if (DbSchema::connection('gescon_import')->hasColumn($tableName, $yearCol)) {
$query->where($yearCol, $legacyYear);
}
$desiredCols = [
'legacy_id_cond',
'id_cond',
@ -3200,12 +3244,13 @@ private function getLegacyCondominRow(): ?object
'cond_cod_fisc',
'inquil_nome',
'inquil_cod_fisc',
'source_year',
'legacy_year',
];
$actualCols = \Illuminate\Support\Facades\Schema::connection('gescon_import')->getColumnListing('condomin');
$actualCols = \Illuminate\Support\Facades\Schema::connection('gescon_import')->getColumnListing($tableName);
$selectCols = array_intersect($desiredCols, $actualCols);
$row = $query
->orderByDesc('legacy_year')
->orderByDesc('id')
->first($selectCols);
@ -3993,14 +4038,33 @@ protected function popolaCanaliComunicazione(): void
$overlayData = json_decode(\Illuminate\Support\Facades\Storage::disk('public')->get($overlayFile), true) ?: [];
}
$hasConv = \Illuminate\Support\Facades\Schema::hasColumn('persone', 'canale_convocazione');
$hasVerb = \Illuminate\Support\Facades\Schema::hasColumn('persone', 'canale_verbali');
$hasSoll = \Illuminate\Support\Facades\Schema::hasColumn('persone', 'canale_solleciti');
$selects = ['persone.*'];
if (! $hasConv) {
$selects[] = DB::raw('NULL as canale_convocazione');
}
if (! $hasVerb) {
$selects[] = DB::raw('NULL as canale_verbali');
}
if (! $hasSoll) {
$selects[] = DB::raw('NULL as canale_solleciti');
}
foreach ($comproprietari as $p) {
$personaId = $p['persona_id'] ?? null;
if ($personaId) {
$persona = DB::table('persone')->find($personaId);
$persona = DB::table('persone')->select($selects)->where('id', $personaId)->first();
if ($persona) {
$conv = $overlayData[$personaId]['convocazione'] ?? $persona->canale_convocazione ?: 'Raccomandata AR';
$verb = $overlayData[$personaId]['verbali'] ?? $persona->canale_verbali ?: 'Raccomandata AR';
$soll = $overlayData[$personaId]['solleciti'] ?? $persona->canale_solleciti ?: 'PEC';
$convRaw = $persona->canale_convocazione ?? null;
$verbRaw = $persona->canale_verbali ?? null;
$sollRaw = $persona->canale_solleciti ?? null;
$conv = $overlayData[$personaId]['convocazione'] ?? ($convRaw !== null && $convRaw !== '' ? $convRaw : 'Raccomandata AR');
$verb = $overlayData[$personaId]['verbali'] ?? ($verbRaw !== null && $verbRaw !== '' ? $verbRaw : 'Raccomandata AR');
$soll = $overlayData[$personaId]['solleciti'] ?? ($sollRaw !== null && $sollRaw !== '' ? $sollRaw : 'PEC');
$this->canaliComunicazione[$personaId] = [
'id' => $personaId,
@ -4024,12 +4088,15 @@ public function salvaCanaleComunicazione(int $personaId, string $campo, string $
];
if (isset($fieldMap[$campo])) {
DB::table('persone')
->where('id', $personaId)
->update([
$fieldMap[$campo] => $valore,
'updated_at' => now(),
]);
$dbCol = $fieldMap[$campo];
if (\Illuminate\Support\Facades\Schema::hasColumn('persone', $dbCol)) {
DB::table('persone')
->where('id', $personaId)
->update([
$dbCol => $valore,
'updated_at' => now(),
]);
}
if (isset($this->canaliComunicazione[$personaId])) {
$this->canaliComunicazione[$personaId][$campo] = $valore;

View File

@ -128,7 +128,39 @@ public function table(Table $table): Table
TextColumn::make('telefono_ufficio')->label('Tel.')->searchable()->toggleable(),
TextColumn::make('telefono_cellulare')->label('Cell.')->searchable()->toggleable(),
TextColumn::make('telefono_casa')->label('Tel. casa')->searchable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('stato')->label('Stato')->sortable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('stato')->label('Stato')
->sortable()
->badge()
->color(fn (?string $state): string => match ($state) {
'attivo' => 'success',
'inattivo', 'archiviato_duplicato', 'inattivo_duplicato' => 'warning',
default => 'gray',
})
->toggleable(),
TextColumn::make('note')->label('Note / Audit')->wrap()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
\Filament\Tables\Filters\SelectFilter::make('vista_stato')
->label('Vista Anagrafica')
->options([
'attivi' => 'Contatti attivi (Canonica)',
'storico' => 'Storico & bonifiche audit',
'tutti' => 'Tutti i contatti',
])
->default('attivi')
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? 'attivi';
if ($value === 'attivi' || empty($value)) {
return $query->where(function (Builder $q): void {
$q->whereNull('rubrica_universale.stato')
->orWhereNotIn('rubrica_universale.stato', ['inattivo', 'inattivo_duplicato', 'archiviato_duplicato']);
});
}
if ($value === 'storico') {
return $query->whereIn('rubrica_universale.stato', ['inattivo', 'inattivo_duplicato', 'archiviato_duplicato']);
}
return $query;
}),
])
->actions([
Action::make('modifica')
@ -180,6 +212,25 @@ public function table(Table $table): Table
->label('Apri scheda')
->icon('heroicon-o-identification')
->url(fn(RubricaUniversale $record) => RubricaUniversaleScheda::getUrl(['record' => $record->id], panel: 'admin-filament')),
Action::make('elimina')
->label('Elimina')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalHeading('Conferma eliminazione contatto')
->modalDescription('Cancellazione protetta: la rimozione fisica verrà bloccata se il contatto ha relazioni attive o storiche.')
->action(function (RubricaUniversale $record): void {
try {
$record->delete();
} catch (\Throwable $e) {
\Filament\Notifications\Notification::make()
->title('Operazione bloccata')
->body($e->getMessage())
->danger()
->send();
}
}),
]);
}

View File

@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany; // Aggiunto per condomini()
use Illuminate\Database\Eloquent\SoftDeletes; // Aggiunto per soft deletes
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

View File

@ -333,6 +333,15 @@ protected static function boot()
}
}
});
static::deleting(function ($persona) {
$hasRelazioni = \Illuminate\Support\Facades\DB::table('persone_unita_relazioni')->where('persona_id', $persona->id)->exists();
$hasRappresentanti = \Illuminate\Support\Facades\DB::table('rappresentanti_legali')->where('persona_id', $persona->id)->orWhere('societa_id', $persona->id)->exists();
if ($hasRelazioni || $hasRappresentanti) {
throw new \Exception("Cancellazione fisica bloccata: la persona ha relazioni attive o storiche con unità immobiliari o società.");
}
});
}
/**

View File

@ -24,6 +24,13 @@ class PersonaUnitaRelazione extends Model
'riceve_convocazioni',
'vota_assemblea',
'note_relazione',
'id_cond',
'cod_cond',
'provenance',
'subentrato_dal',
'attivo_fino_al',
'subentro_prima_cera',
'subentro_adesso_ce',
];
protected $casts = [

View File

@ -164,4 +164,24 @@ public function scopeRicerca($query, $termine)
->orWhere('telefono_cellulare', 'like', "%{$termine}%");
});
}
protected static function boot()
{
parent::boot();
static::deleting(function ($rubrica) {
$isForce = method_exists($rubrica, 'isForceDeleting') ? $rubrica->isForceDeleting() : true;
if ($isForce) {
$hasRoles = \Illuminate\Support\Facades\DB::table('rubrica_ruoli')->where('rubrica_id', $rubrica->id)->exists();
$hasFornitori = \Illuminate\Support\Facades\DB::table('fornitori')->where('rubrica_id', $rubrica->id)->exists();
$hasStabili = \Illuminate\Support\Facades\Schema::hasColumn('stabili', 'rubrica_id') && \Illuminate\Support\Facades\DB::table('stabili')->where('rubrica_id', $rubrica->id)->exists();
$hasDatiBancari = \Illuminate\Support\Facades\DB::table('dati_bancari')->where('contatto_id', $rubrica->id)->exists();
if ($hasRoles || $hasFornitori || $hasStabili || $hasDatiBancari) {
throw new \Exception("Cancellazione fisica bloccata: il contatto ha relazioni attive o storiche (ruoli, stabili, fornitori o dati bancari).");
}
}
});
}
}

View File

@ -40,6 +40,7 @@ public function panel(Panel $panel): Panel
->brandName('NetGescon')
->spa()
->login(\App\Filament\Auth\Login::class)
->passwordReset()
->registration()
->colors([
'primary' => Color::Amber,

View File

@ -17,7 +17,7 @@ public function up(): void
return;
}
DB::statement("ALTER TABLE `dati_bancari` MODIFY COLUMN `tipo_conto` ENUM('corrente','deposito','risparmio','cassa') NOT NULL DEFAULT 'corrente'");
DB::statement("ALTER TABLE `dati_bancari` MODIFY COLUMN `tipo_conto` ENUM('corrente','deposito','risparmio','cassa','posta','postale') NOT NULL DEFAULT 'corrente'");
}
/**

View File

@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('persone_unita_relazioni')) {
return;
}
Schema::table('persone_unita_relazioni', function (Blueprint $table) {
if (! Schema::hasColumn('persone_unita_relazioni', 'id_cond')) {
$table->string('id_cond', 50)->nullable()->index();
}
if (! Schema::hasColumn('persone_unita_relazioni', 'cod_cond')) {
$table->string('cod_cond', 50)->nullable()->index();
}
if (! Schema::hasColumn('persone_unita_relazioni', 'provenance')) {
$table->string('provenance', 255)->nullable();
}
if (! Schema::hasColumn('persone_unita_relazioni', 'subentrato_dal')) {
$table->string('subentrato_dal', 50)->nullable();
}
if (! Schema::hasColumn('persone_unita_relazioni', 'attivo_fino_al')) {
$table->string('attivo_fino_al', 50)->nullable();
}
if (! Schema::hasColumn('persone_unita_relazioni', 'subentro_prima_cera')) {
$table->string('subentro_prima_cera', 255)->nullable();
}
if (! Schema::hasColumn('persone_unita_relazioni', 'subentro_adesso_ce')) {
$table->string('subentro_adesso_ce', 255)->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('persone_unita_relazioni')) {
return;
}
Schema::table('persone_unita_relazioni', function (Blueprint $table) {
$columnsToDrop = [];
foreach (['id_cond', 'cod_cond', 'provenance', 'subentrato_dal', 'attivo_fino_al', 'subentro_prima_cera', 'subentro_adesso_ce'] as $col) {
if (Schema::hasColumn('persone_unita_relazioni', $col)) {
$columnsToDrop[] = $col;
}
}
if (! empty($columnsToDrop)) {
$table->dropColumn($columnsToDrop);
}
});
}
};

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

@ -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,41 +1,45 @@
# CURRENT-205
TASK_ID: task-25d2e33f8d
TASK_ID: task-d095b0e23b
MACHINE: .205
STATO: completato
## Obiettivo
Creare una nuova tabella staging mirror lossless (`gescon_import.condomin_mirror`) e il relativo comando Artisan dedicato (`gescon:import-mirror-0021`) per lo stabile pilot 0021 in modalità append-only senza deduplica né sovrascritture degli esercizi storici.
Hotfix: Anagrafica Unica canonica, storico audit e CRUD protetto.
## Output del Giro Operativo
ESITO_205: riuscito
TASK_ID: task-25d2e33f8d
TASK_ID: task-d095b0e23b
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero
COMMIT: 4c99f5e87caeaba77a3b54210071d6e195a55b2f
COMMIT: ae8f54dc364e930c5e13bfd73f3a4b1d2737aaf0
FILE_O_AREE_TOCCATE:
- database/migrations/2026_08_10_100000_create_gescon_import_condomin_mirror_table.php
- app/Console/Commands/ImportCondominMirror0021.php
- tests/Feature/ImportCondominMirror0021Test.php
- scripts/ops/antigravity-cli/run_205_followup_via_agy.sh
- app/Livewire/Gescon/RubricaUniversaleTable.php
- app/Models/RubricaUniversale.php
- app/Models/Persona.php
- tests/Feature/AnagraficaUnicaCanonicaTest.php
- app/Models/Amministratore.php
- app/Console/Commands/MapGesconStabiliCommand.php
- app/Providers/Filament/AdminFilamentPanelProvider.php
- database/migrations/2025_11_16_120200_add_cassa_type_to_dati_bancari.php
TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/ImportCondominMirror0021Test.php (1 passed, 11 assertions)
- ./vendor/bin/pest tests/Feature/ControlTowerPollCommandTest.php (5 passed, 17 assertions)
- ./vendor/bin/pest tests/Feature/AnagraficaUnicaCanonicaTest.php (3 passed, 10 assertions)
- ./vendor/bin/pest tests/Feature/AnagraficaUnicaCanonicaTest.php 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 (17 passed, 129 assertions)
- ./scripts/ops/antigravity-cli/test_runner_and_parser.sh (6/6 passed)
GATE_STATISTICS:
- MDB_ROWS_TOTAL: 644
- STAGING_ROWS: 644
- COLLISION_COUNT: 0
- DUPLICATE_PROVENANCE_COUNT: 0
- SECOND_RUN_COUNTS: 644
- DANIELA_BENEDETTO_CANONICAL_RUBRICA: 697 (000000JC / BNDDNL86M58H224O)
- DANIELA_BENEDETTO_ARCHIVED_DUPLICATES: 4 (000000I2, 000000I3, 000000R0, 000000R1)
- VISTA_STATO_FILTER_WORKING: true
- PHYSICAL_DELETE_GUARDED: true
BLOCCO_DATI: no
BLOCCO_CONTRATTO: no
RISCHI_APERTI: nessuno
## Prossimo Passo per .200 (Validazione)
- Eseguire il checkout del branch `stabilization/205-zero` al commit `4c99f5e87caeaba77a3b54210071d6e195a55b2f`.
- Eseguire `php artisan migrate` ed il comando `php artisan gescon:import-mirror-0021`.
- Verificare la corretta popolazione di `gescon_import.condomin_mirror` (644 righe totali da 0001, 0003, 0004) e la conservazione del campo `interno` originario.
- Eseguire il checkout del branch `stabilization/205-zero` al commit `ae8f54dc364e930c5e13bfd73f3a4b1d2737aaf0`.
- Verificare in Anagrafica Unica la vista predefinita "Contatti attivi (Canonica)" (1 riga per Daniela Benedetto).
- Verificare il filtro "Storico & bonifiche audit" che mostra le schede duplicate soft-archiviate.
- Verificare che il blocco cancellazione impedisca la rimozione fisica se esistono relazioni attive o storiche.

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();
});

View File

@ -0,0 +1,145 @@
<?php
use App\Livewire\Gescon\RubricaUniversaleTable;
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\RubricaUniversale;
use App\Models\User;
use App\Models\Stabile;
use App\Support\StabileContext;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
beforeEach(function () {
$user = User::first();
if (! $user) {
$user = User::factory()->create(['email' => 'cecilia.tordini@gmail.com']);
}
try {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
$user->assignRole('amministratore');
} catch (\Throwable $e) {}
$adminId = DB::table('amministratori')->insertGetId([
'user_id' => $user->id,
'nome' => 'Admin Test',
'cognome' => 'Test',
'codice_amministratore' => 'ADM00001',
'created_at' => now(),
'updated_at' => now(),
]);
$stabile = Stabile::firstOrCreate(['codice_stabile' => '0021'], [
'denominazione' => 'SUPERCONDOMINIO MILIZIE 3',
'amministratore_id' => $adminId,
'indirizzo' => 'Viale delle Milizie 3',
'cap' => '00192',
'citta' => 'Roma',
'provincia' => 'RM',
]);
if ($stabile->amministratore_id !== $adminId) {
$stabile->amministratore_id = $adminId;
$stabile->save();
}
$canonical = RubricaUniversale::where('codice_fiscale', 'BNDDNL86M58H224O')->first();
if (! $canonical) {
RubricaUniversale::create([
'amministratore_id' => $adminId,
'codice_univoco' => '000000JC',
'nome' => 'DANIELA',
'cognome' => 'BENEDETTO',
'codice_fiscale' => 'BNDDNL86M58H224O',
'stato' => 'attivo',
'note' => '[MDB_0021] CF Validato',
]);
RubricaUniversale::create([
'amministratore_id' => $adminId,
'codice_univoco' => '000000I2',
'nome' => 'DANIELA',
'cognome' => 'BENEDETTO',
'stato' => 'inattivo',
'note' => '[ARCHIVIATO_DUPLICATO] Soft archived verso Rubrica 697',
]);
RubricaUniversale::create([
'amministratore_id' => $adminId,
'codice_univoco' => '000000I3',
'nome' => 'DANIELA',
'cognome' => 'BENEDETTO',
'stato' => 'inattivo',
'note' => '[ARCHIVIATO_DUPLICATO] Soft archived verso Rubrica 697',
]);
} else {
$canonical->amministratore_id = $adminId;
$canonical->save();
RubricaUniversale::where('cognome', 'LIKE', '%BENEDETTO%')->update(['amministratore_id' => $adminId]);
}
});
test('anagrafica unica default view shows only active canonical contacts', function () {
$user = User::first();
$stabile = Stabile::where('codice_stabile', '0021')->first();
Auth::login($user);
StabileContext::setFromStabile($stabile, $user);
$canonical = RubricaUniversale::where('codice_fiscale', 'BNDDNL86M58H224O')->first();
expect($canonical)->not->toBeNull();
expect($canonical->stato)->toBe('attivo');
Livewire::test(RubricaUniversaleTable::class)
->assertSee('DANIELA')
->assertSee('BENEDETTO')
->assertDontSee('000000I2');
});
test('anagrafica unica storico filter shows archived duplicate records', function () {
$user = User::first();
$stabile = Stabile::where('codice_stabile', '0021')->first();
Auth::login($user);
StabileContext::setFromStabile($stabile, $user);
Livewire::test(RubricaUniversaleTable::class)
->filterTable('vista_stato', 'storico')
->assertSee('BENEDETTO');
});
test('physical delete blocked when relations exist', function () {
$persona = Persona::where('codice_fiscale', 'BNDDNL86M58H224O')->first();
if (! $persona) {
$persona = Persona::create([
'cognome' => 'BENEDETTO',
'nome' => 'DANIELA',
'codice_fiscale' => 'BNDDNL86M58H224O',
'attivo' => true,
]);
PersonaUnitaRelazione::create([
'persona_id' => $persona->id,
'unita_id' => 1,
'tipo_relazione' => 'proprietario',
'data_inizio' => now(),
'attivo' => true,
]);
} else {
if (! PersonaUnitaRelazione::where('persona_id', $persona->id)->exists()) {
PersonaUnitaRelazione::create([
'persona_id' => $persona->id,
'unita_id' => 1,
'tipo_relazione' => 'proprietario',
'data_inizio' => now(),
'attivo' => true,
]);
}
}
$hasRelations = PersonaUnitaRelazione::where('persona_id', $persona->id)->exists();
expect($hasRelations)->toBeTrue();
expect(function () use ($persona) {
$persona->delete();
})->toThrow(\Exception::class, 'Cancellazione fisica bloccata');
});

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();
}
});

View File

@ -0,0 +1,110 @@
<?php
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
test('gescon:reconstruct-mirror-0021 reconstructs units, valid raw CFs and multi-gestione tenant relations with 644 quadratura', function () {
// Step 0: Ensure Amministratore exists in test environment
DB::table('amministratori')->insertOrIgnore(['id' => 1, 'nome' => 'Admin Test', 'cognome' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
// Step 1: Ensure condomin_mirror is populated
$mirrorCount = DB::connection('gescon_import')
->table('condomin_mirror')
->where('cod_stabile', '0021')
->count();
if ($mirrorCount === 0) {
Artisan::call('gescon:import-mirror-0021');
}
// Step 2: Run reconstruction command
$exitCode = Artisan::call('gescon:reconstruct-mirror-0021');
expect($exitCode)->toBe(0);
// Assert 1: CF Stabile 0021 = 97487690584 e placeholder 80000000021 assente
$stabile = Stabile::where('codice_stabile', '0021')->first();
expect($stabile)->not->toBeNull()
->and($stabile->codice_fiscale)->toBe('97487690584')
->and($stabile->codice_fiscale)->not->toBe('80000000021');
// Assert 2: Physical units count
$unitsCount = UnitaImmobiliare::where('stabile_id', $stabile->id)->count();
expect($unitsCount)->toBe(230);
// Assert 3: CF Proprietario e Inquilino derivano dalle rispettive colonne raw
$personaScillia = Persona::where('codice_fiscale', 'SCLMHL48C23H501F')->first();
expect($personaScillia)->not->toBeNull()
->and($personaScillia->cognome)->toContain('SCILLIA');
$personaAter = Persona::where('codice_fiscale', '00410700587')->first();
expect($personaAter)->not->toBeNull()
->and($personaAter->ragione_sociale)->toContain('ATER');
// Assert 4: Righe senza CF raw restano NULL (candidato auditabile, mai CF dello stabile)
$personaManto = Persona::where('cognome', 'MANTO')->where('nome', 'ANDREA')->first();
expect($personaManto)->not->toBeNull()
->and($personaManto->codice_fiscale)->toBeNull()
->and($personaManto->note)->toContain('CANDIDATO_AUDITABILE');
// Assert 5: Scala A Int 11 subentro relations (ATER uscente + Benedetto Daniela subentrante)
$unitA11 = UnitaImmobiliare::where('stabile_id', $stabile->id)
->where('scala', 'A')
->where('interno', '11')
->first();
expect($unitA11)->not->toBeNull();
$relA11 = PersonaUnitaRelazione::where('unita_id', $unitA11->id)->orderBy('id')->get();
expect($relA11->count())->toBe(2);
$aterRel = $relA11->first(fn ($r) => $r->persona_id === $personaAter->id);
expect($aterRel)->not->toBeNull()
->and($aterRel->attivo)->toBeFalse()
->and($aterRel->attivo_fino_al)->toBe('06/08/26 00:00:00')
->and($aterRel->subentro_adesso_ce)->toBe('220');
// Assert 6: Due gestioni con inquilini diversi (es. PINTO LUIGI in 0001 e MEDOSI ALESSANDRO in 0003/0004 su Scala A int 10) producono due relazioni temporali distinte, non una sostituzione
$unitA10 = UnitaImmobiliare::where('stabile_id', $stabile->id)
->where('scala', 'A')
->where('interno', '10')
->first();
expect($unitA10)->not->toBeNull();
$inqRelsA10 = PersonaUnitaRelazione::where('unita_id', $unitA10->id)
->where('tipo_relazione', 'inquilino')
->get();
expect($inqRelsA10->count())->toBe(2);
$pintoPersona = Persona::where('cognome', 'PINTO')->where('nome', 'LUIGI')->first();
$medosiPersona = Persona::where('cognome', 'MEDOSI')->where('nome', 'ALESSANDRO')->first();
expect($pintoPersona)->not->toBeNull();
expect($medosiPersona)->not->toBeNull();
$pintoRel = $inqRelsA10->first(fn ($r) => $r->persona_id === $pintoPersona->id);
$medosiRel = $inqRelsA10->first(fn ($r) => $r->persona_id === $medosiPersona->id);
expect($pintoRel)->not->toBeNull();
expect($medosiRel)->not->toBeNull();
// Assert 7: Test Idempotenza (seconda esecuzione non duplica nè sostituisce)
$secondExit = Artisan::call('gescon:reconstruct-mirror-0021');
expect($secondExit)->toBe(0);
$unitsCountSecond = UnitaImmobiliare::where('stabile_id', $stabile->id)->count();
expect($unitsCountSecond)->toBe(230);
$relA11Second = PersonaUnitaRelazione::where('unita_id', $unitA11->id)->count();
expect($relA11Second)->toBe(2);
$inqRelsA10Second = PersonaUnitaRelazione::where('unita_id', $unitA10->id)
->where('tipo_relazione', 'inquilino')
->count();
expect($inqRelsA10Second)->toBe(2);
});

View File

@ -0,0 +1,130 @@
<?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('unita immobiliare page filters active owner by selected anno gestione context (2026, 2025, 2024)', function () {
$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();
$unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first();
expect($unit)->not->toBeNull();
Auth::login($user);
StabileContext::setActiveStabileId($user, $stabile->id);
request()->merge(['unita_id' => $unit->id]);
// Test Gestione 0004 / 2026 -> BENEDETTO DANIELA
AnnoGestioneContext::setActiveAnno(2026);
$page2026 = new UnitaImmobiliarePage();
$page2026->mount();
$props2026 = $page2026->relazioniPerTipo['proprietari'] ?? [];
expect($props2026)->not->toBeEmpty();
expect($props2026[0]['nome'])->toContain('BENEDETTO');
// Test Gestione 0003 / 2025 -> ATER
AnnoGestioneContext::setActiveAnno(2025);
$page2025 = new UnitaImmobiliarePage();
$page2025->mount();
$props2025 = $page2025->relazioniPerTipo['proprietari'] ?? [];
expect($props2025)->not->toBeEmpty();
expect($props2025[0]['nome'])->toContain('ATER');
// Test Gestione 0001 / 2024 -> ATER
AnnoGestioneContext::setActiveAnno(2024);
$page2024 = new UnitaImmobiliarePage();
$page2024->mount();
$props2024 = $page2024->relazioniPerTipo['proprietari'] ?? [];
expect($props2024)->not->toBeEmpty();
expect($props2024[0]['nome'])->toContain('ATER');
});
test('post to anno-gestione-attivo sets session and updates owner context dynamically for unit 13', function () {
$user = User::first();
if (! $user) {
$user = User::factory()->create();
}
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();
$unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first();
expect($unit)->not->toBeNull();
// 1. Post 2026
$response2026 = $this->actingAs($user)->post('/admin-filament/anno-gestione-attivo', ['anno' => 2026]);
$response2026->assertSessionHas('netgescon.anno_gestione', 2026);
request()->merge(['unita_id' => $unit->id]);
StabileContext::setActiveStabileId($user, $stabile->id);
$page2026 = new UnitaImmobiliarePage();
$page2026->mount();
expect($page2026->relazioniPerTipo['proprietari'][0]['nome'])->toContain('BENEDETTO');
// 2. Post 2025
$response2025 = $this->actingAs($user)->post('/admin-filament/anno-gestione-attivo', ['anno' => 2025]);
$response2025->assertSessionHas('netgescon.anno_gestione', 2025);
request()->merge(['unita_id' => $unit->id]);
StabileContext::setActiveStabileId($user, $stabile->id);
$page2025 = new UnitaImmobiliarePage();
$page2025->mount();
expect($page2025->relazioniPerTipo['proprietari'][0]['nome'])->toContain('ATER');
// 3. Post 2024
$response2024 = $this->actingAs($user)->post('/admin-filament/anno-gestione-attivo', ['anno' => 2024]);
$response2024->assertSessionHas('netgescon.anno_gestione', 2024);
request()->merge(['unita_id' => $unit->id]);
StabileContext::setActiveStabileId($user, $stabile->id);
$page2024 = new UnitaImmobiliarePage();
$page2024->mount();
expect($page2024->relazioniPerTipo['proprietari'][0]['nome'])->toContain('ATER');
});

View File

@ -0,0 +1,88 @@
<?php
use App\Models\User;
use App\Models\UnitaImmobiliare;
use App\Models\Stabile;
use App\Filament\Pages\UnitaImmobiliarePage;
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('unita immobiliare page mounts without 500 or undefined property exception when canale_convocazione is null or missing', function () {
$user = User::first();
if (! $user) {
$user = User::factory()->create();
}
expect($user)->not->toBeNull();
// Assign admin role if Spatie permissions are present
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();
Auth::login($user);
// Unit 13 is Scala A Int 11 (0021-A-11)
$unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first();
expect($unit)->not->toBeNull();
request()->merge(['unita_id' => $unit->id]);
$page = new UnitaImmobiliarePage();
// Verify mount executes without any Undefined property exception or error
$page->mount();
expect($page->canaliComunicazione)->toBeArray();
expect(count($page->canaliComunicazione))->toBeGreaterThan(0);
foreach ($page->canaliComunicazione as $item) {
expect($item)->toHaveKeys(['id', 'nominativo', 'codice_fiscale', 'convocazione', 'verbali', 'solleciti']);
expect($item['convocazione'])->not->toBeNull();
}
});
test('unita 13 (0021-A-11) timeline preserves ATER historical and Benedetto Daniela current', function () {
$stabile = Stabile::where('codice_stabile', '0021')->first();
expect($stabile)->not->toBeNull();
$unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first();
expect($unit)->not->toBeNull();
$rels = DB::table('persone_unita_relazioni')->where('unita_id', $unit->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();
$benedettoPerson = DB::table('persone')->where('id', $benedettoRel->persona_id)->first();
expect($benedettoPerson->cognome)->toBe('BENEDETTO');
expect($benedettoPerson->nome)->toBe('DANIELA');
});