559 lines
22 KiB
PHP
559 lines
22 KiB
PHP
<?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;
|
|
}
|
|
}
|