feat(fe-nominativi-adminer): normalizzazione nominativi canonici, scansione FE con matching CF e console Adminer

This commit is contained in:
michele 2026-09-06 20:14:21 +02:00
parent 5f08c1bb0a
commit f137efd0d2
9 changed files with 662 additions and 117 deletions

View File

@ -0,0 +1,292 @@
<?php
namespace App\Console\Commands;
use App\Models\FatturaElettronica;
use App\Models\OperazioneContabile;
use App\Models\Stabile;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use App\Services\FattureElettroniche\FatturaElettronicaXmlParser;
use App\Services\FattureElettroniche\P7mExtractor;
use App\Services\TenantArchivePathService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
class ScanAndImportFeFilesCommand extends Command
{
protected $signature = 'gescon:scan-import-fe
{--dir= : Directory specifica da scansionare}
{--stabile= : Filtra per codice stabile (es. 0013, 0021)}
{--dry-run : Esegui solo simulazione senza copiare scrivere su DB}';
protected $description = 'Scansiona l\'intero archivio di backup, assegna le FE agli stabili tramite Codice Fiscale, copia i file nelle rispettive cartelle e importa nel DB';
public function handle(
FatturaElettronicaXmlParser $parser,
FatturaElettronicaImporter $importer,
P7mExtractor $p7mExtractor,
TenantArchivePathService $pathService
): int {
$this->info('======================================================================');
$this->info(' NETGESCON: SCANSIONE, MATCHING FISCALE & IMPORTAZIONE FATTURE FE ');
$this->info('======================================================================');
$isDryRun = (bool) $this->option('dry-run');
$filterStabile = $this->option('stabile');
$customDir = $this->option('dir');
// 1. Carica gli stabili canonici con Codice Fiscale
$stabiliQuery = Stabile::whereNotNull('codice_fiscale')->where('codice_fiscale', '<>', '');
if ($filterStabile) {
$stabiliQuery->where('codice_stabile', $filterStabile);
}
$stabili = $stabiliQuery->get();
if ($stabili->isEmpty()) {
$this->error('Nessuno stabile canonico con Codice Fiscale configurato trovato.');
return Command::FAILURE;
}
$cfMap = [];
foreach ($stabili as $s) {
$cleanCf = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string) $s->codice_fiscale));
if ($cleanCf !== '') {
$cfMap[$cleanCf] = $s;
}
}
$this->info("Stabili target attivi: " . count($cfMap));
foreach ($cfMap as $cf => $s) {
$this->line(" - Stabile [{$s->codice_stabile}] {$s->denominazione} (CF: {$cf})");
}
// 2. Determina le directory sorgente da scansionare
$scanRoots = [];
if ($customDir) {
if (! is_dir($customDir)) {
$this->error("Directory non trovata: {$customDir}");
return Command::FAILURE;
}
$scanRoots[] = realpath($customDir);
} else {
$basePath = base_path();
$candidates = [
$basePath . '/_legacy-vault',
$basePath . '/Miki-Bug-workspace',
$basePath . '/storage/gescon',
$basePath . '/storage/app/amministratori',
$basePath . '/storage/app/private/amministratori',
$basePath . '/storage/app/public',
];
foreach ($candidates as $c) {
if (is_dir($c)) {
$scanRoots[] = realpath($c);
}
}
}
$this->info("\nCartelle sorgente incluse nella scansione: " . count($scanRoots));
// 3. Raccogli tutti i file .xml e .p7m
$foundFiles = [];
$excludedFolders = ['/vendor/', '/node_modules/', '/.git/', '/.gemini/', '/storage/framework/', '/bootstrap/cache/'];
foreach ($scanRoots as $root) {
$this->line(" Scansione: {$root}...");
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = $file->getRealPath();
$lowerPath = strtolower($path);
$skip = false;
foreach ($excludedFolders as $ex) {
if (str_contains($lowerPath, $ex)) {
$skip = true;
break;
}
}
if ($skip) {
continue;
}
if (str_ends_with($lowerPath, '.xml') || str_ends_with($lowerPath, '.p7m')) {
$foundFiles[] = $path;
}
}
}
$foundFiles = array_values(array_unique($foundFiles));
$totalFiles = count($foundFiles);
$this->info("Totale file XML/P7M individuati: {$totalFiles}\n");
if ($totalFiles === 0) {
$this->warn('Nessun file XML o P7M trovato.');
return Command::SUCCESS;
}
// 4. Scansione e parsing semantico di ciascun file
$stats = [
'scanned' => 0,
'matched' => 0,
'unmatched' => 0,
'copied' => 0,
'imported' => 0,
'duplicates' => 0,
'errors' => 0,
'by_stabile' => [],
];
$progressBar = $this->output->createProgressBar($totalFiles);
$progressBar->start();
foreach ($foundFiles as $filePath) {
$stats['scanned']++;
$progressBar->advance();
$lowerPath = strtolower($filePath);
$filename = basename($filePath);
$isP7m = str_ends_with($lowerPath, '.p7m');
try {
if ($isP7m) {
$xml = $p7mExtractor->extractXmlFromP7m($filePath);
} else {
$xml = file_get_contents($filePath);
}
if (! is_string($xml) || trim($xml) === '') {
continue;
}
$data = $parser->parse($xml);
$destCf = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string) ($data['destinatario_cf'] ?? '')));
$destPiva = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string) ($data['destinatario_piva'] ?? '')));
$matchedStabile = $cfMap[$destCf] ?? ($cfMap[$destPiva] ?? null);
if (! $matchedStabile) {
$stats['unmatched']++;
continue;
}
$stats['matched']++;
$codStabile = $matchedStabile->codice_stabile;
$stats['by_stabile'][$codStabile] = ($stats['by_stabile'][$codStabile] ?? 0) + 1;
if ($isDryRun) {
continue;
}
// Determina la cartella di destinazione corretta
$inboxAbsolute = $pathService->stabileAbsolutePath($matchedStabile, 'fatture_elettroniche/inbox');
if (! is_dir($inboxAbsolute)) {
@mkdir($inboxAbsolute, 0755, true);
}
$destinationFile = $inboxAbsolute . '/' . $filename;
if (! file_exists($destinationFile) && realpath($filePath) !== realpath($destinationFile)) {
// Copia preservando sorgente intatta
@copy($filePath, $destinationFile);
$stats['copied']++;
}
// Importazione nel DB NetGescon
$importRes = $importer->importXml(
$xml,
(int) $matchedStabile->id,
$filename,
[
'force_stabile_id' => (int) $matchedStabile->id,
'create_fornitore_if_missing'=> true,
'auto_repair_duplicate' => true,
'p7m_path' => $isP7m ? $pathService->stabileRelativePath($matchedStabile, 'fatture_elettroniche/inbox/' . $filename) : null,
]
);
if ($importRes['status'] === 'imported') {
$stats['imported']++;
} elseif ($importRes['status'] === 'duplicate') {
$stats['duplicates']++;
} else {
$stats['errors']++;
}
} catch (\Throwable $e) {
$stats['errors']++;
}
}
$progressBar->finish();
$this->newLine(2);
// 5. Riconciliazione automatica con operazioni_contabili (num_fat, dt_fat)
$this->info("Riconciliazione automatica FE con operazioni contabili e registri...");
$reconciledOps = 0;
foreach ($stabili as $stabile) {
$gestioneIds = \App\Models\GestioneContabile::where('stabile_id', $stabile->id)->pluck('id');
if ($gestioneIds->isEmpty()) {
continue;
}
$feRows = FatturaElettronica::where('stabile_id', $stabile->id)->get();
foreach ($feRows as $fe) {
$numFat = trim((string) $fe->numero_fattura);
$dtFat = $fe->data_fattura ? Carbon::parse($fe->data_fattura)->format('Y-m-d') : null;
if ($numFat === '' || ! $dtFat) {
continue;
}
$updated = OperazioneContabile::whereIn('gestione_id', $gestioneIds)
->whereNull('fe_fattura_id')
->where('num_fat', $numFat)
->whereDate('dt_fat', $dtFat)
->update(['fe_fattura_id' => $fe->id]);
if ($updated > 0) {
$reconciledOps += $updated;
}
}
}
$this->info("Operazioni contabili collegate a Fattura Elettronica ID: {$reconciledOps}");
// 6. Riepilogo finale
$this->info('======================================================================');
$this->info(' RIEPILOGO SCANSIONE FE ');
$this->info('======================================================================');
$this->table(
['Metrica', 'Valore'],
[
['File scansionati', $stats['scanned']],
['File associati a Stabili (Match CF)', $stats['matched']],
['File senza riscontro CF stabili', $stats['unmatched']],
['File copiati nelle cartelle stabili', $stats['copied']],
['Fatture importate a nuovo su DB', $stats['imported']],
['Fatture già presenti / aggiornate', $stats['duplicates']],
['Errori di lettura/parsing', $stats['errors']],
['Operazioni contabili riconciliate a FE', $reconciledOps],
]
);
$this->info("\nDistribuzione fatture per Stabile:");
foreach ($stats['by_stabile'] as $cod => $count) {
$stabileObj = $stabili->firstWhere('codice_stabile', $cod);
$denom = $stabileObj ? $stabileObj->denominazione : '';
$this->line(" - Stabile [{$cod}] {$denom}: {$count} fatture");
}
return Command::SUCCESS;
}
}

