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

632 lines
24 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\RubricaUniversale;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconQaRubricaLegamiCommand extends Command
{
protected $signature = 'gescon:qa-rubrica-legami
{--rubrica-id=* : Uno o piu ID rubrica da analizzare}
{--limit=50 : Numero massimo di record se non passi rubrica-id}
{--apply-safe : Applica solo fix non ambigui su riferimenti e rubrica_ruoli}';
protected $description = 'Analizza e, se richiesto, applica fix sicuri sui legami rubrica -> stabile -> unita -> rubrica_ruoli.';
public function handle(): int
{
foreach (['rubrica_universale', 'stabili', 'unita_immobiliari'] as $table) {
if (! Schema::hasTable($table)) {
$this->error('Tabella richiesta mancante: ' . $table);
return self::FAILURE;
}
}
$rubricaIds = array_values(array_filter(array_map(
fn($value): int => is_numeric($value) ? (int) $value : 0,
(array) $this->option('rubrica-id')
)));
$query = RubricaUniversale::query()
->where('categoria', 'condomino')
->orderByDesc('id');
if ($rubricaIds !== []) {
$query->whereIn('id', $rubricaIds);
} else {
$query->where(function ($inner): void {
$inner->whereNull('riferimento_stabile')
->orWhereNull('riferimento_unita');
});
}
$rows = $query->limit(max(1, min(500, (int) $this->option('limit'))))->get();
if ($rows->isEmpty()) {
$this->info('Nessun record rubrica da analizzare.');
return self::SUCCESS;
}
$applySafe = (bool) $this->option('apply-safe');
$updatedReferences = 0;
$createdRuoli = 0;
$ambiguous = 0;
$manualReview = 0;
foreach ($rows as $rubrica) {
$analysis = $this->analyzeRubrica($rubrica);
$this->line('');
$this->info('Rubrica #' . (int) $rubrica->id . ' - ' . $this->displayRubricaName($rubrica));
$this->line('Legacy: cod_stabile=' . ($analysis['legacy']['cod_stabile'] ?: '-') . ' | cod_cond=' . ($analysis['legacy']['cod_cond'] ?: '-') . ' | ruolo=' . ($analysis['legacy']['ruolo'] ?: '-'));
$this->line('Stabile candidato: ' . ($analysis['stabile_label'] ?: '-'));
$this->line('Ruoli esistenti: ' . count($analysis['existing_ruoli']));
$this->line('Persona match: ' . ($analysis['persona_summary'] !== '' ? $analysis['persona_summary'] : '-'));
if ($analysis['candidate_units'] === []) {
$this->warn('Nessuna unita candidata trovata.');
} else {
foreach ($analysis['candidate_units'] as $candidate) {
$this->line('- unita #' . $candidate['unit_id']
. ' [' . $candidate['source'] . '] '
. $candidate['unit_label']
. ' | ruolo=' . ($candidate['role_standard'] ?: '-')
. ' | fix-ruolo=' . ($candidate['safe_role_creation'] ? 'si' : 'no'));
}
}
if ($analysis['unique_unit'] === null && $analysis['candidate_units'] !== []) {
$ambiguous++;
$this->warn('Caso ambiguo: piu unita candidate o ruolo non sicuro.');
} elseif ($analysis['requires_manual_review']) {
$manualReview++;
$this->warn('Caso da revisione manuale: unita unica trovata, ma il ruolo strutturato non e abbastanza sicuro da creare rubrica_ruoli.');
}
if (! $applySafe) {
continue;
}
$applied = $this->applySafeFixes($rubrica, $analysis);
$updatedReferences += $applied['references_updated'];
$createdRuoli += $applied['ruoli_created'];
}
$this->line('');
$this->info('QA legami completato.');
$this->line('Record analizzati: ' . $rows->count());
$this->line('Casi ambigui: ' . $ambiguous);
$this->line('Casi da revisione manuale: ' . $manualReview);
if ($applySafe) {
$this->line('Riferimenti aggiornati: ' . $updatedReferences);
$this->line('Rubrica ruoli creati: ' . $createdRuoli);
}
return ($ambiguous > 0 || $manualReview > 0) ? self::INVALID : self::SUCCESS;
}
/**
* @return array<string,mixed>
*/
private function analyzeRubrica(RubricaUniversale $rubrica): array
{
$legacy = $this->parseLegacyMeta((string) ($rubrica->note ?? ''));
$stabile = $this->resolveStabile($legacy['cod_stabile']);
$existingRuoli = Schema::hasTable('rubrica_ruoli')
? DB::table('rubrica_ruoli')->where('rubrica_id', (int) $rubrica->id)->get()->all()
: [];
$personaMatches = $this->findPersonaMatches($rubrica, $stabile['id']);
$candidateUnits = $this->mergeCandidates(
$this->collectLegacyUnitCandidates($stabile['id'], $legacy),
$this->collectPersonaRelationCandidates($personaMatches, $stabile['id']),
$this->collectImportUnitCandidates($rubrica, $stabile['id'], $legacy)
);
$uniqueUnit = count($candidateUnits) === 1 ? $candidateUnits[0] : null;
if ($uniqueUnit !== null && ($uniqueUnit['role_standard'] ?? null) === null) {
$uniqueUnit['role_standard'] = $this->mapRoleStandard($legacy['ruolo']);
}
if ($uniqueUnit !== null && ($uniqueUnit['role_standard'] ?? null) === null) {
$uniqueUnit = null;
}
$requiresManualReview = $uniqueUnit !== null
&& ! empty($stabile['id'])
&& ! $uniqueUnit['safe_role_creation']
&& count($existingRuoli) === 0;
return [
'legacy' => $legacy,
'stabile' => $stabile,
'stabile_label' => $this->buildStabileReference($stabile),
'existing_ruoli' => $existingRuoli,
'candidate_units' => $candidateUnits,
'unique_unit' => $uniqueUnit,
'persona_summary' => $this->summarizePersonas($personaMatches),
'requires_manual_review' => $requiresManualReview,
];
}
/**
* @return array{references_updated:int,ruoli_created:int}
*/
private function applySafeFixes(RubricaUniversale $rubrica, array $analysis): array
{
$referencesUpdated = 0;
$ruoliCreated = 0;
$stabile = $analysis['stabile'];
$uniqueUnit = $analysis['unique_unit'];
$dirty = false;
if (($rubrica->riferimento_stabile === null || trim((string) $rubrica->riferimento_stabile) === '') && ! empty($stabile['id'])) {
$rubrica->riferimento_stabile = $this->buildStabileReference($stabile);
$dirty = true;
}
if (($rubrica->riferimento_unita === null || trim((string) $rubrica->riferimento_unita) === '') && $uniqueUnit !== null) {
$rubrica->riferimento_unita = $uniqueUnit['unit_reference'];
$dirty = true;
}
if ($dirty) {
$rubrica->data_ultima_modifica = now();
$rubrica->save();
$referencesUpdated++;
}
if (
$uniqueUnit !== null
&& ! empty($stabile['id'])
&& $uniqueUnit['safe_role_creation']
&& ($uniqueUnit['role_standard'] ?? null) !== null
&& Schema::hasTable('rubrica_ruoli')
&& count($analysis['existing_ruoli']) === 0
) {
DB::table('rubrica_ruoli')->updateOrInsert(
[
'rubrica_id' => (int) $rubrica->id,
'stabile_id' => (int) $stabile['id'],
'unita_immobiliare_id' => (int) $uniqueUnit['unit_id'],
'ruolo_standard' => $uniqueUnit['role_standard'],
],
[
'is_attivo' => true,
'is_preferito' => true,
'meta' => json_encode([
'source' => $uniqueUnit['source'],
'created_by' => 'gescon:qa-rubrica-legami',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => now(),
'created_at' => now(),
]
);
$ruoliCreated++;
}
return [
'references_updated' => $referencesUpdated,
'ruoli_created' => $ruoliCreated,
];
}
/**
* @return array{cod_stabile:?string,cod_cond:?string,ruolo:?string}
*/
private function parseLegacyMeta(string $note): array
{
$payload = [
'cod_stabile' => null,
'cod_cond' => null,
'ruolo' => null,
];
foreach (['cod_stabile', 'cod_cond', 'ruolo'] as $key) {
if (preg_match('/' . preg_quote($key, '/') . '=([^|\n\r]+)/i', $note, $matches) === 1) {
$payload[$key] = trim((string) ($matches[1] ?? '')) ?: null;
}
}
return $payload;
}
/**
* @return array{id:int|null,codice_stabile:?string,cod_stabile:?string,denominazione:?string}
*/
private function resolveStabile(?string $legacyCode): array
{
$blank = ['id' => null, 'codice_stabile' => null, 'cod_stabile' => null, 'denominazione' => null];
$code = trim((string) $legacyCode);
if ($code === '') {
return $blank;
}
$trimmed = ltrim($code, '0');
$row = DB::table('stabili')
->select(['id', 'codice_stabile', 'cod_stabile', 'denominazione'])
->where(function ($query) use ($code, $trimmed): void {
$query->where('codice_stabile', $code)
->orWhere('cod_stabile', $code);
if ($trimmed !== '' && $trimmed !== $code) {
$query->orWhere('codice_stabile', $trimmed)
->orWhere('cod_stabile', $trimmed);
}
})
->orderBy('id')
->first();
if (! $row) {
return $blank;
}
return [
'id' => (int) $row->id,
'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null,
'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null,
'denominazione' => $row->denominazione ? (string) $row->denominazione : null,
];
}
/**
* @return array<int,array<string,mixed>>
*/
private function collectLegacyUnitCandidates(?int $stabileId, array $legacy): array
{
if (! Schema::hasTable('unita_immobiliare_nominativi') || ! $stabileId || empty($legacy['cod_cond'])) {
return [];
}
$rows = DB::table('unita_immobiliare_nominativi as uin')
->join('unita_immobiliari as ui', 'ui.id', '=', 'uin.unita_immobiliare_id')
->where('uin.stabile_id', $stabileId)
->where('uin.legacy_cond_id', trim((string) $legacy['cod_cond']))
->select([
'ui.id as unit_id',
'ui.codice_unita',
'ui.denominazione',
'ui.scala',
'ui.interno',
'uin.ruolo',
])
->orderBy('ui.id')
->get();
$grouped = [];
foreach ($rows as $row) {
$unitId = (int) $row->unit_id;
$grouped[$unitId] ??= [
'unit_id' => $unitId,
'unit_label' => $this->buildUnitLabel($row),
'unit_reference' => $this->buildUnitReference($row),
'source' => 'legacy_local',
'role_tokens' => [],
'safe_role_creation' => true,
];
$roleToken = $this->mapLegacyRoleToken((string) ($row->ruolo ?? ''));
if ($roleToken !== null) {
$grouped[$unitId]['role_tokens'][$roleToken] = true;
}
}
return array_values(array_map(function (array $candidate) use ($legacy): array {
$roleStandard = $this->mapRoleStandard($legacy['ruolo']);
if ($roleStandard === null) {
$tokens = array_keys($candidate['role_tokens']);
$roleStandard = count($tokens) === 1 ? $tokens[0] : null;
}
unset($candidate['role_tokens']);
$candidate['role_standard'] = $roleStandard;
return $candidate;
}, $grouped));
}
/**
* @param array<int,array<string,mixed>> $personaMatches
* @return array<int,array<string,mixed>>
*/
private function collectPersonaRelationCandidates(array $personaMatches, ?int $stabileId): array
{
if (! Schema::hasTable('persone_unita_relazioni') || $personaMatches === []) {
return [];
}
$personaIds = array_values(array_unique(array_map(fn(array $row): int => (int) $row['id'], $personaMatches)));
$rows = DB::table('persone_unita_relazioni as pur')
->join('unita_immobiliari as ui', 'ui.id', '=', 'pur.unita_id')
->whereIn('pur.persona_id', $personaIds)
->when($stabileId, fn($query) => $query->where('ui.stabile_id', $stabileId))
->select([
'pur.persona_id',
'pur.tipo_relazione',
'ui.id as unit_id',
'ui.codice_unita',
'ui.denominazione',
'ui.scala',
'ui.interno',
])
->orderBy('ui.id')
->get();
$grouped = [];
foreach ($rows as $row) {
$unitId = (int) $row->unit_id;
$grouped[$unitId] ??= [
'unit_id' => $unitId,
'unit_label' => $this->buildUnitLabel($row),
'unit_reference' => $this->buildUnitReference($row),
'source' => 'persona_relazione',
'role_tokens' => [],
'safe_role_creation' => true,
];
$roleStandard = $this->mapRoleStandard((string) ($row->tipo_relazione ?? ''));
if ($roleStandard !== null) {
$grouped[$unitId]['role_tokens'][$roleStandard] = true;
}
}
return array_values(array_map(function (array $candidate): array {
$tokens = array_keys($candidate['role_tokens']);
unset($candidate['role_tokens']);
$candidate['role_standard'] = count($tokens) === 1 ? $tokens[0] : null;
return $candidate;
}, $grouped));
}
/**
* @return array<int,array<string,mixed>>
*/
private function collectImportUnitCandidates(RubricaUniversale $rubrica, ?int $stabileId, array $legacy): array
{
if (! $stabileId || empty($legacy['cod_stabile']) || empty($legacy['cod_cond']) || ! $this->hasGesconImportCondomin()) {
return [];
}
$rows = DB::connection('gescon_import')
->table('condomin')
->where('cod_stabile', trim((string) $legacy['cod_stabile']))
->where('cod_cond', trim((string) $legacy['cod_cond']))
->select(['nom_cond', 'scala', 'interno', 'proprietario', 'inquilino'])
->get();
$candidates = [];
foreach ($rows as $row) {
$unit = $this->resolveUnitFromImportRow($stabileId, [
'nom_cond' => (string) ($row->nom_cond ?? ''),
'scala' => (string) ($row->scala ?? ''),
'interno' => (string) ($row->interno ?? ''),
]);
if ($unit === null) {
continue;
}
$roleFromNote = $this->mapRoleStandard($legacy['ruolo']);
$candidates[] = [
'unit_id' => (int) $unit->unit_id,
'unit_label' => $this->buildUnitLabel($unit),
'unit_reference' => $this->buildUnitReference($unit),
'source' => 'gescon_import',
'role_standard' => $roleFromNote,
'safe_role_creation' => false,
];
}
return $candidates;
}
/**
* @return array<int,array<string,mixed>>
*/
private function findPersonaMatches(RubricaUniversale $rubrica, ?int $stabileId): array
{
if (! Schema::hasTable('persone')) {
return [];
}
$query = DB::table('persone')->select(['id', 'nome', 'cognome', 'ragione_sociale', 'codice_fiscale', 'email_principale', 'telefono_principale']);
$cf = trim((string) ($rubrica->codice_fiscale ?? ''));
if ($cf !== '') {
$rows = (clone $query)->where('codice_fiscale', $cf)->get();
if ($rows->isNotEmpty()) {
return $rows->map(fn($row) => (array) $row)->all();
}
}
$email = mb_strtolower(trim((string) ($rubrica->email ?? '')));
if ($email !== '') {
$rows = (clone $query)->whereRaw('LOWER(email_principale) = ?', [$email])->get();
if ($rows->isNotEmpty()) {
return $rows->map(fn($row) => (array) $row)->all();
}
}
$phone = $this->normalizeDigits((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: ''));
if ($phone !== '') {
$rows = (clone $query)
->whereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_principale, ''), ' ', ''), '+', ''), '-', '') = ?", [$phone])
->get();
if ($rows->isNotEmpty()) {
return $rows->map(fn($row) => (array) $row)->all();
}
}
$ragioneSociale = $this->normalizeText((string) ($rubrica->ragione_sociale ?: $rubrica->nome_completo));
if ($ragioneSociale !== '') {
return (clone $query)
->whereRaw("UPPER(REGEXP_REPLACE(COALESCE(ragione_sociale, ''), '[^A-Z0-9]', '')) = ?", [$ragioneSociale])
->get()
->map(fn($row) => (array) $row)
->all();
}
return [];
}
/**
* @param array<int,array<string,mixed>> ...$candidateSets
* @return array<int,array<string,mixed>>
*/
private function mergeCandidates(array ...$candidateSets): array
{
$merged = [];
foreach ($candidateSets as $candidateSet) {
foreach ($candidateSet as $candidate) {
$unitId = (int) ($candidate['unit_id'] ?? 0);
if ($unitId <= 0) {
continue;
}
if (! isset($merged[$unitId])) {
$merged[$unitId] = $candidate;
continue;
}
$merged[$unitId]['source'] .= '+' . $candidate['source'];
$merged[$unitId]['safe_role_creation'] = (bool) $merged[$unitId]['safe_role_creation'] || (bool) $candidate['safe_role_creation'];
if (($merged[$unitId]['role_standard'] ?? null) === null && ($candidate['role_standard'] ?? null) !== null) {
$merged[$unitId]['role_standard'] = $candidate['role_standard'];
}
}
}
ksort($merged);
return array_values($merged);
}
private function resolveUnitFromImportRow(int $stabileId, array $row): ?object
{
$query = DB::table('unita_immobiliari')
->where('stabile_id', $stabileId)
->select(['id as unit_id', 'codice_unita', 'denominazione', 'scala', 'interno']);
$scala = trim((string) ($row['scala'] ?? ''));
$interno = trim((string) ($row['interno'] ?? ''));
if ($scala !== '' || $interno !== '') {
$strict = (clone $query)
->when($scala !== '', fn($inner) => $inner->where('scala', $scala))
->when($interno !== '', fn($inner) => $inner->where('interno', $interno))
->get();
if ($strict->count() === 1) {
return $strict->first();
}
}
$normalizedName = $this->normalizeText((string) ($row['nom_cond'] ?? ''));
if ($normalizedName === '') {
return null;
}
$matches = (clone $query)->get()->filter(function ($unit) use ($normalizedName): bool {
$haystack = $this->normalizeText((string) ($unit->denominazione ?? ''));
return $haystack !== '' && ($haystack === $normalizedName || str_contains($haystack, $normalizedName) || str_contains($normalizedName, $haystack));
})->values();
return $matches->count() === 1 ? $matches->first() : null;
}
private function hasGesconImportCondomin(): bool
{
try {
return Schema::connection('gescon_import')->hasTable('condomin');
} catch (\Throwable) {
return false;
}
}
private function buildStabileReference(array $stabile): ?string
{
if (empty($stabile['id'])) {
return null;
}
$parts = array_values(array_filter([
trim((string) ($stabile['cod_stabile'] ?? '')),
trim((string) ($stabile['codice_stabile'] ?? '')),
trim((string) ($stabile['denominazione'] ?? '')),
]));
return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile['id']);
}
private function buildUnitReference(object|array $row): string
{
$code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', '')));
$label = trim((string) data_get((array) $row, 'denominazione', data_get($row, 'denominazione', '')));
return trim($code . ($label !== '' ? ' - ' . $label : ''));
}
private function buildUnitLabel(object|array $row): string
{
$code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', '')));
$scala = trim((string) data_get((array) $row, 'scala', data_get($row, 'scala', '')));
$interno = trim((string) data_get((array) $row, 'interno', data_get($row, 'interno', '')));
$denominazione = trim((string) data_get((array) $row, 'denominazione', data_get($row, 'denominazione', '')));
$bits = array_values(array_filter([$code, $scala, $interno, $denominazione]));
return implode(' / ', $bits);
}
private function mapRoleStandard(?string $value): ?string
{
$normalized = strtolower(trim((string) $value));
return match ($normalized) {
'c', 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino',
'i', 'inquilino', 'conduttore', 'locatario' => 'inquilino',
default => null,
};
}
private function mapLegacyRoleToken(string $value): ?string
{
return $this->mapRoleStandard($value);
}
private function displayRubricaName(RubricaUniversale $rubrica): string
{
return trim((string) ($rubrica->nome_completo ?: $rubrica->ragione_sociale ?: ('Rubrica #' . $rubrica->id)));
}
/**
* @param array<int,array<string,mixed>> $personaMatches
*/
private function summarizePersonas(array $personaMatches): string
{
if ($personaMatches === []) {
return '';
}
return implode(', ', array_map(function (array $row): string {
$label = trim((string) (($row['ragione_sociale'] ?? '') ?: trim((string) (($row['cognome'] ?? '') . ' ' . ($row['nome'] ?? '')))));
return '#' . (int) $row['id'] . ' ' . ($label !== '' ? $label : 'persona');
}, $personaMatches));
}
private function normalizeDigits(string $value): string
{
return preg_replace('/\D+/', '', $value) ?: '';
}
private function normalizeText(string $value): string
{
$upper = mb_strtoupper(trim($value));
$normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper);
return is_string($normalized) ? $normalized : '';
}
}