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

559 lines
22 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconBonificaRuoliLegacyCommand extends Command
{
protected $signature = 'gescon:bonifica-ruoli-legacy
{--stabile-id= : ID stabile (dominio) su cui operare}
{--name-like= : Filtra per nominativo (nome/cognome/ragione sociale contiene)}
{--limit=20000 : Max ruoli da processare}
{--skip-mismatch : Non disattiva i ruoli che non matchano l\'identità legacy (fa solo report); applica comunque dedup e inquilini spurii}
{--include-inactive : Include anche ruoli non attivi (is_attivo=0) nel controllo}
{--reactivate : Se combinato con --include-inactive e --apply, riattiva solo ruoli inquilino che matchano il tenant legacy}
{--apply : Applica le modifiche (altrimenti dry-run)}
';
protected $description = 'Controlla/bonifica rubrica_ruoli confrontando con gescon_import.condomin (unità/ruoli legacy).';
public function handle(): int
{
$apply = (bool) $this->option('apply');
$skipMismatch = (bool) $this->option('skip-mismatch');
$includeInactive = (bool) $this->option('include-inactive');
$reactivate = (bool) $this->option('reactivate');
$stabileId = $this->option('stabile-id');
$stabileId = $stabileId !== null && $stabileId !== '' ? (int) $stabileId : null;
$nameLike = trim((string) ($this->option('name-like') ?? ''));
$limit = (int) $this->option('limit');
if ($limit < 1) $limit = 1;
if ($limit > 20000) $limit = 20000;
if (!$stabileId) {
$this->error('Specifica --stabile-id');
return self::FAILURE;
}
if (!Schema::hasTable('rubrica_ruoli') || !Schema::hasTable('rubrica_universale') || !Schema::hasTable('unita_immobiliari')) {
$this->error('Tabelle richieste mancanti (rubrica_ruoli/rubrica_universale/unita_immobiliari).');
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_deactivated_spurious_inquilino' => 0,
'roles_deactivated_mismatch_legacy' => 0,
'roles_deactivated_duplicate' => 0,
'roles_reactivated_inquilino' => 0,
'roles_kept' => 0,
'skipped_no_unit' => 0,
'skipped_no_legacy_match' => 0,
];
// Carica ruoli attivi dello stabile; opzionalmente filtra per nominativo.
$q = DB::table('rubrica_ruoli as rr')
->join('rubrica_universale as ru', 'ru.id', '=', 'rr.rubrica_id')
->leftJoin('unita_immobiliari as ui', 'ui.id', '=', 'rr.unita_immobiliare_id')
->where('rr.stabile_id', $stabileId)
->when(!$includeInactive, fn($qq) => $qq->where('rr.is_attivo', 1))
->orderBy('rr.id')
->limit($limit)
->select([
'rr.id',
'rr.rubrica_id',
'rr.ruolo_standard',
'rr.ruolo_custom',
'rr.unita_immobiliare_id',
'rr.is_attivo',
'rr.meta',
'ru.nome',
'ru.cognome',
'ru.ragione_sociale',
'ru.codice_fiscale',
'ui.scala',
'ui.interno',
'ui.codice_unita',
]);
if ($nameLike !== '') {
$needle = '%' . $nameLike . '%';
$q->where(function ($w) use ($needle) {
$w->where('ru.cognome', 'like', $needle)
->orWhere('ru.nome', 'like', $needle)
->orWhere('ru.ragione_sociale', 'like', $needle);
});
}
$rows = $q->get();
if ($rows->isEmpty()) {
$this->info('Nessun ruolo attivo trovato (con i filtri selezionati).');
return self::SUCCESS;
}
// Dedup: per rubrica+ruolo_standard+unità mantiene il record con id più alto.
$seen = [];
$inquilinoMarker = [];
foreach ($rows as $rr) {
$ruolo = strtolower(trim((string) ($rr->ruolo_standard ?? '')));
if ($ruolo !== 'inquilino') {
continue;
}
$metaArr = json_decode((string) ($rr->meta ?? ''), true);
$metaArr = is_array($metaArr) ? $metaArr : [];
$metaCodCond = $this->normalizeCodCondRaw(data_get($metaArr, 'cod_cond'));
$metaCodCondNum = $this->normalizeCodCond(data_get($metaArr, 'cod_cond'));
if ($metaCodCond && $this->isInquilinoCodCond($metaCodCond)) {
$unitaId = $rr->unita_immobiliare_id !== null ? (int) $rr->unita_immobiliare_id : null;
if ($unitaId) {
$inquilinoMarker[$unitaId] = true;
}
}
}
foreach ($rows as $rr) {
$stats['roles_scanned']++;
$ruolo = strtolower(trim((string) ($rr->ruolo_standard ?? '')));
$isAttivo = (int) ($rr->is_attivo ?? 0) === 1;
$unitaId = $rr->unita_immobiliare_id !== null ? (int) $rr->unita_immobiliare_id : null;
if (!$unitaId) {
$stats['skipped_no_unit']++;
continue;
}
if ($isAttivo) {
$dedupKey = (int) $rr->rubrica_id . '|' . $ruolo . '|' . $unitaId;
if (!isset($seen[$dedupKey])) {
$seen[$dedupKey] = (int) $rr->id;
} else {
// Se già visto, disattiva il più vecchio e tieni il più nuovo.
$keepId = max($seen[$dedupKey], (int) $rr->id);
$dropId = min($seen[$dedupKey], (int) $rr->id);
$seen[$dedupKey] = $keepId;
if ($dropId === (int) $rr->id) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'duplicate role per rubrica+ruolo+unita');
$stats['roles_deactivated_duplicate']++;
continue;
}
// Se devo droppare quello precedente, lo farò dopo con una passata mirata.
}
}
$metaArr = json_decode((string) ($rr->meta ?? ''), true);
$metaArr = is_array($metaArr) ? $metaArr : [];
$metaCodCond = $this->normalizeCodCondRaw(data_get($metaArr, 'cod_cond'));
if ($ruolo === 'inquilino' && $unitaId && ($inquilinoMarker[$unitaId] ?? false)) {
if (! $metaCodCond || ! $this->isInquilinoCodCond($metaCodCond)) {
if ($isAttivo) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'duplicate inquilino without INQ marker');
$stats['roles_deactivated_duplicate']++;
continue;
}
}
}
if ($ruolo === 'condomino' && $metaCodCond && $this->isInquilinoCodCond($metaCodCond)) {
if ($isAttivo) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'condomino with INQ marker');
$stats['roles_deactivated_duplicate']++;
continue;
}
}
$scala = $this->firstNonEmpty([
data_get($metaArr, 'scala'),
$rr->scala ?? null,
]);
$interno = $this->firstNonEmpty([
data_get($metaArr, 'interno'),
$rr->interno ?? null,
]);
$codStabile = $this->firstNonEmpty([
data_get($metaArr, 'cod_stabile'),
]);
$legacyDir = $this->firstNonEmpty([
data_get($metaArr, 'legacy_anno_dir'),
]);
if ($scala === null || $interno === null || $codStabile === null) {
$stats['skipped_no_legacy_match']++;
continue;
}
// Legacy snapshot: per una data unità, cerca separatamente ownerRow e tenantRow.
$legacyBase = DB::connection($conn)->table('condomin')
->where('cod_stabile', $codStabile);
if ($metaCodCondNum !== null) {
$legacyBase->where('cod_cond', $metaCodCondNum);
} else {
$legacyBase->where('scala', $scala)
->where('interno', $interno);
}
$legacyAny = null;
if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) {
$legacyAny = (clone $legacyBase)
->where('legacy_year', $legacyDir)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'proprietario',
'inquilino',
'cognome',
'nome',
'nom_cond',
'cond_cod_fisc',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
}
if (!$legacyAny) {
$legacyAny = (clone $legacyBase)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'proprietario',
'inquilino',
'cognome',
'nome',
'nom_cond',
'cond_cod_fisc',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
}
if (!$legacyAny) {
$stats['skipped_no_legacy_match']++;
continue;
}
$ownerRow = null;
if (Schema::connection($conn)->hasColumn('condomin', 'proprietario')) {
$ownerRowQ = (clone $legacyBase);
if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) {
$ownerRowQ->where('legacy_year', $legacyDir);
}
$ownerRow = $ownerRowQ
->where('proprietario', 1)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'nom_cond',
'nome',
'cognome',
'cond_cod_fisc',
]);
}
if (!$ownerRow && Schema::connection($conn)->hasColumn('condomin', 'proprietario')) {
// Fallback: se per l'anno richiesto non esiste la riga proprietario=1,
// cerca su qualsiasi anno. Evita che ownerRow si trasformi in una riga inquilino.
$ownerRow = (clone $legacyBase)
->where('proprietario', 1)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'nom_cond',
'nome',
'cognome',
'cond_cod_fisc',
]);
}
if (!$ownerRow) {
$ownerRow = $legacyAny;
}
$tenantRow = null;
$tenantRowQ = (clone $legacyBase);
if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) {
$tenantRowQ->where('legacy_year', $legacyDir);
}
if (Schema::connection($conn)->hasColumn('condomin', 'inquilino')) {
$tenantRow = (clone $tenantRowQ)
->where('inquilino', 1)
->orderByDesc('id')
->first([
'id',
'legacy_year',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
}
if (!$tenantRow) {
// Fallback: tenant presente anche se flag incoerente (campi identità valorizzati)
$tenantRow = (clone $tenantRowQ)
->where(function ($w) {
$w->whereNotNull('inquilino_denominazione')
->where('inquilino_denominazione', '!=', '')
->orWhere(function ($w2) {
$w2->whereNotNull('inquil_nome')->where('inquil_nome', '!=', '');
})
->orWhere(function ($w3) {
$w3->whereNotNull('inquil_cod_fisc')->where('inquil_cod_fisc', '!=', '');
});
})
->orderByDesc('id')
->first([
'id',
'legacy_year',
'inquilino_denominazione',
'inquil_nome',
'inquil_cod_fisc',
]);
}
$rubricaName = $this->normalizeIdentityStr($rr->ragione_sociale ?: trim((string) ($rr->nome ?? '') . ' ' . (string) ($rr->cognome ?? '')));
$rubricaCf = $this->normalizeCf($rr->codice_fiscale ?? null);
$legacyOwnerName = $this->normalizeIdentityStr($ownerRow->nom_cond ?? trim((string) ($ownerRow->nome ?? '') . ' ' . (string) ($ownerRow->cognome ?? '')));
$legacyOwnerCf = $this->normalizeCf($ownerRow->cond_cod_fisc ?? null);
$legacyTenantName = $this->normalizeIdentityStr($this->firstNonEmpty([
$tenantRow->inquilino_denominazione ?? null,
$tenantRow->inquil_nome ?? null,
]));
$legacyTenantCf = $this->normalizeCf($tenantRow->inquil_cod_fisc ?? null);
$hasTenant = ($legacyTenantName !== '' || $legacyTenantCf !== '');
$hasOwner = ($legacyOwnerName !== '' || $legacyOwnerCf !== '');
// Evita falsi positivi: se "inquilino" coincide col proprietario, consideralo assente.
if ($hasTenant && $this->identityMatches($legacyTenantName, $legacyTenantCf, $legacyOwnerName, $legacyOwnerCf)) {
$hasTenant = false;
}
if ($ruolo === 'inquilino') {
if (!$hasTenant) {
if ($isAttivo) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'spurious inquilino: legacy has no tenant');
$stats['roles_deactivated_spurious_inquilino']++;
continue;
}
} else {
$matchesTenant = $this->identityMatches($rubricaName, $rubricaCf, $legacyTenantName, $legacyTenantCf);
if (!$skipMismatch && !$matchesTenant && $isAttivo) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'mismatch legacy tenant for unit');
$stats['roles_deactivated_mismatch_legacy']++;
continue;
}
if ($reactivate && $apply && $includeInactive && !$isAttivo && $matchesTenant) {
$this->reactivateRubricaRuolo((int) $rr->id, 'reactivated: matches legacy tenant for unit');
$stats['roles_reactivated_inquilino']++;
// Non fare continue: lo consideriamo tenuto.
}
if ($reactivate && !$apply && $includeInactive && !$isAttivo && $matchesTenant) {
$stats['roles_reactivated_inquilino']++;
}
}
} elseif ($ruolo === 'condomino') {
if (!$hasOwner) {
if (!$skipMismatch) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'spurious condomino: legacy has no owner');
$stats['roles_deactivated_mismatch_legacy']++;
continue;
}
}
if (!$skipMismatch && !$this->identityMatches($rubricaName, $rubricaCf, $legacyOwnerName, $legacyOwnerCf)) {
$this->deactivateRubricaRuolo((int) $rr->id, $apply, 'mismatch legacy owner for unit');
$stats['roles_deactivated_mismatch_legacy']++;
continue;
}
}
$stats['roles_kept']++;
}
// Seconda passata: se per una key abbiamo visto un record più nuovo, droppa i più vecchi rimasti attivi.
foreach ($seen as $key => $keepId) {
[$rubricaId, $ruolo, $unitaId] = explode('|', $key);
$oldIds = DB::table('rubrica_ruoli')
->where('rubrica_id', (int) $rubricaId)
->where('ruolo_standard', $ruolo)
->where('unita_immobiliare_id', (int) $unitaId)
->where('is_attivo', 1)
->where('id', '!=', (int) $keepId)
->pluck('id')
->all();
foreach ($oldIds as $oldId) {
$this->deactivateRubricaRuolo((int) $oldId, $apply, 'duplicate role per rubrica+ruolo+unita (post-pass)');
$stats['roles_deactivated_duplicate']++;
}
}
$this->info('✅ Bonifica ruoli legacy completata' . ($apply ? '' : ' (dry-run)'));
$this->line(json_encode($stats, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
private function deactivateRubricaRuolo(int $id, bool $apply, string $reason): void
{
if (!$apply) {
return;
}
$row = DB::table('rubrica_ruoli')->where('id', $id)->first(['id', 'meta']);
$meta = [];
if ($row && !empty($row->meta)) {
$decoded = json_decode((string) $row->meta, true);
if (is_array($decoded)) {
$meta = $decoded;
}
}
$meta['bonifica'] = array_merge(is_array($meta['bonifica'] ?? null) ? $meta['bonifica'] : [], [
'disabled_at' => now()->toISOString(),
'disabled_reason' => $reason,
'source' => 'gescon:bonifica-ruoli-legacy',
]);
DB::table('rubrica_ruoli')
->where('id', $id)
->update([
'is_attivo' => 0,
'meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
}
private function reactivateRubricaRuolo(int $id, string $reason): void
{
$row = DB::table('rubrica_ruoli')->where('id', $id)->first(['id', 'meta']);
$meta = [];
if ($row && !empty($row->meta)) {
$decoded = json_decode((string) $row->meta, true);
if (is_array($decoded)) {
$meta = $decoded;
}
}
$meta['bonifica'] = array_merge(is_array($meta['bonifica'] ?? null) ? $meta['bonifica'] : [], [
'reactivated_at' => now()->toISOString(),
'reactivated_reason' => $reason,
'source' => 'gescon:bonifica-ruoli-legacy',
]);
DB::table('rubrica_ruoli')
->where('id', $id)
->update([
'is_attivo' => 1,
'meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
}
private function normalizeCf($cf): string
{
$cf = strtoupper(preg_replace('/\s+/', '', (string) ($cf ?? '')));
$cf = preg_replace('/[^A-Z0-9]/', '', $cf);
return $cf ?: '';
}
private function normalizeCodCondRaw(?string $value): ?string
{
$v = trim((string) $value);
if ($v === '') return null;
return strtoupper($v);
}
private function normalizeCodCond(?string $value): ?string
{
$v = trim((string) $value);
if ($v === '') return null;
if (preg_match('/(\d{1,})/', $v, $m)) {
return ltrim($m[1], '0') !== '' ? ltrim($m[1], '0') : $m[1];
}
return null;
}
private function isInquilinoCodCond(string $value): bool
{
$v = strtoupper(trim($value));
return str_starts_with($v, 'I') || str_contains($v, 'INQ');
}
private function normalizeIdentityStr($s): string
{
$s = strtoupper((string) ($s ?? ''));
$s = str_replace(['.', ',', ';', ':', '-', '/', '\\', '\t', "\n", "\r"], ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
$s = trim($s);
return $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;
}
// Match permissivo: token-based (ignora l'ordine).
$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;
}
// Fallback: contenimento stringa.
return str_contains($nameA, $nameB) || str_contains($nameB, $nameA);
}
/** @param array<int, mixed> $candidates */
private function firstNonEmpty(array $candidates): ?string
{
foreach ($candidates as $c) {
$v = trim((string) ($c ?? ''));
if ($v !== '') {
return $v;
}
}
return null;
}
}