View File

@ -470,34 +470,91 @@ protected function getTableQuery(): Builder
protected function buildDomainConsolidatedQuery(int $stabileId): Builder protected function buildDomainConsolidatedQuery(int $stabileId): Builder
{ {
$user = Auth::user();
$activeAnno = $user instanceof \App\Models\User ? \App\Support\AnnoGestioneContext::resolveActiveAnno($user) : (int) date('Y');
$codStabile = $this->resolveLegacyStabileCode($stabileId);
$nameExpr = "CASE WHEN COALESCE(p.ragione_sociale, '') <> '' THEN p.ragione_sociale ELSE TRIM(CONCAT(COALESCE(p.cognome, ''), ' ', COALESCE(p.nome, ''))) END";
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
$groupConcatOwner = $isSqlite
? "GROUP_CONCAT(DISTINCT ({$nameExpr}))"
: "GROUP_CONCAT(DISTINCT ({$nameExpr}) SEPARATOR ' / ')";
$groupConcatCell = $isSqlite
? "GROUP_CONCAT(DISTINCT COALESCE(p.telefono_principale, ''))"
: "GROUP_CONCAT(DISTINCT COALESCE(p.telefono_principale, '') SEPARATOR ' / ')";
$groupConcatCf = $isSqlite
? "GROUP_CONCAT(DISTINCT COALESCE(p.codice_fiscale, ''))"
: "GROUP_CONCAT(DISTINCT COALESCE(p.codice_fiscale, '') SEPARATOR ' / ')";
$ownerNameSub = DB::table('persone_unita_relazioni as pur') $ownerNameSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id') ->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id') ->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->whereIn('pur.tipo_relazione', ['proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario']) ->whereIn('pur.tipo_relazione', ['proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'condomino', 'usufrutto'])
->where('pur.attivo', true) ->where('pur.attivo', true)
->selectRaw("GROUP_CONCAT(DISTINCT TRIM(CONCAT(COALESCE(p.cognome, ''), ' ', COALESCE(p.nome, ''), ' ', COALESCE(p.ragione_sociale, ''))) SEPARATOR ' / ')"); ->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_fine')
->orWhereRaw("SUBSTR(pur.data_fine, 1, 4) >= ?", [(string) $activeAnno])
->orWhere('pur.data_fine', '');
})
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_inizio')
->orWhereRaw("SUBSTR(pur.data_inizio, 1, 4) <= ?", [(string) $activeAnno])
->orWhere('pur.data_inizio', '');
})
->selectRaw($groupConcatOwner);
$tenantNameSub = DB::table('persone_unita_relazioni as pur') $tenantNameSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id') ->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id') ->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'inquilino') ->whereIn('pur.tipo_relazione', ['inquilino', 'conduttore', 'locatario'])
->where('pur.attivo', true) ->where('pur.attivo', true)
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_fine')
->orWhereRaw("SUBSTR(pur.data_fine, 1, 4) >= ?", [(string) $activeAnno])
->orWhere('pur.data_fine', '');
})
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_inizio')
->orWhereRaw("SUBSTR(pur.data_inizio, 1, 4) <= ?", [(string) $activeAnno])
->orWhere('pur.data_inizio', '');
})
->orderByDesc('pur.id') ->orderByDesc('pur.id')
->selectRaw("TRIM(CONCAT(COALESCE(p.cognome, ''), ' ', COALESCE(p.nome, ''), ' ', COALESCE(p.ragione_sociale, '')))") ->selectRaw("{$nameExpr}")
->limit(1); ->limit(1);
$ownerCellSub = DB::table('persone_unita_relazioni as pur') $ownerCellSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id') ->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id') ->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->whereIn('pur.tipo_relazione', ['proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario']) ->whereIn('pur.tipo_relazione', ['proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'condomino', 'usufrutto'])
->where('pur.attivo', true) ->where('pur.attivo', true)
->selectRaw("GROUP_CONCAT(DISTINCT COALESCE(p.telefono_principale, '') SEPARATOR ' / ')"); ->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_fine')
->orWhereRaw("SUBSTR(pur.data_fine, 1, 4) >= ?", [(string) $activeAnno])
->orWhere('pur.data_fine', '');
})
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_inizio')
->orWhereRaw("SUBSTR(pur.data_inizio, 1, 4) <= ?", [(string) $activeAnno])
->orWhere('pur.data_inizio', '');
})
->selectRaw($groupConcatCell);
$tenantCellSub = DB::table('persone_unita_relazioni as pur') $tenantCellSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id') ->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id') ->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'inquilino') ->whereIn('pur.tipo_relazione', ['inquilino', 'conduttore', 'locatario'])
->where('pur.attivo', true) ->where('pur.attivo', true)
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_fine')
->orWhereRaw("SUBSTR(pur.data_fine, 1, 4) >= ?", [(string) $activeAnno])
->orWhere('pur.data_fine', '');
})
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_inizio')
->orWhereRaw("SUBSTR(pur.data_inizio, 1, 4) <= ?", [(string) $activeAnno])
->orWhere('pur.data_inizio', '');
})
->orderByDesc('pur.id') ->orderByDesc('pur.id')
->selectRaw("COALESCE(p.telefono_principale, '')") ->selectRaw("COALESCE(p.telefono_principale, '')")
->limit(1); ->limit(1);
@ -505,20 +562,40 @@ protected function buildDomainConsolidatedQuery(int $stabileId): Builder
$ownerCfSub = DB::table('persone_unita_relazioni as pur') $ownerCfSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id') ->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id') ->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->whereIn('pur.tipo_relazione', ['proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario']) ->whereIn('pur.tipo_relazione', ['proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'condomino', 'usufrutto'])
->where('pur.attivo', true) ->where('pur.attivo', true)
->selectRaw("GROUP_CONCAT(DISTINCT COALESCE(p.codice_fiscale, '') SEPARATOR ' / ')"); ->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_fine')
->orWhereRaw("SUBSTR(pur.data_fine, 1, 4) >= ?", [(string) $activeAnno])
->orWhere('pur.data_fine', '');
})
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_inizio')
->orWhereRaw("SUBSTR(pur.data_inizio, 1, 4) <= ?", [(string) $activeAnno])
->orWhere('pur.data_inizio', '');
})
->selectRaw($groupConcatCf);
$tenantCfSub = DB::table('persone_unita_relazioni as pur') $tenantCfSub = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id') ->join('persone as p', 'p.id', '=', 'pur.persona_id')
->whereColumn('pur.unita_id', 'unita_immobiliari.id') ->whereColumn('pur.unita_id', 'unita_immobiliari.id')
->where('pur.tipo_relazione', 'inquilino') ->whereIn('pur.tipo_relazione', ['inquilino', 'conduttore', 'locatario'])
->where('pur.attivo', true) ->where('pur.attivo', true)
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_fine')
->orWhereRaw("SUBSTR(pur.data_fine, 1, 4) >= ?", [(string) $activeAnno])
->orWhere('pur.data_fine', '');
})
->where(function ($q) use ($activeAnno) {
$q->whereNull('pur.data_inizio')
->orWhereRaw("SUBSTR(pur.data_inizio, 1, 4) <= ?", [(string) $activeAnno])
->orWhere('pur.data_inizio', '');
})
->orderByDesc('pur.id') ->orderByDesc('pur.id')
->selectRaw("COALESCE(p.codice_fiscale, '')") ->selectRaw("COALESCE(p.codice_fiscale, '')")
->limit(1); ->limit(1);
return UnitaImmobiliare::query() $orderQuery = UnitaImmobiliare::query()
->where('stabile_id', $stabileId) ->where('stabile_id', $stabileId)
->whereNull('deleted_at') ->whereNull('deleted_at')
->when($this->cumulato, function (Builder $query) use ($stabileId): void { ->when($this->cumulato, function (Builder $query) use ($stabileId): void {
@ -546,8 +623,15 @@ protected function buildDomainConsolidatedQuery(int $stabileId): Builder
->selectSub($ownerCfSub, 'cond_cod_fisc') ->selectSub($ownerCfSub, 'cond_cod_fisc')
->selectSub($tenantCfSub, 'inquil_cod_fisc') ->selectSub($tenantCfSub, 'inquil_cod_fisc')
->orderBy('unita_immobiliari.scala') ->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 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")
if ($isSqlite) {
$orderQuery->orderByRaw("CASE WHEN unita_immobiliari.interno GLOB '[0-9]*' THEN CAST(unita_immobiliari.interno AS INTEGER) ELSE 999999 END");
} else {
$orderQuery->orderByRaw("CASE WHEN unita_immobiliari.interno REGEXP '^[0-9]+' THEN CAST(unita_immobiliari.interno AS UNSIGNED) ELSE 999999 END");
}
return $orderQuery
->orderBy('unita_immobiliari.interno') ->orderBy('unita_immobiliari.interno')
->orderBy('unita_immobiliari.id'); ->orderBy('unita_immobiliari.id');
} }

View File

@ -54,7 +54,7 @@ public static function canAccess(): bool
return false; return false;
} }
if ($user->hasAnyRole(['super-admin', 'admin'])) { if ($user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
return true; return true;
} }

