457 lines
18 KiB
PHP
457 lines
18 KiB
PHP
<?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;
|
|
}
|
|
}
|