netgescon-day0/app/Console/Commands/GesconBonificaNominativiCommand.php

457 lines
18 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconBonificaNominativiCommand extends Command
{
protected $signature = 'gescon:bonifica-nominativi
{--stabile-id= : Filtra per stabile_id (dominio)}
{--limit=5000 : Max ruoli da processare}
{--include-inactive : Include anche ruoli non attivi (is_attivo=false)}
{--fix-inquilino-overwritten : Per ruoli inquilino: se in staging esiste un tenant diverso dal proprietario e il contatto sembra quello del proprietario, sovrascrive con i dati tenant}
{--apply : Esegue realmente gli aggiornamenti}
{--deactivate-unresolved : Se non si riesce a recuperare l\'identità, disattiva il ruolo (is_attivo=false)}';
protected $description = 'Bonifica nominativi: ripara contatti rubrica_universale vuoti collegati a rubrica_ruoli usando gescon_import.condomin.';
public function handle(): int
{
$apply = (bool) $this->option('apply');
$deactivateUnresolved = (bool) $this->option('deactivate-unresolved');
$includeInactive = (bool) $this->option('include-inactive');
$fixInquilinoOverwritten = (bool) $this->option('fix-inquilino-overwritten');
$limit = (int) $this->option('limit');
if ($limit < 1) $limit = 1;
if ($limit > 20000) $limit = 20000;
$stabileId = $this->option('stabile-id');
$stabileId = $stabileId !== null && $stabileId !== '' ? (int) $stabileId : null;
if (!Schema::hasTable('rubrica_ruoli') || !Schema::hasTable('rubrica_universale')) {
$this->error('Tabelle rubrica_ruoli/rubrica_universale non disponibili.');
return self::FAILURE;
}
$conn = Config::get('database.connections.gescon_import') ? 'gescon_import' : DB::getDefaultConnection();
if (!Schema::connection($conn)->hasTable('condomin')) {
$this->error("Tabella {$conn}.condomin non disponibile.");
return self::FAILURE;
}
$stats = [
'roles_scanned' => 0,
'roles_with_empty_contact' => 0,
'contacts_fixed' => 0,
'roles_deactivated' => 0,
'skipped_missing_meta' => 0,
'skipped_no_staging_match' => 0,
];
/** @var array<int, array{amministratore_id:int}> $stabileMap */
$stabileMap = [];
if ($stabileId !== null) {
$s = Stabile::query()->find($stabileId);
if (!$s) {
$this->error('stabile-id non trovato.');
return self::FAILURE;
}
$stabileMap[$stabileId] = ['amministratore_id' => (int) ($s->amministratore_id ?? 0)];
}
$q = RubricaRuolo::query()
->with(['contatto'])
->orderBy('id')
->when(!$includeInactive, fn($qq) => $qq->where('is_attivo', true));
if ($stabileId !== null) {
$q->where('stabile_id', $stabileId);
}
$q->limit($limit)->chunkById(200, function ($chunk) use (&$stats, $apply, $deactivateUnresolved, $conn, $stabileMap, $fixInquilinoOverwritten) {
foreach ($chunk as $ruolo) {
$stats['roles_scanned']++;
/** @var RubricaUniversale|null $rubrica */
$rubrica = $ruolo->contatto;
if (!$rubrica) {
continue;
}
$role = strtolower(trim((string) ($ruolo->ruolo_standard ?? '')));
$isEmpty = $this->isRubricaEmptyIdentity($rubrica);
$needsFix = $isEmpty;
if (!$needsFix && $fixInquilinoOverwritten && $role === 'inquilino') {
$needsFix = $this->inquilinoLooksOverwrittenFromOwner($ruolo, $rubrica, $conn);
}
if (!$needsFix) {
continue;
}
if ($isEmpty) {
$stats['roles_with_empty_contact']++;
}
$codStabile = trim((string) data_get($ruolo->meta, 'cod_stabile', ''));
$codCond = trim((string) data_get($ruolo->meta, 'cod_cond', ''));
if ($codStabile === '' || $codCond === '') {
$stats['skipped_missing_meta']++;
if ($deactivateUnresolved && $apply) {
$this->deactivateRole($ruolo, 'missing meta.cod_stabile/cod_cond');
$stats['roles_deactivated']++;
}
continue;
}
$codStabileCandidates = $this->buildCodeCandidates($codStabile, 4);
$codCondCandidates = $this->buildCodeCandidates($codCond, null);
// Preferenza lookup: per inquilino usare anche scala/interno (cod_cond può essere quello del proprietario).
$scala = trim((string) data_get($ruolo->meta, 'scala', ''));
$interno = trim((string) data_get($ruolo->meta, 'interno', ''));
$base = DB::connection($conn)->table('condomin')
->whereIn('cod_stabile', $codStabileCandidates);
if ($role === 'inquilino' && $scala !== '' && $interno !== '') {
$base->where('scala', $scala)->where('interno', $interno);
} else {
$base->whereIn('cod_cond', $codCondCandidates);
}
$row = null;
// 1) Prova con filtro proprietario/inquilino (se presente)
$stagingQuery = clone $base;
if (Schema::connection($conn)->hasColumn('condomin', 'inquilino') && $role === 'inquilino') {
$stagingQuery->where('inquilino', 1);
}
if (Schema::connection($conn)->hasColumn('condomin', 'proprietario') && $role === 'condomino') {
$stagingQuery->where('proprietario', 1);
}
$row = $stagingQuery->orderByDesc('id')->first();
// 2) Fallback: senza filtro ruolo (alcuni archivi hanno flag incoerenti)
if (!$row) {
$row = $base->orderByDesc('id')->first();
}
if (!$row) {
$stats['skipped_no_staging_match']++;
if ($deactivateUnresolved && $apply) {
$this->deactivateRole($ruolo, 'no staging condomin match');
$stats['roles_deactivated']++;
}
continue;
}
$payload = $this->buildRubricaPayloadFromCondominRow($row, $role, $ruolo->stabile_id, $stabileMap);
if (empty($payload)) {
$stats['skipped_no_staging_match']++;
if ($deactivateUnresolved && $apply) {
$this->deactivateRole($ruolo, 'staging row has no identity');
$stats['roles_deactivated']++;
}
continue;
}
if (!$apply) {
$stats['contacts_fixed']++;
continue;
}
$rubrica->fill($payload);
if ($rubrica->isDirty()) {
$rubrica->data_ultima_modifica = now()->toDateString();
$rubrica->save();
}
$stats['contacts_fixed']++;
}
});
$this->info('✅ Bonifica nominativi completata' . ($apply ? '' : ' (dry-run)'));
$this->line(json_encode($stats, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
private function isRubricaEmptyIdentity(RubricaUniversale $r): bool
{
foreach (['ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa'] as $f) {
$v = trim((string) ($r->{$f} ?? ''));
if ($v !== '') {
return false;
}
}
return true;
}
private function inquilinoLooksOverwrittenFromOwner(RubricaRuolo $ruolo, RubricaUniversale $rubrica, string $conn): bool
{
$codStabile = trim((string) data_get($ruolo->meta, 'cod_stabile', ''));
$scala = trim((string) data_get($ruolo->meta, 'scala', ''));
$interno = trim((string) data_get($ruolo->meta, 'interno', ''));
$legacyDir = trim((string) data_get($ruolo->meta, 'legacy_anno_dir', ''));
if ($codStabile === '' || $scala === '' || $interno === '') {
return false;
}
$codStabileCandidates = $this->buildCodeCandidates($codStabile, 4);
if (empty($codStabileCandidates)) {
return false;
}
$internoCandidates = array_values(array_unique(array_merge(
$this->buildCodeCandidates($interno, null),
$this->buildCodeCandidates($interno, 2),
$this->buildCodeCandidates($interno, 3)
)));
if (empty($internoCandidates)) {
return false;
}
$base = DB::connection($conn)->table('condomin')
->whereIn('cod_stabile', $codStabileCandidates)
->where('scala', $scala)
->whereIn('interno', $internoCandidates);
$row = null;
if ($legacyDir !== '' && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) {
$row = (clone $base)
->where('legacy_year', $legacyDir)
->orderByDesc('id')
->first([
'nom_cond',
'nome',
'cognome',
'cond_cod_fisc',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
}
if (!$row) {
$row = $base
->orderByDesc('id')
->first([
'nom_cond',
'nome',
'cognome',
'cond_cod_fisc',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
}
if (!$row) {
return false;
}
$ownerName = $this->normalizeIdentityStr(($row->nom_cond ?? null) ?: trim((string) ($row->nome ?? '') . ' ' . (string) ($row->cognome ?? '')));
$tenantNameRaw = $this->firstNonEmptyStr([
$row->inquilino_denominazione ?? null,
$row->inquil_nome ?? null,
]);
$tenantName = $this->normalizeIdentityStr($tenantNameRaw);
$ownerCf = $this->normalizeCf($row->cond_cod_fisc ?? null);
$tenantCf = $this->normalizeCf($row->inquil_cod_fisc ?? null);
// Se non esiste tenant, non ha senso parlare di "overwritten".
if ($tenantName === '' && $tenantCf === '') {
return false;
}
// Se tenant coincide col proprietario, non forzare.
if ($this->identityMatches($tenantName, $tenantCf, $ownerName, $ownerCf)) {
return false;
}
$rubricaName = $this->normalizeIdentityStr(($rubrica->ragione_sociale ?? '') ?: trim((string) ($rubrica->nome ?? '') . ' ' . (string) ($rubrica->cognome ?? '')));
$rubricaCf = $this->normalizeCf($rubrica->codice_fiscale ?? null);
// Se il contatto "inquilino" matcha il proprietario legacy, è molto probabile che sia stato sovrascritto.
return $this->identityMatches($rubricaName, $rubricaCf, $ownerName, $ownerCf);
}
private function normalizeCf($cf): string
{
$cf = strtoupper(preg_replace('/\s+/', '', (string) ($cf ?? '')));
$cf = preg_replace('/[^A-Z0-9]/', '', $cf);
return $cf ?: '';
}
private function normalizeIdentityStr($s): string
{
$s = strtoupper((string) ($s ?? ''));
$s = str_replace(['.', ',', ';', ':', '-', '/', '\\', "\t", "\n", "\r"], ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
return trim($s);
}
private function identityMatches(string $nameA, string $cfA, string $nameB, string $cfB): bool
{
if ($cfA !== '' && $cfB !== '') {
return $cfA === $cfB;
}
if ($nameA === '' || $nameB === '') {
return false;
}
if ($nameA === $nameB) {
return true;
}
$tokensA = array_values(array_filter(array_unique(preg_split('/\s+/', $nameA) ?: []), fn($t) => $t !== ''));
$tokensB = array_values(array_filter(array_unique(preg_split('/\s+/', $nameB) ?: []), fn($t) => $t !== ''));
if (count($tokensA) === 0 || count($tokensB) === 0) {
return false;
}
$common = array_values(array_intersect($tokensA, $tokensB));
$min = min(count($tokensA), count($tokensB));
$ratio = $min > 0 ? (count($common) / $min) : 0.0;
if ($min >= 2 && $ratio >= 0.8) {
return true;
}
return str_contains($nameA, $nameB) || str_contains($nameB, $nameA);
}
/** @param array<int, mixed> $candidates */
private function firstNonEmptyStr(array $candidates): ?string
{
foreach ($candidates as $c) {
$v = trim((string) ($c ?? ''));
if ($v !== '') {
return $v;
}
}
return null;
}
/** @return array<string, mixed> */
private function buildRubricaPayloadFromCondominRow(object $row, string $role, ?int $stabileId, array $stabileMap): array
{
$get = fn(string $k) => property_exists($row, $k) ? $row->{$k} : null;
$role = strtolower(trim($role));
if ($role === 'inquilino') {
// ⚠️ IMPORTANTE: per l'inquilino usare SOLO campi tenant.
$den = $this->trimOrNull($get('inquilino_denominazione'))
?: $this->trimOrNull($get('inquil_nome'));
$nome = null;
$cognome = null;
$cf = $this->trimOrNull($get('inquil_cod_fisc'));
$email = $this->normalizeEmail($this->trimOrNull($get('e_mail_inquilino')));
$pec = $this->normalizeEmail($this->trimOrNull($get('pec_inquilino')));
$tel = $this->trimOrNull($get('inquil_tel1'));
$cell = $this->trimOrNull($get('cell_inq'));
$indirizzo = $this->trimOrNull($get('inquil_indir'));
$isPersonaGiuridica = $den !== null && $nome === null && $cognome === null;
} else {
// Condomino/proprietario: usa campi owner.
$den = $this->trimOrNull($get('denominazione'));
$nome = $this->trimOrNull($get('nome'));
$cognome = $this->trimOrNull($get('cognome'));
// Normalizza: se denominazione presente e mancano nome/cognome, consideriamo persona giuridica.
$isPersonaGiuridica = $den !== null && $nome === null && $cognome === null;
$cf = $this->trimOrNull($get('codice_fiscale'));
$email = $this->normalizeEmail($this->trimOrNull($get('email')));
$pec = $this->normalizeEmail($this->trimOrNull($get('pec')));
$tel = $this->trimOrNull($get('telefono'));
$cell = $this->trimOrNull($get('cellulare'));
$indirizzo = $this->trimOrNull($get('indirizzo_corrispondenza'));
}
// Identità minima: almeno uno tra denominazione/nome/cognome/cf/email/telefono/cell.
if ($den === null && $nome === null && $cognome === null && $cf === null && $email === null && $pec === null && $tel === null && $cell === null) {
return [];
}
$payload = [
'ragione_sociale' => $isPersonaGiuridica ? mb_substr($den ?? '', 0, 255) : null,
'nome' => !$isPersonaGiuridica ? ($nome ? mb_substr($nome, 0, 100) : null) : null,
'cognome' => !$isPersonaGiuridica ? ($cognome ? mb_substr($cognome, 0, 100) : null) : null,
'tipo_contatto' => $isPersonaGiuridica ? 'persona_giuridica' : 'persona_fisica',
'codice_fiscale' => $cf ? mb_substr($cf, 0, 20) : null,
'email' => $email ? mb_substr($email, 0, 191) : null,
'pec' => $pec ? mb_substr($pec, 0, 191) : null,
'telefono_ufficio' => $tel ? mb_substr($tel, 0, 50) : null,
'telefono_cellulare' => $cell ? mb_substr($cell, 0, 50) : null,
'indirizzo' => $indirizzo ? mb_substr($indirizzo, 0, 255) : null,
'categoria' => 'condomino',
'stato' => 'attivo',
];
// Imposta amministratore_id se possibile.
if ($stabileId && !empty($stabileMap[(int) $stabileId]['amministratore_id'])) {
$payload['amministratore_id'] = (int) $stabileMap[(int) $stabileId]['amministratore_id'];
}
// Rimuovi null per evitare sovrascritture inutili.
return array_filter($payload, fn($v) => $v !== null);
}
/** @return array<int, string> */
private function buildCodeCandidates(string $raw, ?int $padTo): array
{
$raw = trim($raw);
if ($raw === '') {
return [];
}
$candidates = [$raw];
$trim = ltrim($raw, '0');
if ($trim !== '' && $trim !== $raw) {
$candidates[] = $trim;
}
if ($padTo !== null) {
$onlyDigits = preg_match('/^\d+$/', $trim !== '' ? $trim : $raw) === 1;
if ($onlyDigits) {
$base = $trim !== '' ? $trim : $raw;
if (strlen($base) < $padTo) {
$candidates[] = str_pad($base, $padTo, '0', STR_PAD_LEFT);
}
}
}
return array_values(array_unique(array_filter($candidates, fn($v) => trim((string) $v) !== '')));
}
private function deactivateRole(RubricaRuolo $ruolo, string $reason): void
{
$meta = is_array($ruolo->meta) ? $ruolo->meta : [];
$meta['bonifica'] = array_merge(is_array($meta['bonifica'] ?? null) ? $meta['bonifica'] : [], [
'disabled_at' => now()->toISOString(),
'disabled_reason' => $reason,
]);
$ruolo->is_attivo = false;
$ruolo->meta = $meta;
$ruolo->save();
}
private function trimOrNull(mixed $v): ?string
{
if ($v === null) return null;
$s = trim((string) $v);
return $s === '' ? null : $s;
}
private function normalizeEmail(?string $email): ?string
{
$email = strtolower(trim((string) ($email ?? '')));
return $email !== '' ? $email : null;
}
}