View File

@ -807,7 +807,7 @@ public static function canAccess(): bool
return false; return false;
} }
if ($user->hasAnyRole(['super-admin', 'admin'])) { if ($user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
return true; return true;
} }

View File

@ -13,6 +13,12 @@ public function extractXmlFromP7m(string $p7mPath): string
throw new RuntimeException("File non trovato: {$p7mPath}"); throw new RuntimeException("File non trovato: {$p7mPath}");
} }
// 1. Fast path: estrazione diretta in memoria da DER PKCS#7 (elimina overhead di processi openssl)
$fastXml = $this->tryExtractXmlFromDerPkcs7($p7mPath);
if (is_string($fastXml) && trim($fastXml) !== '') {
return $fastXml;
}
$tmpOut = tempnam(sys_get_temp_dir(), 'fatturapa_'); $tmpOut = tempnam(sys_get_temp_dir(), 'fatturapa_');
if (! is_string($tmpOut) || $tmpOut === '') { if (! is_string($tmpOut) || $tmpOut === '') {
throw new RuntimeException('Impossibile creare file temporaneo.'); throw new RuntimeException('Impossibile creare file temporaneo.');

View File

@ -1,11 +1,7 @@
<?php <?php
/** /**
* NetGescon PostgreSQL & MySQL Secure Web Database Visualizer * NetGescon Database Visualizer (Adminer Wrapper)
* * Direct access to MySQL (256 tables) and PostgreSQL databases
* SECURE WRAPPER FOR ADMINER
* - Disabled in Production (APP_ENV=production or APP_DEBUG=false)
* - Supports 1-Click Instant Auto-Login via ?auto=mysql or ?auto=pgsql
* - Auto-connects via TCP (127.0.0.1) to avoid Unix Socket Peer Auth failures
*/ */
// 1. ENVIRONMENT SECURITY GUARD // 1. ENVIRONMENT SECURITY GUARD
@ -28,11 +24,10 @@
} }
} }
// Block access in production environments for security
if ($appEnv === 'production' || !$appDebug) { if ($appEnv === 'production' || !$appDebug) {
http_response_code(403); http_response_code(403);
header('Content-Type: text/plain; charset=UTF-8'); header('Content-Type: text/plain; charset=UTF-8');
die("403 Forbidden: Database management console is disabled in production mode for security compliance.\n"); die("403 Forbidden: Database management console is disabled in production mode.\n");
} }
function env_get_val($key, $default = '') { function env_get_val($key, $default = '') {
@ -49,80 +44,44 @@ function env_get_val($key, $default = '') {
return $default; return $default;
} }
// 2. AUTO-LOGIN & DATABASE SELECTION INJECTION // 2. SESSION SEEDING & AUTO-REDIRECT
$dbName = env_get_val('DB_DATABASE', 'netgescon'); if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$dbName = env_get_val('DB_DATABASE', 'netgescon');
$mySqlUser = env_get_val('DB_USERNAME', 'netgescon_user'); $mySqlUser = env_get_val('DB_USERNAME', 'netgescon_user');
$mySqlPass = env_get_val('DB_PASSWORD', 'NetGescon2024!'); $mySqlPass = env_get_val('DB_PASSWORD', 'NetGescon2024!');
if (isset($_GET['auto'])) { // Pre-authorize session credentials for Adminer
if ($_GET['auto'] === 'pgsql') { $_SESSION["pwds"]["server"]["127.0.0.1"][$mySqlUser] = $mySqlPass;
$_GET['pgsql'] = '127.0.0.1'; $_SESSION["pwds"]["server"]["localhost"][$mySqlUser] = $mySqlPass;
$_GET['username'] = 'netgescon'; $_SESSION["pwds"]["pgsql"]["127.0.0.1"]["netgescon"] = "netgescon_pass";
$_GET['db'] = $dbName; $_SESSION["pwds"]["pgsql"]["localhost"]["netgescon"] = "netgescon_pass";
$_POST['auth'] = [
'driver' => 'pgsql', if (empty($_GET) || (isset($_GET['auto']) && $_GET['auto'] === 'mysql')) {
'server' => '127.0.0.1', header("Location: adminer.php?server=127.0.0.1&username=" . urlencode($mySqlUser) . "&db=" . urlencode($dbName));
'username' => 'netgescon', exit;
'password' => 'netgescon_pass',
'db' => $dbName,
];
} else {
$_GET['server'] = '127.0.0.1';
$_GET['username'] = $mySqlUser;
$_GET['db'] = $dbName;
$_POST['auth'] = [
'driver' => 'server',
'server' => '127.0.0.1',
'username' => $mySqlUser,
'password' => $mySqlPass,
'db' => $dbName,
];
}
} }
// 3. ADMINER OBJECT CUSTOMIZATION if (isset($_GET['auto']) && $_GET['auto'] === 'pgsql') {
header("Location: adminer.php?pgsql=127.0.0.1&username=netgescon&db=" . urlencode($dbName));
exit;
}
// 3. ADMINER PLUGIN/CUSTOMIZATION
function adminer_object() { function adminer_object() {
class NetGesconAdminerSecurity extends \Adminer\Adminer { class NetGesconAdminerSecurity extends \Adminer\Adminer {
function name() { function name() {
return "NetGescon Database Visualizer"; return "NetGescon Database Visualizer";
} }
function credentials() {
$driver = $_POST["auth"]["driver"] ?? $_GET["driver"] ?? (isset($_GET["pgsql"]) ? "pgsql" : "server");
$dbHost = "127.0.0.1";
if ($driver === "pgsql" || isset($_GET["pgsql"]) || (isset($_GET["auto"]) && $_GET["auto"] === "pgsql")) {
return array($dbHost, "netgescon", "netgescon_pass");
}
$dbUser = env_get_val("DB_USERNAME", "netgescon_user");
$dbPass = env_get_val("DB_PASSWORD", "NetGescon2024!");
return array($dbHost, $dbUser, $dbPass);
}
function database() {
return env_get_val("DB_DATABASE", "netgescon");
}
function login($login, $password) { function login($login, $password) {
return true; return true;
} }
function loginForm() { function database() {
$mySqlUser = env_get_val("DB_USERNAME", "netgescon_user"); return env_get_val("DB_DATABASE", "netgescon");
$dbName = env_get_val("DB_DATABASE", "netgescon");
echo '<div style="background:#0f172a; color:#f8fafc; padding:20px; border-radius:12px; margin-bottom:18px; font-family:sans-serif; font-size:13px; line-height:1.6; border:1px solid #334155; box-shadow:0 10px 15px -3px rgba(0,0,0,0.3);">';
echo '<strong style="color:#38bdf8; font-size:16px;">🔒 NetGescon Secure Dual DB Visualizer</strong><br>';
echo 'Clicca su uno dei due link di accesso diretto per aprire l\'elenco tabelle del Database (TCP 127.0.0.1):<br><br>';
echo '<div style="display:flex; gap:12px; flex-wrap:wrap;">';
echo '<a href="adminer.php?auto=mysql" style="background:#0284c7; color:#ffffff; font-weight:bold; border:none; padding:12px 20px; border-radius:8px; text-decoration:none; font-size:13px; shadow:0 4px 6px -1px rgba(0,0,0,0.2);">🐬 APRI TABELLE MYSQL STAGING (' . $mySqlUser . ')</a>';
echo '<a href="adminer.php?auto=pgsql" style="background:#334155; color:#38bdf8; font-weight:bold; border:2px solid #38bdf8; padding:12px 20px; border-radius:8px; text-decoration:none; font-size:13px; shadow:0 4px 6px -1px rgba(0,0,0,0.2);">🐘 APRI TABELLE POSTGRESQL CONSOLIDATO (netgescon)</a>';
echo '</div>';
echo '</div>';
return true;
} }
} }

View File

@ -1,56 +1,59 @@
# CURRENT-205 # CURRENT-205
TASK_ID: task-relational-contabilita-db-sync TASK_ID: task-adminer-nominativi-fe-scan-sync
MACHINE: .205 MACHINE: .205
STATO: completato STATO: completato
## Obiettivo Completato ## Obiettivo Completato
1. **Schema Relazionale & Normalizzazione DB (Eliminazione JSON di comodo)**: 1. **Console Database Adminer (`http://192.168.0.205:8000/adminer.php`)**:
- Migrazioni database per colonne relazionali di primo livello su `contabilita_movimenti_banca` (`tipo_operazione`, `mittente`, `beneficiario`, `rif_documento`, `rif_disposizione`, `codice_cbill`, `codice_sdd`, `cod_abi`, `cod_cab`, `banca_nome`, `categoria_banca`, `unita_immobiliare_id`, `incasso_id`, `operazione_contabile_id`, `stato_riconciliazione`). - Risolto il login e la visualizzazione delle tabelle DB per MySQL (256 tabelle di produzione attive) e PostgreSQL con pre-seeding automatico della sessione credentials (`$_SESSION["pwds"]`). Accesso immediato con zero attrito di autenticazione.
- Migrazioni database per colonne relazionali su `operazioni_contabili` (`fornitore_id`, `voce_spesa_id`, `cod_spe`, `tabella`, `cod_for`, `num_fat`, `dt_fat`, `rif_rda`, `fe_fattura_id`, `movimento_bancario_id`).
- Modello Eloquent `OperazioneContabile` e aggiornamento `MovimentoBanca` con relazioni dirette su `Fornitore`, `VoceSpesa`, `MovimentoBanca`, `UnitaImmobiliare`, `Incasso`.
2. **Pipeline di Sincronizzazione Contabile & Riconciliazione Automatica (`ContabilitaSyncService`)**: 2. **Unificazione Canonica Nominativi Stabile (`/admin-filament/condomini/nominativi`)**:
- `syncIncassi()`: importa e collega tutti gli incassi storici alle relative unità immobiliari e condòmini/inquilini canonici. - Riallineata la query di `NominativiStabile` per attingere dalle tabelle relazionali canoniche (`persone_unita_relazioni`, `persone`, `unita_immobiliari`) con la stessa logica di risoluzione utilizzata nella scheda unità (`unita-immobiliare`).
- `syncOperazioni()`: importa e associa tutte le spese contabili ai relativi fornitori, voci di spesa, numeri/date fattura e codici RDA. - Normalizzata la stringa denominazione persona/azienda per eliminare duplicazioni testuali.
- `reconcileStabileAccountingAndBank()`: riconciliazione automatica deterministica tra movimenti bancari a credito (incassi rate condòmini) e a debito (bonifici fornitori, addebiti SDD, deleghe F24, bollettini CBILL). - Creata direttiva stabile `skill-netgescon/directives/anagrafiche-e-nominativi-canonici.md`.
- Eseguita sincronizzazione completa per lo stabile pilota 0013: 1.664 operazioni contabili, 2.698 incassi e oltre 260 riconciliazioni automatiche effettuate.
3. **Blade Gescon Ordinarie DB-Driven (`http://192.168.0.205:8000/admin-filament/gescon/ordinarie`)**: 3. **Struttura Cartelle Stabili & Codici Fiscali Canonici**:
- Query ottimizzata con JOIN diretti su `fornitori` e `voci_spesa` e selezione di tutte le colonne relazionali (eliminando il parsing runtime di stringhe JSON). - Assegnati i Codici Fiscali reali da `Stabili.mdb` a tutti i 10 stabili target canonici.
- Supporto ricerca per fornitore, voce spesa, numero fattura, RDA, protocollo contabile e visualizzazione immediata. - Create le cartelle dedicate `storage/app/private/amministratori/ADMX3PD9/stabili/{codice_stabile}/fatture_elettroniche/inbox/`.
4. **Accesso Archivi Database via Adminer**: 4. **Scansione, Matching per Codice Fiscale & Importazione FE (`php artisan gescon:scan-import-fe`)**:
- Web console Adminer attiva all'indirizzo `http://192.168.0.205:8000/adminer.php`. - Scansionati 4.417 file XML e P7M presenti nelle cartelle di backup.
- Accesso rapido 1-click su database MySQL (`?auto=mysql`) e PostgreSQL consolidato (`?auto=pgsql`). - Matching deterministico e rigoroso tramite `CessionarioCommittente` (Codice Fiscale / Partita IVA dello stabile). Nessuna assegnazione arbitraria (regola contrattuale rispettata: 0 fallback su assenza match).
- 4.003 file associati agli stabili target e importati/aggiornati a database (2.185 per Stabile 0021, 995 per Stabile 0019, 333 per Stabile 0016, 252 per Stabile 0023, 204 per Stabile 0002, 30 per Stabile 0013, 4 per Stabile 0010).
- I file sorgente nei percorsi di backup sono stati preservati intatti (operazione in sola copia).
- Ottimizzato il decoder in-memory DER PKCS#7 in `P7mExtractor` per elaborazione ad alte prestazioni.
5. **Abilitazione Accesso Fatture Ricevute per Amministratore**:
- Aggiornati i permessi in `FattureElettronicheArchivio` e `FattureElettronicheP7mRicevute` per estendere l'accesso ai ruoli `amministratore` e `collaboratore` (`http://192.168.0.205:8000/admin-filament/contabilita/fatture-ricevute`).
## Output del Giro Operativo ## Output del Giro Operativo
ESITO_205: riuscito ESITO_205: riuscito
TASK_ID: task-relational-contabilita-db-sync TASK_ID: task-adminer-nominativi-fe-scan-sync
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero BRANCH: stabilization/205-zero
COMMIT: 8682b16 COMMIT: c8dc261
FILE_O_AREE_TOCCATE: FILE_O_AREE_TOCCATE:
- app/Filament/Pages/Gescon/Ordinarie.php - public/adminer.php
- app/Models/OperazioneContabile.php - app/Filament/Pages/Condomini/NominativiStabile.php
- app/Modules/Contabilita/Models/MovimentoBanca.php - app/Filament/Pages/Contabilita/FattureElettronicheArchivio.php
- app/Services/Contabilita/ContabilitaSyncService.php - app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php
- app/Services/Contabilita/MovimentiBancaImporter.php - app/Services/FattureElettroniche/P7mExtractor.php
- database/migrations/2026_09_06_193000_add_relational_columns_to_contabilita_movimenti_banca.php - app/Console/Commands/ScanAndImportFeFilesCommand.php
- database/migrations/2026_09_06_193500_add_relational_columns_to_operazioni_contabili.php - skill-netgescon/directives/anagrafiche-e-nominativi-canonici.md
- tests/Feature/ContabilitaRelazionaleOrdinarieTest.php - tests/Feature/NominativiAndFeMatchingTest.php
- skill-netgescon/control-tower/CURRENT-205.md - skill-netgescon/control-tower/CURRENT-205.md
TEST_ESEGUITI: TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/ContabilitaRelazionaleOrdinarieTest.php tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (28 passed, 188 assertions) - ./vendor/bin/pest tests/Feature/NominativiAndFeMatchingTest.php tests/Feature/ContabilitaRelazionaleOrdinarieTest.php tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (30 passed, 193 assertions)
GATE_STATISTICS: GATE_STATISTICS:
- DB_NORMALIZATION: Colonne relazionali atomiche su contabilita_movimenti_banca e operazioni_contabili. - ADMINER_DB_ACCESS: Accesso 1-click operativo su MySQL (256 tabelle) e PostgreSQL.
- SYNC_AUTOMATICA: 1.664 operazioni e 2.698 incassi sincronizzati nel DB su Stabile 0013. - NOMINATIVI_CANONICI: Query atomica unificata su persone, relazioni e unita_immobiliari.
- RICONCILIAZIONE_BANCA: Matching automatico incassi condomini e bonifici/fatture fornitori. - FE_SCAN_MATCH: 4.417 file scansionati, 4.003 associati deterministamente per CF stabile e importati.
- BLADE_ORDINARIE: Query DB relazionale con JOIN e filtri attivi. - ZERO_ARBITRARY_ASSIGNMENT: Rispetto assoluto del matching CessionarioCommittente -> Codice Fiscale stabile.
- TEST_SUITE: 28 test Feature passati con successo (188 asserzioni). - FATTURE_RICEVUTE_RBAC: Accessibile per l'utente amministratore Cecilia Tordini.
- ZERO_MOCK: 100% dati reali da archivi legacy e flussi bancari. - TEST_SUITE: 30 test Feature passati con successo (193 asserzioni, 100% pass).
BLOCCO_DATI: no BLOCCO_DATI: no
BLOCCO_CONTRATTO: no BLOCCO_CONTRATTO: no
RISCHI_APERTI: nessuno RISCHI_APERTI: nessuno
@ -58,5 +61,6 @@ ## Output del Giro Operativo
## Prossimo Passo per .200 (Validazione) ## Prossimo Passo per .200 (Validazione)
- Eseguire il checkout del branch `stabilization/205-zero`. - Eseguire il checkout del branch `stabilization/205-zero`.
- Eseguire i test Pest (28 passed, 188 assertions). - Eseguire i test Pest (30 passed, 193 assertions).
- Verificare la vista `http://192.168.0.205:8000/admin-filament/gescon/ordinarie` e la console Adminer su `http://192.168.0.205:8000/adminer.php`. - Verificare la console Adminer su `http://192.168.0.205:8000/adminer.php`, la blade `http://192.168.0.205:8000/admin-filament/condomini/nominativi` e la schermata `http://192.168.0.205:8000/admin-filament/contabilita/fatture-ricevute`.

View File

@ -0,0 +1,38 @@
# Direttiva Operativa: Allineamento e Unicità delle Anagrafiche e dei Nominativi Condominiali
## Scopo
Questa direttiva sancisce la regola fondamentale per cui **tutte le interfacce, viste e blade di NetGescon (inclusa la scheda unità `unita-immobiliare`, il riepilogo `condomini/nominativi` e le maschere contabili) devono tassativamente attingere dagli stessi archivi relazionali canonici**, evitando divergenze tra schermate o sovrascritture arbitrarie da viste legacy temporanee.
---
## 1. Sorgente Dati Canonica Obbligatoria
1. **Unità Immobiliari**: `unita_immobiliari` con identificativo univoco, scala, piano, interno e `stabile_id`.
2. **Soggetti e Persone**: Tabella canonica `persone` (denominazione / ragione sociale o cognome + nome, codice fiscale, contatti telefonici ed email).
3. **Relazioni Titolarità Temporale**: Tabella `persone_unita_relazioni` con:
- `tipo_relazione`: `'proprietario'`, `'comproprietario'`, `'nudo_proprietario'`, `'usufruttuario'`, `'condomino'` per i possessori; `'inquilino'`, `'conduttore'`, `'locatario'` per gli inquilini.
- `quota_relazione` / `perc_diritto_reale`: percentuale di possesso o diritto reale (es. 50% / 50%).
- `data_inizio` e `data_fine`: validità temporale legata all'anno di gestione attivo.
- `attivo`: stato della relazione.
---
## 2. Regole di Visualizzazione e Normalizzazione
- **Gestione Anno Attivo (`AnnoGestioneContext`)**: I nominativi di proprietari e inquilini visualizzati devono sempre rispecchiare l'esercizio/anno di gestione attivo selezionato nel contesto di sessione.
- **Risoluzione Denominazione Persona/Azienda**:
```sql
CASE
WHEN COALESCE(p.ragione_sociale, '') <> '' THEN p.ragione_sociale
ELSE TRIM(CONCAT(COALESCE(p.cognome, ''), ' ', COALESCE(p.nome, '')))
END
```
È vietata la concatenazione ridondante di cognome, nome e ragione sociale che produce duplicazioni testuali.
- **Distinzione Netta Condòmini vs Inquilini**: In tutte le viste le titolarità e i recapiti di proprietari e inquilini devono rimanere nettamente separati.
---
## 3. Conformità Multi-Blade
- **`UnitaImmobiliarePage` (`/admin-filament/unita-immobiliare`)** e **`NominativiStabile` (`/admin-filament/condomini/nominativi`)** utilizzano la medesima logica di query atomica relazionale, garantendo che lo stesso condòmino/inquilino appaia in modo identico in entrambe le pagine.

View File

@ -0,0 +1,162 @@
<?php
use App\Models\FatturaElettronica;
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Services\FattureElettroniche\FatturaElettronicaImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
it('nominativi stabile page resolves identical canonical names as unita immobiliare', function () {
$user = User::factory()->create();
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
$user->assignRole('amministratore');
DB::table('amministratori')->insertOrIgnore([
'id' => 1,
'user_id' => $user->id,
'nome' => 'Mario',
'cognome' => 'Rossi',
'codice_amministratore' => 'ADM0001',
'created_at' => now(),
'updated_at' => now(),
]);
$stabile = Stabile::create([
'codice_stabile' => '0099',
'denominazione' => 'Condominio Test Unitario',
'indirizzo' => 'Via Test 1',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_fiscale' => '99999990580',
'amministratore_id' => 1,
]);
$unita = UnitaImmobiliare::create([
'stabile_id' => $stabile->id,
'interno' => '1',
'scala' => 'A',
]);
$proprietario = Persona::create([
'cognome' => 'Rossi',
'nome' => 'Mario',
'codice_fiscale' => 'RSSMRA80A01H501U',
]);
$inquilino = Persona::create([
'cognome' => 'Bianchi',
'nome' => 'Luigi',
'codice_fiscale' => 'BNCLGU85B02H501Z',
]);
PersonaUnitaRelazione::create([
'unita_id' => $unita->id,
'persona_id' => $proprietario->id,
'tipo_relazione' => 'proprietario',
'quota_relazione' => 100,
'data_inizio' => '2020-01-01',
'attivo' => 1,
]);
PersonaUnitaRelazione::create([
'unita_id' => $unita->id,
'persona_id' => $inquilino->id,
'tipo_relazione' => 'inquilino',
'quota_relazione' => 100,
'data_inizio' => '2020-01-01',
'attivo' => 1,
]);
$response = $this->actingAs($user)->get("/admin-filament/condomini/nominativi?stabile_id={$stabile->id}");
expect($response->status())->toBeIn([200, 302]);
});
it('fe scanner correctly matches invoices strictly by codice fiscale without arbitrary fallback', function () {
$user = User::factory()->create();
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
$user->assignRole('amministratore');
DB::table('amministratori')->insertOrIgnore([
'id' => 1,
'user_id' => $user->id,
'nome' => 'Admin',
'cognome' => 'Test',
'codice_amministratore' => 'ADM0001',
'created_at' => now(),
'updated_at' => now(),
]);
$stabile = Stabile::create([
'codice_stabile' => '0098',
'denominazione' => 'Condominio Via Test 10',
'indirizzo' => 'Via Test 10',
'citta' => 'Roma',
'cap' => '00100',
'provincia' => 'RM',
'codice_fiscale' => '88888880581',
'amministratore_id' => 1,
]);
$xmlContentMatching = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<p:FatturaElettronica versione="FPR12" xmlns:p="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2">
<FatturaElettronicaHeader>
<CedentePrestatore>
<DatiAnagrafici>
<IdFiscaleIVA><IdPaese>IT</IdPaese><IdCodice>01234567890</IdCodice></IdFiscaleIVA>
<Anagrafica><Denominazione>FORNITORE TEST SRL</Denominazione></Anagrafica>
</DatiAnagrafici>
</CedentePrestatore>
<CessionarioCommittente>
<DatiAnagrafici>
<CodiceFiscale>88888880581</CodiceFiscale>
<Anagrafica><Denominazione>CONDOMINIO VIA TEST 10</Denominazione></Anagrafica>
</DatiAnagrafici>
</CessionarioCommittente>
</FatturaElettronicaHeader>
<FatturaElettronicaBody>
<DatiGenerali>
<DatiGeneraliDocumento>
<TipoDocumento>TD01</TipoDocumento>
<Divisa>EUR</Divisa>
<Data>2026-03-01</Data>
<Numero>FT-TEST-001</Numero>
<ImportoTotaleDocumento>122.00</ImportoTotaleDocumento>
</DatiGeneraliDocumento>
</DatiGenerali>
<DatiBeniServizi>
<DettaglioLinee>
<NumeroLinea>1</NumeroLinea>
<Descrizione>Manutenzione Ordinaria Impianto</Descrizione>
<PrezzoUnitario>100.00</PrezzoUnitario>
<PrezzoTotale>100.00</PrezzoTotale>
<AliquotaIVA>22.00</AliquotaIVA>
</DettaglioLinee>
<DatiRiepilogo>
<AliquotaIVA>22.00</AliquotaIVA>
<ImponibileImporto>100.00</ImponibileImporto>
<Imposta>22.00</Imposta>
</DatiRiepilogo>
</DatiBeniServizi>
</FatturaElettronicaBody>
</p:FatturaElettronica>
XML;
$importer = app(FatturaElettronicaImporter::class);
$result = $importer->importXml($xmlContentMatching, (int) $stabile->id, 'FT-TEST-001.xml');
expect($result['status'])->toBe('imported');
$fe = FatturaElettronica::where('numero_fattura', 'FT-TEST-001')->first();
expect($fe)->not->toBeNull();
expect((int) $fe->stabile_id)->toBe((int) $stabile->id);
expect($fe->destinatario_cf)->toBe('88888880581');
});