From a9204e125bf9491d4be3978eae923836df1ee8ee Mon Sep 17 00:00:00 2001 From: michele Date: Wed, 8 Apr 2026 14:48:10 +0000 Subject: [PATCH] Rubrica cleanup, PBX/Google contact sync, and import pilot validation --- .../GesconBonificaRubricaCanaliCommand.php | 401 ++++++++++++++++++ .../GooglePushRubricaContactsCommand.php | 157 ++++++- .../GoogleSyncRubricaContactsCommand.php | 71 +++- .../Commands/ImportGesconFullPipeline.php | 334 +++++++++++---- .../NetgesconQaUnitaNominativiCommand.php | 33 +- .../Pages/Gescon/RubricaUniversaleScheda.php | 151 ++++--- app/Filament/Pages/Strumenti/PostIt.php | 17 +- .../Pages/Strumenti/PostItGestione.php | 168 +++++--- app/Filament/Pages/UnitaImmobiliarePage.php | 2 +- .../Api/PanasonicCstaController.php | 21 +- .../Anagrafiche/RubricaPhoneLookupService.php | 91 ++++ app/Services/Cti/LiveIncomingCallService.php | 35 +- .../01-GESCON/GUIDA-IMPORT-GESCON-MDB.md | 80 ++++ docs/NETGESCON-MODIFICHE.md | 5 + docs/sviluppo/stabili_import.md | 97 ++++- .../components/topbar-context.blade.php | 33 +- .../rubrica-universale-scheda.blade.php | 38 +- 17 files changed, 1447 insertions(+), 287 deletions(-) create mode 100644 app/Console/Commands/GesconBonificaRubricaCanaliCommand.php create mode 100644 app/Services/Anagrafiche/RubricaPhoneLookupService.php diff --git a/app/Console/Commands/GesconBonificaRubricaCanaliCommand.php b/app/Console/Commands/GesconBonificaRubricaCanaliCommand.php new file mode 100644 index 0000000..31db2cd --- /dev/null +++ b/app/Console/Commands/GesconBonificaRubricaCanaliCommand.php @@ -0,0 +1,401 @@ +error('Tabella rubrica_universale non disponibile.'); + return self::FAILURE; + } + + if (! Schema::hasTable('rubrica_contatti_canali')) { + $this->error('Tabella rubrica_contatti_canali non disponibile.'); + return self::FAILURE; + } + + $apply = (bool) $this->option('apply'); + $limit = max(1, min((int) ($this->option('limit') ?? 5000), 50000)); + $adminId = (int) ($this->option('admin-id') ?: 0); + $stabile = trim((string) ($this->option('stabile') ?? '')); + + $query = RubricaUniversale::query()->orderBy('id'); + + if ($adminId > 0 && Schema::hasColumn('rubrica_universale', 'amministratore_id')) { + $query->where('amministratore_id', $adminId); + } + + if ($stabile !== '') { + $rubricaIds = $this->resolveScopedRubricaIds($stabile); + if ($rubricaIds === []) { + $this->warn('Nessuna rubrica trovata per lo stabile richiesto.'); + return self::SUCCESS; + } + + $query->whereIn('id', $rubricaIds); + } + + $records = $query->limit($limit)->get(); + if ($records->isEmpty()) { + $this->info('Nessuna rubrica da bonificare.'); + return self::SUCCESS; + } + + $stats = [ + 'analizzate' => 0, + 'rubriche_aggiornate' => 0, + 'canali_creati' => 0, + 'canali_aggiornati' => 0, + 'saltate' => 0, + ]; + + foreach ($records as $rubrica) { + $stats['analizzate']++; + + $contactSet = $this->extractStructuredContacts($rubrica); + if ($contactSet['channels'] === [] && $contactSet['updates'] === []) { + $stats['saltate']++; + continue; + } + + if ($apply && $contactSet['updates'] !== []) { + $rubrica->fill($contactSet['updates']); + $rubrica->save(); + $stats['rubriche_aggiornate']++; + } elseif ($contactSet['updates'] !== []) { + $stats['rubriche_aggiornate']++; + } + + foreach ($contactSet['channels'] as $channel) { + $existing = RubricaContattoCanale::query() + ->where('rubrica_id', (int) $rubrica->id) + ->where('tipo', $channel['tipo']) + ->where('valore', $channel['valore']) + ->whereNull('stabile_id') + ->whereNull('unita_immobiliare_id') + ->first(); + + if (! $apply) { + if ($existing instanceof RubricaContattoCanale) { + $stats['canali_aggiornati']++; + } else { + $stats['canali_creati']++; + } + continue; + } + + if ($existing instanceof RubricaContattoCanale) { + $existing->etichetta = $channel['etichetta']; + $existing->is_principale = $channel['is_principale']; + $existing->meta = $channel['meta']; + $existing->save(); + $stats['canali_aggiornati']++; + continue; + } + + RubricaContattoCanale::query()->create([ + 'rubrica_id' => (int) $rubrica->id, + 'tipo' => $channel['tipo'], + 'etichetta' => $channel['etichetta'], + 'valore' => $channel['valore'], + 'is_principale' => $channel['is_principale'], + 'meta' => $channel['meta'], + ]); + $stats['canali_creati']++; + } + } + + $mode = $apply ? 'apply' : 'dry-run'; + $this->info('Bonifica rubrica canali completata (' . $mode . '): ' . json_encode($stats, JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + /** @return array */ + private function resolveScopedRubricaIds(string $stabileCode): array + { + if (! Schema::hasTable('stabili')) { + return []; + } + + $trimmed = ltrim($stabileCode, '0'); + $stabileIds = Stabile::query() + ->where(function (Builder $query) use ($stabileCode, $trimmed): void { + $query->where('codice_stabile', $stabileCode); + if ($trimmed !== '' && $trimmed !== $stabileCode) { + $query->orWhere('codice_stabile', $trimmed); + } + }) + ->pluck('id') + ->map(fn($id) => (int) $id) + ->all(); + + if ($stabileIds === []) { + return []; + } + + $rubricaIds = RubricaContattoCanale::query() + ->whereIn('stabile_id', $stabileIds) + ->pluck('rubrica_id') + ->map(fn($id) => (int) $id) + ->all(); + + if (Schema::hasTable('rubrica_ruoli')) { + $roleRubricaIds = \DB::table('rubrica_ruoli') + ->whereIn('stabile_id', $stabileIds) + ->pluck('rubrica_id') + ->map(fn($id) => (int) $id) + ->all(); + $rubricaIds = array_merge($rubricaIds, $roleRubricaIds); + } + + $stabileRubricaIds = Stabile::query() + ->whereIn('id', $stabileIds) + ->whereNotNull('rubrica_id') + ->pluck('rubrica_id') + ->map(fn($id) => (int) $id) + ->all(); + + $rubricaIds = array_merge($rubricaIds, $stabileRubricaIds); + $rubricaIds = array_values(array_unique(array_filter($rubricaIds, fn(int $id): bool => $id > 0))); + + return $rubricaIds; + } + + /** @return array{updates: array, channels: array>} */ + private function extractStructuredContacts(RubricaUniversale $rubrica): array + { + $emails = $this->uniqueEmails([ + ...$this->splitEmails((string) ($rubrica->email ?? '')), + ]); + $pecs = $this->uniqueEmails([ + ...$this->splitEmails((string) ($rubrica->pec ?? '')), + ]); + + $mobilePhones = $this->normalizePhones($this->splitPhones((string) ($rubrica->telefono_cellulare ?? '')), 'cellulare'); + $officePhones = $this->normalizePhones($this->splitPhones((string) ($rubrica->telefono_ufficio ?? '')), 'telefono'); + $homePhones = $this->normalizePhones($this->splitPhones((string) ($rubrica->telefono_casa ?? '')), 'telefono'); + + $primaryEmail = $emails[0] ?? $this->normalizeEmail((string) ($rubrica->email ?? '')); + $primaryPec = $pecs[0] ?? $this->normalizeEmail((string) ($rubrica->pec ?? '')); + $primaryMobile = $mobilePhones[0]['value'] ?? $this->normalizePhoneDisplay((string) ($rubrica->telefono_cellulare ?? '')); + $primaryOffice = $officePhones[0]['value'] ?? $this->normalizePhoneDisplay((string) ($rubrica->telefono_ufficio ?? '')); + $primaryHome = $homePhones[0]['value'] ?? $this->normalizePhoneDisplay((string) ($rubrica->telefono_casa ?? '')); + + $updates = []; + if ($primaryEmail !== $this->normalizeEmail((string) ($rubrica->email ?? ''))) { + $updates['email'] = $primaryEmail; + } + if ($primaryPec !== $this->normalizeEmail((string) ($rubrica->pec ?? ''))) { + $updates['pec'] = $primaryPec; + } + if ($primaryMobile !== $this->normalizePhoneDisplay((string) ($rubrica->telefono_cellulare ?? ''))) { + $updates['telefono_cellulare'] = $primaryMobile; + [$e164, $prefix, $national] = PhoneNumber::splitItaly($primaryMobile); + $updates['prefisso_cellulare'] = $prefix; + $updates['telefono_cellulare_nazionale'] = $national; + } + if ($primaryOffice !== $this->normalizePhoneDisplay((string) ($rubrica->telefono_ufficio ?? ''))) { + $updates['telefono_ufficio'] = $primaryOffice; + } + if ($primaryHome !== $this->normalizePhoneDisplay((string) ($rubrica->telefono_casa ?? ''))) { + $updates['telefono_casa'] = $primaryHome; + } + + $channels = []; + foreach ($emails as $index => $email) { + $channels[] = $this->buildChannel('email', $email, $index === 0 ? 'Email primaria' : 'Email aggiuntiva', $index === 0, 'rubrica.email'); + } + foreach ($pecs as $index => $email) { + $channels[] = $this->buildChannel('pec', $email, $index === 0 ? 'PEC primaria' : 'PEC aggiuntiva', $index === 0, 'rubrica.pec'); + } + foreach ($mobilePhones as $index => $phone) { + $channels[] = $this->buildChannel('cellulare', $phone['value'], $index === 0 ? 'Cellulare principale' : 'Cellulare aggiuntivo', $index === 0, 'rubrica.telefono_cellulare'); + } + foreach ($officePhones as $index => $phone) { + $channels[] = $this->buildChannel('telefono', $phone['value'], $index === 0 ? 'Telefono ufficio' : 'Telefono aggiuntivo', $index === 0, 'rubrica.telefono_ufficio'); + } + foreach ($homePhones as $index => $phone) { + $channels[] = $this->buildChannel('telefono', $phone['value'], $index === 0 ? 'Telefono casa' : 'Telefono casa aggiuntivo', false, 'rubrica.telefono_casa'); + } + + return [ + 'updates' => $updates, + 'channels' => $this->deduplicateChannels($channels), + ]; + } + + /** @return array */ + private function splitEmails(string $value): array + { + $raw = trim($value); + if ($raw === '') { + return []; + } + + $parts = preg_split('/[;,\n\r]+/', $raw) ?: []; + + return array_values(array_filter(array_map(fn(string $part): ?string => $this->normalizeEmail($part), $parts))); + } + + /** @return array */ + private function splitPhones(string $value): array + { + $raw = trim($value); + if ($raw === '') { + return []; + } + + if (! preg_match('/[;,\n\r\/|]/', $raw)) { + return [$raw]; + } + + $parts = preg_split('/[;,\n\r\/|]+/', $raw) ?: []; + + return array_values(array_filter(array_map(fn(string $part): ?string => $this->normalizePhoneDisplay($part), $parts))); + } + + private function normalizeEmail(?string $value): ?string + { + $normalized = strtolower(trim((string) $value)); + return filter_var($normalized, FILTER_VALIDATE_EMAIL) ? $normalized : null; + } + + private function normalizePhoneDisplay(?string $value): ?string + { + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + $clean = preg_replace('/[^0-9+]/', '', $raw); + return is_string($clean) && $clean !== '' ? $clean : null; + } + + /** @param array $values + * @return array + */ + private function normalizePhones(array $values, string $defaultType): array + { + $result = []; + foreach ($values as $value) { + $clean = $this->normalizePhoneDisplay($value); + if ($clean === null) { + continue; + } + + $digits = PhoneNumber::normalizeDigits($clean); + if ($digits === '') { + continue; + } + + $type = $defaultType; + if (str_starts_with($digits, '3')) { + $type = 'cellulare'; + } elseif (str_starts_with($digits, '0')) { + $type = 'telefono'; + } + + $normalizedValue = $type === 'cellulare' + ? (PhoneNumber::toE164Italy($clean) ?? $clean) + : $clean; + + $result[] = [ + 'value' => $normalizedValue, + 'type' => $type, + ]; + } + + $unique = []; + $seen = []; + foreach ($result as $row) { + $key = $row['type'] . '|' . PhoneNumber::normalizeDigits($row['value']); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $unique[] = $row; + } + + return $unique; + } + + /** @param array $values + * @return array + */ + private function uniqueEmails(array $values): array + { + $unique = []; + foreach ($values as $value) { + $normalized = $this->normalizeEmail($value); + if ($normalized === null || in_array($normalized, $unique, true)) { + continue; + } + $unique[] = $normalized; + } + + return $unique; + } + + /** @return array */ + private function buildChannel(string $tipo, string $valore, string $etichetta, bool $isPrincipale, string $source): array + { + return [ + 'tipo' => $tipo, + 'valore' => $valore, + 'etichetta' => $etichetta, + 'is_principale' => $isPrincipale, + 'meta' => [ + 'source' => $source, + ], + ]; + } + + /** @param array> $channels + * @return array> + */ + private function deduplicateChannels(array $channels): array + { + $result = []; + $seen = []; + + foreach ($channels as $channel) { + $key = $channel['tipo'] . '|'; + if (in_array($channel['tipo'], ['telefono', 'cellulare'], true)) { + $key .= PhoneNumber::normalizeDigits((string) $channel['valore']); + } else { + $key .= strtolower((string) $channel['valore']); + } + + if (isset($seen[$key])) { + if (! empty($channel['is_principale'])) { + $result[$seen[$key]]['is_principale'] = true; + } + continue; + } + + $seen[$key] = count($result); + $result[] = $channel; + } + + return $result; + } +} \ No newline at end of file diff --git a/app/Console/Commands/GooglePushRubricaContactsCommand.php b/app/Console/Commands/GooglePushRubricaContactsCommand.php index 6465892..5ff3021 100644 --- a/app/Console/Commands/GooglePushRubricaContactsCommand.php +++ b/app/Console/Commands/GooglePushRubricaContactsCommand.php @@ -3,12 +3,14 @@ namespace App\Console\Commands; use App\Models\Amministratore; +use App\Models\RubricaContattoCanale; use App\Models\RubricaUniversale; use App\Support\PhoneNumber; use Illuminate\Console\Command; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Schema; class GooglePushRubricaContactsCommand extends Command { @@ -86,8 +88,10 @@ public function handle(): int $errors = 0; foreach ($records as $record) { - $email = trim((string) ($record->email ?? '')); - $phone = $this->mobileForGoogle($record) ?? ''; + $emails = $this->emailsForGoogle($record); + $phones = $this->phonesForGoogle($record); + $email = $emails[0]['value'] ?? ''; + $phone = $phones[0]['value'] ?? ''; $phoneNormalized = $this->normalizePhone($phone); [$givenName, $familyName, $fullName] = $this->buildGoogleNameParts($record); @@ -100,11 +104,11 @@ public function handle(): int ]], ]; - if ($email !== '') { - $payload['emailAddresses'] = [['value' => $email]]; + if ($emails !== []) { + $payload['emailAddresses'] = $emails; } - if ($phone !== '') { - $payload['phoneNumbers'] = [['value' => $phone, 'type' => 'mobile', 'formattedType' => 'Cellulare']]; + if ($phones !== []) { + $payload['phoneNumbers'] = $phones; } $resourceName = null; @@ -282,16 +286,19 @@ private function loadGoogleContactIndex(Amministratore $amministratore, array $g foreach ($connections as $person) { $resourceName = trim((string) Arr::get($person, 'resourceName', '')); - $email = trim((string) Arr::get($person, 'emailAddresses.0.value', '')); - $phone = trim((string) Arr::get($person, 'phoneNumbers.0.value', '')); - - if ($email !== '' && $resourceName !== '') { - $existingEmails[strtolower($email)] = $resourceName; + foreach ((array) Arr::get($person, 'emailAddresses', []) as $emailRow) { + $email = trim((string) Arr::get($emailRow, 'value', '')); + if ($email !== '' && $resourceName !== '') { + $existingEmails[strtolower($email)] = $resourceName; + } } - if ($phone !== '' && $resourceName !== '') { - $normalized = $this->normalizePhone($phone); - if ($normalized !== '') { - $existingPhones[$normalized] = $resourceName; + foreach ((array) Arr::get($person, 'phoneNumbers', []) as $phoneRow) { + $phone = trim((string) Arr::get($phoneRow, 'value', '')); + if ($phone !== '' && $resourceName !== '') { + $normalized = $this->normalizePhone($phone); + if ($normalized !== '') { + $existingPhones[$normalized] = $resourceName; + } } } } @@ -408,6 +415,126 @@ private function mobileForGoogle(RubricaUniversale $record): ?string return PhoneNumber::toE164Italy($candidate); } + /** @return array> */ + private function emailsForGoogle(RubricaUniversale $record): array + { + $emails = []; + $seen = []; + + $push = function (?string $value, string $type, string $formattedType) use (&$emails, &$seen): void { + $email = strtolower(trim((string) $value)); + if ($email === '' || isset($seen[$email])) { + return; + } + $seen[$email] = true; + $emails[] = [ + 'value' => $email, + 'type' => $type, + 'formattedType' => $formattedType, + ]; + }; + + foreach ($this->splitEmailValues((string) ($record->email ?? '')) as $email) { + $push($email, 'work', 'Email'); + } + foreach ($this->splitEmailValues((string) ($record->pec ?? '')) as $email) { + $push($email, 'other', 'PEC'); + } + + if (Schema::hasTable('rubrica_contatti_canali')) { + $channels = RubricaContattoCanale::query() + ->where('rubrica_id', (int) $record->id) + ->whereIn('tipo', ['email', 'pec']) + ->orderByDesc('is_principale') + ->orderBy('id') + ->get(['tipo', 'valore']); + + foreach ($channels as $channel) { + $push((string) ($channel->valore ?? ''), (string) ($channel->tipo ?? '') === 'pec' ? 'other' : 'work', (string) ($channel->tipo ?? '') === 'pec' ? 'PEC' : 'Email'); + } + } + + return $emails; + } + + /** @return array> */ + private function phonesForGoogle(RubricaUniversale $record): array + { + $phones = []; + $seen = []; + + $push = function (?string $value, string $type, string $formattedType) use (&$phones, &$seen): void { + $phone = trim((string) $value); + if ($phone === '') { + return; + } + + $normalized = $this->normalizePhone($phone); + if ($normalized === '' || isset($seen[$normalized])) { + return; + } + + $seen[$normalized] = true; + $phones[] = [ + 'value' => $phone, + 'type' => $type, + 'formattedType' => $formattedType, + ]; + }; + + foreach ($this->splitPhoneValues((string) ($record->telefono_cellulare ?? '')) as $phone) { + $push(PhoneNumber::toE164Italy($phone) ?? $phone, 'mobile', 'Cellulare'); + } + if (($primaryMobile = $this->mobileForGoogle($record)) !== null) { + $push($primaryMobile, 'mobile', 'Cellulare'); + } + foreach ($this->splitPhoneValues((string) ($record->telefono_ufficio ?? '')) as $phone) { + $push($phone, 'work', 'Ufficio'); + } + foreach ($this->splitPhoneValues((string) ($record->telefono_casa ?? '')) as $phone) { + $push($phone, 'home', 'Casa'); + } + + if (Schema::hasTable('rubrica_contatti_canali')) { + $channels = RubricaContattoCanale::query() + ->where('rubrica_id', (int) $record->id) + ->whereIn('tipo', ['telefono', 'cellulare']) + ->orderByDesc('is_principale') + ->orderBy('id') + ->get(['tipo', 'valore']); + + foreach ($channels as $channel) { + $isMobile = (string) ($channel->tipo ?? '') === 'cellulare'; + $value = $isMobile ? (PhoneNumber::toE164Italy((string) ($channel->valore ?? '')) ?? (string) ($channel->valore ?? '')) : (string) ($channel->valore ?? ''); + $push($value, $isMobile ? 'mobile' : 'work', $isMobile ? 'Cellulare' : 'Telefono'); + } + } + + return $phones; + } + + /** @return array */ + private function splitEmailValues(string $value): array + { + $parts = preg_split('/[;,\n\r]+/', trim($value)) ?: []; + + return array_values(array_filter(array_map(function (string $part): ?string { + $email = strtolower(trim($part)); + return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + }, $parts))); + } + + /** @return array */ + private function splitPhoneValues(string $value): array + { + $parts = preg_split('/[;,\n\r\/|]+/', trim($value)) ?: []; + + return array_values(array_filter(array_map(function (string $part): ?string { + $clean = preg_replace('/[^0-9+]/', '', trim($part)); + return is_string($clean) && $clean !== '' ? $clean : null; + }, $parts))); + } + /** * @return array{0:string,1:string,2:string} */ diff --git a/app/Console/Commands/GoogleSyncRubricaContactsCommand.php b/app/Console/Commands/GoogleSyncRubricaContactsCommand.php index 6cc7794..77e5fd6 100644 --- a/app/Console/Commands/GoogleSyncRubricaContactsCommand.php +++ b/app/Console/Commands/GoogleSyncRubricaContactsCommand.php @@ -2,12 +2,14 @@ namespace App\Console\Commands; use App\Models\Amministratore; +use App\Models\RubricaContattoCanale; use App\Models\RubricaUniversale; use App\Support\PhoneNumber; use Illuminate\Console\Command; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Schema; class GoogleSyncRubricaContactsCommand extends Command { @@ -194,9 +196,13 @@ public function handle(): int $existing->modificato_da = $adminUserId; } $existing->save(); + $this->syncAdditionalGoogleChannels($existing, $person); } $updated++; } else { + if (! $dryRun) { + $this->syncAdditionalGoogleChannels($existing, $person); + } $skipped++; } @@ -208,7 +214,7 @@ public function handle(): int continue; } - RubricaUniversale::create([ + $createdRubrica = RubricaUniversale::create([ 'nome' => $nome, 'cognome' => $cognome, 'tipo_contatto' => $tipoContatto, @@ -225,6 +231,7 @@ public function handle(): int 'creato_da' => $adminUserId > 0 ? $adminUserId : null, 'modificato_da' => $adminUserId > 0 ? $adminUserId : null, ]); + $this->syncAdditionalGoogleChannels($createdRubrica, $person); } $nextPageToken = $response->json('nextPageToken'); @@ -400,4 +407,66 @@ private function shouldSyncField(?string $current, ?string $incoming, bool $isGo return mb_strtolower($current) !== mb_strtolower($incoming); } + + private function syncAdditionalGoogleChannels(RubricaUniversale $rubrica, array $person): void + { + if (! Schema::hasTable('rubrica_contatti_canali')) { + return; + } + + $emailRows = (array) Arr::get($person, 'emailAddresses', []); + foreach ($emailRows as $index => $emailRow) { + $value = strtolower(trim((string) Arr::get($emailRow, 'value', ''))); + if ($value === '') { + continue; + } + + $tipo = str_contains($value, '@cert.') ? 'pec' : 'email'; + RubricaContattoCanale::query()->updateOrCreate( + [ + 'rubrica_id' => (int) $rubrica->id, + 'tipo' => $tipo, + 'valore' => $value, + 'stabile_id' => null, + 'unita_immobiliare_id' => null, + ], + [ + 'etichetta' => $tipo === 'pec' ? 'PEC Google' : 'Email Google', + 'is_principale' => $index === 0, + 'meta' => ['source' => 'google_workspace'], + ] + ); + } + + $phoneRows = (array) Arr::get($person, 'phoneNumbers', []); + foreach ($phoneRows as $index => $phoneRow) { + $raw = trim((string) Arr::get($phoneRow, 'value', '')); + if ($raw === '') { + continue; + } + + $digits = PhoneNumber::normalizeDigits($raw); + if ($digits === '') { + continue; + } + + $tipo = str_starts_with($digits, '3') || str_starts_with($digits, '39') ? 'cellulare' : 'telefono'; + $value = $tipo === 'cellulare' ? (PhoneNumber::toE164Italy($raw) ?? $raw) : $raw; + + RubricaContattoCanale::query()->updateOrCreate( + [ + 'rubrica_id' => (int) $rubrica->id, + 'tipo' => $tipo, + 'valore' => $value, + 'stabile_id' => null, + 'unita_immobiliare_id' => null, + ], + [ + 'etichetta' => $tipo === 'cellulare' ? 'Cellulare Google' : 'Telefono Google', + 'is_principale' => $index === 0, + 'meta' => ['source' => 'google_workspace'], + ] + ); + } + } } diff --git a/app/Console/Commands/ImportGesconFullPipeline.php b/app/Console/Commands/ImportGesconFullPipeline.php index 61d6829..b54033c 100644 --- a/app/Console/Commands/ImportGesconFullPipeline.php +++ b/app/Console/Commands/ImportGesconFullPipeline.php @@ -27,6 +27,7 @@ class ImportGesconFullPipeline extends Command {--solo= : Step singolo: unita|relazioni|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto} {--no-views : Non ricrea le viste QA} {--force-mapping : Applica mapping anche sovrascrivendo valori esistenti} + {--fill-missing-millesimi : Crea righe mancanti a zero per ogni unita/tabella (uso eccezionale)} {--amministratore-id= : Forza l\'assegnazione dello stabile a questo amministratore} '; @@ -80,6 +81,70 @@ private function scopeCondominQuery($query) return $query; } + private array $stagingLatestLegacyYearCache = []; + + private function resolveLatestStagingLegacyYear(string $table, ?string $stabile = null): ?string + { + $cacheKey = $table . '|' . ($stabile ?? '*'); + if (array_key_exists($cacheKey, $this->stagingLatestLegacyYearCache)) { + return $this->stagingLatestLegacyYearCache[$cacheKey]; + } + + if (! Schema::connection('gescon_import')->hasTable($table)) { + return $this->stagingLatestLegacyYearCache[$cacheKey] = null; + } + + if (! Schema::connection('gescon_import')->hasColumn($table, 'legacy_year')) { + return $this->stagingLatestLegacyYearCache[$cacheKey] = $this->requestedLegacyYear(); + } + + $query = DB::connection('gescon_import')->table($table) + ->whereNotNull('legacy_year') + ->where('legacy_year', '!=', ''); + + if ($stabile !== null && $stabile !== '' && Schema::connection('gescon_import')->hasColumn($table, 'cod_stabile')) { + $query->where('cod_stabile', $stabile); + } + + $value = $query->orderByDesc('legacy_year')->value('legacy_year'); + + return $this->stagingLatestLegacyYearCache[$cacheKey] = ($value !== null ? trim((string) $value) : null); + } + + private function currentSnapshotCondominQuery() + { + $query = DB::connection('gescon_import')->table('condomin'); + if ($this->option('stabile')) { + $query->where('cod_stabile', $this->option('stabile')); + } + if ($this->option('scala')) { + $query->where('scala', $this->option('scala')); + } + + $latestYear = $this->resolveLatestStagingLegacyYear('condomin', (string) ($this->option('stabile') ?? '')); + if ($latestYear !== null && Schema::connection('gescon_import')->hasColumn('condomin', 'legacy_year')) { + $query->where('legacy_year', $latestYear); + } + + return $query; + } + + private function currentSnapshotComproprietariQuery() + { + $query = DB::connection('gescon_import')->table('comproprietari'); + if ($this->option('stabile')) { + $query->where('cod_stabile', $this->option('stabile')); + } + + $latestYear = $this->resolveLatestStagingLegacyYear('comproprietari', (string) ($this->option('stabile') ?? '')) + ?: $this->resolveLatestStagingLegacyYear('condomin', (string) ($this->option('stabile') ?? '')); + if ($latestYear !== null && Schema::connection('gescon_import')->hasColumn('comproprietari', 'legacy_year')) { + $query->where('legacy_year', $latestYear); + } + + return $query; + } + // Mapping campi configurato via UI (per-utente/per-stabile). Caricato on-demand. private const BOOLEAN_TARGET_KEYS = [ 'stabile.ascensore', @@ -526,13 +591,7 @@ private function stepUnita(?int $limit): int $batch = $limit ? min(max(1, $limit), 1000) : 1000; $offset = 0; while (true) { - $q = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')); - if ($this->option('stabile')) { - $q->where('cod_stabile', $this->option('stabile')); - } - if ($this->option('scala')) { - $q->where('scala', $this->option('scala')); - } + $q = $this->currentSnapshotCondominQuery(); $q->limit($batch)->offset($offset); $rows = $q->get(); if ($rows->isEmpty()) { @@ -577,8 +636,14 @@ private function stepUnita(?int $limit): int } else { $rowStabileId = $this->stabileId; } - $interno = $r->interno ?? null; - $sub = $r->sub ?? $interno; + $interno = isset($r->interno) ? trim((string) $r->interno) : null; + if ($interno === '') { + $interno = null; + } + $sub = isset($r->catasto_sub) ? trim((string) $r->catasto_sub) : (isset($r->sub) ? trim((string) $r->sub) : $interno); + if ($sub === '') { + $sub = null; + } // Riconosci pertinenze nel campo INT o note: cantina, box/garage, posto auto, soffitta/solaio, locale tecnico $internoStr = is_string($interno) ? trim($interno) : (string) $interno; $ptype = null; @@ -614,12 +679,37 @@ private function stepUnita(?int $limit): int $pianoVal = (int) $pv; } } + $normalizedInterno = $ptype && $pcode ? $pcode : $interno; + if ($normalizedInterno !== null) { + $normalizedInterno = trim((string) $normalizedInterno); + if ($normalizedInterno === '' || (! $ptype && preg_match('/^0+$/', $normalizedInterno))) { + $normalizedInterno = null; + } + } if ($hasDetailedSchema) { - // Codice unita coerente: STAB-SCALA-INT (per cantine: STAB-SCALA-CAN) - $intPart = $ptype && $pcode ? $pcode : (($interno ?? null) ?: '0'); + $exists = null; + if ($legacyCondId !== null && $legacyCondId !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { + $exists = DB::table('unita_immobiliari') + ->where('stabile_id', $rowStabileId) + ->where('legacy_cond_id', $legacyCondId) + ->first(); + } + + if ($normalizedInterno === null && ! $exists) { + $this->warn(sprintf( + 'Unita saltata per stabile %s cod_cond %s: interno mancante nello snapshot corrente autorevole.', + (string) ($r->cod_stabile ?? $this->option('stabile') ?? '-'), + $legacyCondId !== null && $legacyCondId !== '' ? $legacyCondId : '-' + )); + continue; + } + + // Codice unita coerente: STAB-SCALA-INT (per pertinenze: STAB-SCALA-CAN/BOX/...) + $intPart = $normalizedInterno; $codiceBaseStab = ($r->cod_stabile ?? $this->option('stabile')) ?: 'S0'; - // Se stabili-per-scala, includi comunque la scala nel codice per leggibilità - $codice = $codiceBaseStab . '-' . ($scalaVal ?: 'A') . '-' . $intPart; + $codice = $intPart !== null + ? $this->buildLegacyUnitCode($codiceBaseStab, $scalaVal ?: 'A', (string) $intPart, $legacyCondId) + : (string) ($exists->codice_unita ?? ''); // Filtri post-normalizzazione: pertinenze e piano min/max if ($this->option('only-pertinenze')) { $opt = strtolower((string) $this->option('only-pertinenze')); @@ -637,18 +727,11 @@ private function stepUnita(?int $limit): int if ($pMax !== null && $pianoVal > $pMax) { continue; } - $exists = null; - if ($legacyCondId !== null && $legacyCondId !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { - $exists = DB::table('unita_immobiliari') - ->where('stabile_id', $rowStabileId) - ->where('legacy_cond_id', $legacyCondId) - ->first(); - } - if (! $exists) { + if (! $exists && $codice !== '') { $exists = DB::table('unita_immobiliari')->where('codice_unita', $codice)->first(); if ($exists && $legacyCondId !== null && $legacyCondId !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { if (! empty($exists->legacy_cond_id) && (string) $exists->legacy_cond_id !== (string) $legacyCondId) { - $codice = $codice . '-C' . $legacyCondId; + $codice = $this->buildLegacyUnitCode($codiceBaseStab, $scalaVal ?: 'A', (string) $intPart, 'C' . $legacyCondId); $exists = DB::table('unita_immobiliari')->where('codice_unita', $codice)->first(); } } @@ -661,6 +744,39 @@ private function stepUnita(?int $limit): int $patch['legacy_cond_id'] = $legacyCondId; } } + if ($codice !== '' && Schema::hasColumn('unita_immobiliari', 'codice_unita') && (string) ($exists->codice_unita ?? '') !== $codice) { + $codeOwner = DB::table('unita_immobiliari') + ->where('codice_unita', $codice) + ->where('id', '!=', $exists->id) + ->first(); + if (! $codeOwner) { + $patch['codice_unita'] = $codice; + } + } + if ($normalizedInterno !== null && Schema::hasColumn('unita_immobiliari', 'interno') && (string) ($exists->interno ?? '') !== $normalizedInterno) { + $patch['interno'] = $normalizedInterno; + } + if (Schema::hasColumn('unita_immobiliari', 'subalterno') && (string) ($exists->subalterno ?? '') !== (string) ($sub ?? '')) { + $patch['subalterno'] = $sub; + } + if (Schema::hasColumn('unita_immobiliari', 'piano') && (int) ($exists->piano ?? 0) !== $pianoVal) { + $patch['piano'] = $pianoVal; + } + if (Schema::hasColumn('unita_immobiliari', 'denominazione')) { + $denominazione = trim(($r->cognome ?? '') . ' ' . ($r->nome ?? '')) ?: null; + if ($denominazione !== null && (string) ($exists->denominazione ?? '') !== $denominazione) { + $patch['denominazione'] = $denominazione; + } + } + if (Schema::hasColumn('unita_immobiliari', 'tipo_unita') && (string) ($exists->tipo_unita ?? '') !== ($ptype ?: 'abitazione')) { + $patch['tipo_unita'] = $ptype ?: 'abitazione'; + } + if (Schema::hasColumn('unita_immobiliari', 'attiva') && ! (bool) ($exists->attiva ?? false)) { + $patch['attiva'] = true; + } + if (Schema::hasColumn('unita_immobiliari', 'deleted_at') && ! empty($exists->deleted_at)) { + $patch['deleted_at'] = null; + } if ($this->splitMode === 'palazzine') { if (Schema::hasColumn('unita_immobiliari', 'palazzina')) { if (empty($exists->palazzina) || $exists->palazzina !== $scalaVal) { @@ -692,8 +808,8 @@ private function stepUnita(?int $limit): int 'palazzina' => $this->splitMode === 'palazzine' ? $scalaVal : null, 'scala' => $scalaVal, 'piano' => $pianoVal, - 'interno' => (string) ($ptype && $pcode ? $pcode : $interno), - 'subalterno' => (string) $sub, + 'interno' => $normalizedInterno, + 'subalterno' => $sub, 'categoria_catastale' => null, 'classe' => null, 'consistenza' => null, @@ -788,8 +904,7 @@ private function stepUnita(?int $limit): int // Cleanup: rimuovi unità non presenti in condomin (evita "alieni") if (! $this->isDryRun && $this->option('stabile')) { - $condRows = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')) - ->where('cod_stabile', $this->option('stabile')) + $condRows = $this->currentSnapshotCondominQuery() ->select('scala', 'interno', 'cod_cond') ->get(); $condSet = []; @@ -920,13 +1035,7 @@ private function stepSoggetti(?int $limit): int $batch = $limit ? min(max(1, $limit), 1000) : 1000; $offset = 0; while (true) { - $q = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')); - if ($this->option('stabile')) { - $q->where('cod_stabile', $this->option('stabile')); - } - if ($this->option('scala')) { - $q->where('scala', $this->option('scala')); - } + $q = $this->currentSnapshotCondominQuery(); $q->limit($batch)->offset($offset); $rows = $q->get(); if ($rows->isEmpty()) { @@ -1050,13 +1159,7 @@ private function stepDiritti(?int $limit): int return $value ?? ''; }; while (true) { - $q = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')); - if ($this->option('stabile')) { - $q->where('cod_stabile', $this->option('stabile')); - } - if ($this->option('scala')) { - $q->where('scala', $this->option('scala')); - } + $q = $this->currentSnapshotCondominQuery(); $q->limit($batch)->offset($offset); $rows = $q->get(); if ($rows->isEmpty()) { @@ -1262,10 +1365,7 @@ private function stepDiritti(?int $limit): int // Import comproprietari (se presenti) if (Schema::connection('gescon_import')->hasTable('comproprietari')) { - $q = DB::connection('gescon_import')->table('comproprietari'); - if ($this->option('stabile')) { - $q->where('cod_stabile', $this->option('stabile')); - } + $q = $this->currentSnapshotComproprietariQuery(); $comprRows = $q->limit(50000)->get(); foreach ($comprRows as $cr) { @@ -1970,8 +2070,10 @@ private function stepMillesimi(?int $limit): int } $affected = $detailsInserted + $detailsUpdated; - // 4) Completa righe mancanti: ogni unità deve avere una riga per ogni tabella - if ($detailsTable === 'dettaglio_millesimi' && Schema::hasTable('unita_immobiliari')) { + // 4) Completa righe mancanti solo se richiesto esplicitamente. + // Di default evitiamo placeholder a zero che spezzano il collegamento reale + // tra millesimi, nominativi e unita immobiliari. + if ($this->option('fill-missing-millesimi') && $detailsTable === 'dettaglio_millesimi' && Schema::hasTable('unita_immobiliari')) { $detailCols = Schema::getColumnListing('dettaglio_millesimi'); $unitaIds = DB::table('unita_immobiliari') ->where('stabile_id', $this->stabileId) @@ -2166,6 +2268,19 @@ private function stepVoci(?int $limit): int $tabellaQuery->where('anno_gestione', $annoGestione); } $tabellaId = $tabellaQuery->value('id'); + if (! $tabellaId && $annoGestione && Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) { + $tabellaId = DB::table('tabelle_millesimali') + ->where('stabile_id', $stabileId) + ->where(function ($q) use ($v) { + if (Schema::hasColumn('tabelle_millesimali', 'codice_tabella')) { + $q->orWhere('codice_tabella', $v->tabella); + } + if (Schema::hasColumn('tabelle_millesimali', 'nome_tabella')) { + $q->orWhere('nome_tabella', $v->tabella); + } + }) + ->value('id'); + } } $gestioneId = $this->resolveGestioneContabileIdForStabile($stabileId, $legacyYear, $tipoLegacy, $numeroStraordinaria); $data = [ @@ -3762,7 +3877,9 @@ private function preflightStaging(): void return; } - $legacyYearOpt = $this->option('anno') ? sprintf('%04d', (int) $this->option('anno')) : null; + $legacyYearOpt = $this->option('anno') ? sprintf('%04d', (int) $this->option('anno')) : null; + $authoritativeCondomin = $this->resolveCondominMdbForStabile($stab, null); + $authoritativeLegacyYear = (string) ($authoritativeCondomin['legacy_year'] ?? ''); // Autoderiva --mdb-condomin se non fornito, preferendo l'anno richiesto e poi l'ultima annualità disponibile. if (! $this->option('mdb-condomin')) { @@ -3783,12 +3900,10 @@ private function preflightStaging(): void )); } - if ($legacyYearOpt === null) { - if (! empty($resolved['anno_o'])) { - $legacyYearOpt = (string) $resolved['anno_o']; - } elseif (! empty($resolved['legacy_year'])) { - $legacyYearOpt = (string) $resolved['legacy_year']; - } + if (! empty($resolved['legacy_year'])) { + $legacyYearOpt = (string) $resolved['legacy_year']; + } elseif ($legacyYearOpt === null && ! empty($resolved['anno_o'])) { + $legacyYearOpt = (string) $resolved['anno_o']; } } } @@ -3798,7 +3913,7 @@ private function preflightStaging(): void return; } - $needsLoad = $this->stagingTableNeedsLoadForStabile('condomin', $stab); + $needsLoad = $this->stagingTableNeedsLoadForStabile('condomin', $stab, $legacyYearOpt); if ($needsLoad) { $mdbCondomin = $this->option('mdb-condomin'); if ($mdbCondomin && is_file($mdbCondomin)) { @@ -3821,6 +3936,26 @@ private function preflightStaging(): void } } + if ($authoritativeLegacyYear !== '' && $authoritativeLegacyYear !== $legacyYearOpt) { + $authoritativePath = (string) ($authoritativeCondomin['path'] ?? ''); + if ($authoritativePath !== '' && is_file($authoritativePath)) { + foreach (['condomin', 'comproprietari'] as $table) { + if (! Schema::connection('gescon_import')->hasTable($table)) { + continue; + } + if (! $this->stagingTableNeedsLoadForStabile($table, $stab, $authoritativeLegacyYear)) { + continue; + } + $this->callSilently('gescon:load-mdb', [ + '--mdb' => $authoritativePath, + '--table' => $table, + '--stabile' => $stab, + '--legacy-year' => $authoritativeLegacyYear, + ]); + } + } + } + // Nota: partizionamento logico di condomin (unità, proprietari, inquilini) avviene negli step senza creare VIEW. // Carica tabelle aggiuntive utili (millesimi, voci) se vuote per lo stabile @@ -3828,7 +3963,7 @@ private function preflightStaging(): void if ($mdbCondomin && is_file($mdbCondomin)) { // dett_tab if (Schema::connection('gescon_import')->hasTable('dett_tab')) { - $need = $this->stagingTableNeedsLoadForStabile('dett_tab', $stab); + $need = $this->stagingTableNeedsLoadForStabile('dett_tab', $stab, $legacyYearOpt); if ($need) { $this->callSilently('gescon:load-mdb', [ '--mdb' => $mdbCondomin, @@ -3840,7 +3975,7 @@ private function preflightStaging(): void } // tabelle_millesimali if (Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) { - $need = $this->stagingTableNeedsLoadForStabile('tabelle_millesimali', $stab); + $need = $this->stagingTableNeedsLoadForStabile('tabelle_millesimali', $stab, $legacyYearOpt); if ($need) { $this->callSilently('gescon:load-mdb', [ '--mdb' => $mdbCondomin, @@ -3852,7 +3987,7 @@ private function preflightStaging(): void } // voc_spe (voci spesa) if (Schema::connection('gescon_import')->hasTable('voc_spe')) { - $need = $this->stagingTableNeedsLoadForStabile('voc_spe', $stab); + $need = $this->stagingTableNeedsLoadForStabile('voc_spe', $stab, $legacyYearOpt); if ($need) { $this->callSilently('gescon:load-mdb', [ '--mdb' => $mdbCondomin, @@ -3864,7 +3999,7 @@ private function preflightStaging(): void } // comproprietari if (Schema::connection('gescon_import')->hasTable('comproprietari')) { - $need = $this->stagingTableNeedsLoadForStabile('comproprietari', $stab); + $need = $this->stagingTableNeedsLoadForStabile('comproprietari', $stab, $legacyYearOpt); if ($need) { $this->callSilently('gescon:load-mdb', [ '--mdb' => $mdbCondomin, @@ -3876,7 +4011,7 @@ private function preflightStaging(): void } // Cre_Deb_preced (crediti/debiti pregressi) if (Schema::connection('gescon_import')->hasTable('cre_deb_preced')) { - $need = $this->stagingTableNeedsLoadForStabile('cre_deb_preced', $stab); + $need = $this->stagingTableNeedsLoadForStabile('cre_deb_preced', $stab, $legacyYearOpt); if ($need) { $this->callSilently('gescon:load-mdb', [ '--mdb' => $mdbCondomin, @@ -3898,7 +4033,7 @@ private function preflightStaging(): void ]; foreach ($more as $tb) { if (Schema::connection('gescon_import')->hasTable($tb)) { - $need = $this->stagingTableNeedsLoadForStabile($tb, $stab); + $need = $this->stagingTableNeedsLoadForStabile($tb, $stab, $legacyYearOpt); if ($need) { $this->callSilently('gescon:load-mdb', [ '--mdb' => $mdbCondomin, @@ -4021,7 +4156,7 @@ private function resolveCondominMdbForStabile(string $stab, ?string $legacyYear return Arr::except($candidates[0], ['sort_key']); } - private function stagingTableNeedsLoadForStabile(string $table, string $stab): bool + private function stagingTableNeedsLoadForStabile(string $table, string $stab, ?string $legacyYear = null): bool { if (! Schema::connection('gescon_import')->hasTable($table)) { return false; @@ -4029,7 +4164,12 @@ private function stagingTableNeedsLoadForStabile(string $table, string $stab): b $conn = DB::connection('gescon_import'); if (Schema::connection('gescon_import')->hasColumn($table, 'cod_stabile')) { - return ! $conn->table($table)->where('cod_stabile', $stab)->exists(); + $query = $conn->table($table)->where('cod_stabile', $stab); + if ($legacyYear !== null && $legacyYear !== '' && Schema::connection('gescon_import')->hasColumn($table, 'legacy_year')) { + $query->where('legacy_year', $legacyYear); + } + + return ! $query->exists(); } return $conn->table($table)->count() === 0; @@ -4243,9 +4383,24 @@ private function findUnitaByStagingRow($r) return null; } + $stabileId = $this->stabileId; + if ($stabileId !== null && Schema::hasColumn('unita_immobiliari', 'stabile_id')) { + $legacyCond = trim((string) ($r->cod_cond ?? $r->legacy_cond_id ?? $r->id_cond ?? '')); + if ($legacyCond !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { + $match = DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at')) + ->where('legacy_cond_id', $legacyCond) + ->first(); + if ($match) { + return $match; + } + } + } + $crit = []; - if ($this->stabileId !== null && Schema::hasColumn('unita_immobiliari', 'stabile_id')) { - $crit['stabile_id'] = $this->stabileId; + if ($stabileId !== null && Schema::hasColumn('unita_immobiliari', 'stabile_id')) { + $crit['stabile_id'] = $stabileId; } if (Schema::hasColumn('unita_immobiliari', 'scala') && isset($r->scala)) { $crit['scala'] = $r->scala; @@ -4255,15 +4410,18 @@ private function findUnitaByStagingRow($r) if (is_string($int) && preg_match('/^\s*CAN\s*(\d+)\s*$/i', $int, $m)) { $int = 'CAN' . $m[1]; } - if (($int === null || trim($int) === '') && isset($r->cod_cond)) { - $int = (string) $r->cod_cond; - } if ($int !== null) { + $int = trim($int); + } + if ($int !== null && $int !== '' && ! preg_match('/^0+$/', $int)) { $crit['interno'] = $int; } } if (! empty($crit)) { - return DB::table('unita_immobiliari')->where($crit)->first(); + return DB::table('unita_immobiliari') + ->where($crit) + ->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at')) + ->first(); } return null; } @@ -4719,6 +4877,35 @@ private function generateStableCondKey(string $prefix, $stabile, $cond, int $len return $prefix . $digest; } + private function buildLegacyUnitCode(string $stabileCode, string $scala, string $interno, ?string $salt = null): string + { + $maxLen = 20; + + $stablePart = $this->sanitizeUnitCodeSegment($stabileCode, 4) ?: 'S0'; + $scalaPart = $this->sanitizeUnitCodeSegment($scala, 4) ?: 'A'; + $internoPart = $this->sanitizeUnitCodeSegment($interno, 32) ?: 'INT'; + + $base = $stablePart . '-' . $scalaPart . '-' . $internoPart; + if (strlen($base) <= $maxLen) { + return $base; + } + + $hash = strtoupper(substr(hash('crc32b', implode('|', [$stabileCode, $scala, $interno, (string) $salt])), 0, 4)); + $reserved = strlen($stablePart) + strlen($scalaPart) + strlen($hash) + 3; + $availableInterno = max(1, $maxLen - $reserved); + $internoCompact = substr($internoPart, 0, $availableInterno); + + return $stablePart . '-' . $scalaPart . '-' . $internoCompact . '-' . $hash; + } + + private function sanitizeUnitCodeSegment(?string $value, int $maxLen): string + { + $normalized = strtoupper(trim((string) $value)); + $normalized = preg_replace('/[^A-Z0-9]+/u', '', $normalized) ?? ''; + + return substr($normalized, 0, max(1, $maxLen)); + } + private function nextProtocolNumber(): int { return ++$this->protocolCounter; @@ -5111,13 +5298,16 @@ private function buildComproprietariHistoryPayloads(array $historyRows, ?string $currentNameKey = $this->normalizeLegacyPartyName($current['nominativo'] ?? null); $currentRightKey = $this->normalizeLegacyPartyName($current['diritto_reale'] ?? $current['diritto_label'] ?? null); - $sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey; - $sameRight = $snapshotRightKey === $currentRightKey; - $samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null + $sameSeries = ! empty($snapshot['id_compr']) && ! empty($current['id_compr']) + && (int) $snapshot['id_compr'] === (int) $current['id_compr']; + $sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey; + $sameRight = $snapshotRightKey === $currentRightKey; + $samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null || abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005; - if ($sameName && $sameRight && $samePercent) { + if (($sameName && $sameRight && $samePercent) || ($sameSeries && $sameRight)) { $current['data_inizio'] = $snapshotStart ?? $current['data_inizio']; + $current['percentuale'] = $current['percentuale'] ?? $snapshot['percentuale']; if (! empty($snapshot['legacy_year'])) { $current['legacy_years'][] = $snapshot['legacy_year']; } diff --git a/app/Console/Commands/NetgesconQaUnitaNominativiCommand.php b/app/Console/Commands/NetgesconQaUnitaNominativiCommand.php index c66d4e8..d6da82a 100644 --- a/app/Console/Commands/NetgesconQaUnitaNominativiCommand.php +++ b/app/Console/Commands/NetgesconQaUnitaNominativiCommand.php @@ -73,7 +73,11 @@ public function handle(): int ->when($stabileId > 0, fn($q) => $q->where('ui.stabile_id', $stabileId)) ->count(); - $sovrapposizioniPeriodi = Schema::hasTable('unita_immobiliare_nominativi') + $sameNormalizedNameSql = "UPPER(TRIM(REGEXP_REPLACE(COALESCE(a.nominativo, ''), '[[:space:]]+', ' '))) = UPPER(TRIM(REGEXP_REPLACE(COALESCE(b.nominativo, ''), '[[:space:]]+', ' ')))"; + $sameComproprietarioSeriesSql = "a.fonte = 'legacy_comproprietari' AND b.fonte = 'legacy_comproprietari' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(a.legacy_payload, '$.id_compr')), '') != '' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(a.legacy_payload, '$.id_compr')), '') = COALESCE(JSON_UNQUOTE(JSON_EXTRACT(b.legacy_payload, '$.id_compr')), '')"; + $periodiSiSovrappongonoSql = "COALESCE(a.data_inizio, '1000-01-01') <= COALESCE(b.data_fine, '9999-12-31') AND COALESCE(b.data_inizio, '1000-01-01') <= COALESCE(a.data_fine, '9999-12-31')"; + + $duplicatiPeriodiStessoNominativo = Schema::hasTable('unita_immobiliare_nominativi') ? DB::table('unita_immobiliare_nominativi as a') ->join('unita_immobiliare_nominativi as b', function ($join) : void { $join->on('a.unita_immobiliare_id', '=', 'b.unita_immobiliare_id') @@ -81,9 +85,27 @@ public function handle(): int ->whereColumn('a.id', '<', 'b.id'); }) ->join('unita_immobiliari as ui', 'ui.id', '=', 'a.unita_immobiliare_id') - ->where(function ($q): void { - $q->whereNull('a.data_fine')->orWhereNull('b.data_inizio')->orWhereColumn('a.data_fine', '>=', 'b.data_inizio'); + ->whereIn('a.fonte', ['legacy_condomin', 'legacy_comproprietari']) + ->whereIn('b.fonte', ['legacy_condomin', 'legacy_comproprietari']) + ->whereRaw($periodiSiSovrappongonoSql) + ->whereRaw($sameNormalizedNameSql) + ->whereRaw('NOT (' . $sameComproprietarioSeriesSql . ')') + ->when($stabileId > 0, fn($q) => $q->where('ui.stabile_id', $stabileId)) + ->count() + : 0; + + $compresenzeMultiIntestatario = Schema::hasTable('unita_immobiliare_nominativi') + ? DB::table('unita_immobiliare_nominativi as a') + ->join('unita_immobiliare_nominativi as b', function ($join) : void { + $join->on('a.unita_immobiliare_id', '=', 'b.unita_immobiliare_id') + ->on('a.ruolo', '=', 'b.ruolo') + ->whereColumn('a.id', '<', 'b.id'); }) + ->join('unita_immobiliari as ui', 'ui.id', '=', 'a.unita_immobiliare_id') + ->whereIn('a.fonte', ['legacy_condomin', 'legacy_comproprietari']) + ->whereIn('b.fonte', ['legacy_condomin', 'legacy_comproprietari']) + ->whereRaw($periodiSiSovrappongonoSql) + ->whereRaw('NOT (' . $sameNormalizedNameSql . ')') ->when($stabileId > 0, fn($q) => $q->where('ui.stabile_id', $stabileId)) ->count() : 0; @@ -116,7 +138,8 @@ public function handle(): int $this->line('Unita senza storico nominativi: ' . $unitaSenzaNominativi); $this->line('Ruoli con stabile mismatch: ' . $mismatchRuoliStabile . ($applyFix ? ' (fix applicato)' : '')); $this->line('Ruoli attivi senza cellulare: ' . $ruoliAttiviSenzaCell); - $this->line('Possibili sovrapposizioni periodi nominativi: ' . $sovrapposizioniPeriodi); + $this->line('Duplicati periodi stesso nominativo: ' . $duplicatiPeriodiStessoNominativo); + $this->line('Compresenze multi-intestatario contemporanee: ' . $compresenzeMultiIntestatario); if (! empty($legacyYears)) { $this->line('Anni legacy disponibili (condomin):'); @@ -125,7 +148,7 @@ public function handle(): int } } - if ($unitaSenzaNominativi > 0 || $mismatchRuoliStabile > 0 || $sovrapposizioniPeriodi > 0) { + if ($unitaSenzaNominativi > 0 || $mismatchRuoliStabile > 0 || $duplicatiPeriodiStessoNominativo > 0) { $this->warn('QA completato con anomalie da lavorare.'); return self::INVALID; } diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index 50578e3..bec929a 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -323,10 +323,10 @@ private function hydrateCollegamentoWorkspace(): void { $suggestion = $this->resolveCollegamentoSuggerito(); - $this->collegamentoSuggerito = $suggestion; - $this->collegamentoStabileId = ! empty($suggestion['stabile_id']) ? (int) $suggestion['stabile_id'] : null; - $this->collegamentoUnitaId = ! empty($suggestion['unita_id']) ? (int) $suggestion['unita_id'] : null; - $this->collegamentoRuolo = ! empty($suggestion['role_standard']) ? (string) $suggestion['role_standard'] : 'altro'; + $this->collegamentoSuggerito = $suggestion; + $this->collegamentoStabileId = ! empty($suggestion['stabile_id']) ? (int) $suggestion['stabile_id'] : null; + $this->collegamentoUnitaId = ! empty($suggestion['unita_id']) ? (int) $suggestion['unita_id'] : null; + $this->collegamentoRuolo = ! empty($suggestion['role_standard']) ? (string) $suggestion['role_standard'] : 'altro'; $this->collegamentoRuoloCustom = ! empty($suggestion['role_custom']) ? (string) $suggestion['role_custom'] : ''; } @@ -353,15 +353,15 @@ public function collegaCollegamentoSuggerito(): void public function salvaCollegamentoManuale(): void { $data = validator([ - 'stabile_id' => $this->collegamentoStabileId, + 'stabile_id' => $this->collegamentoStabileId, 'unita_immobiliare_id' => $this->collegamentoUnitaId, - 'ruolo_standard' => $this->collegamentoRuolo, - 'ruolo_custom' => $this->collegamentoRuoloCustom, + 'ruolo_standard' => $this->collegamentoRuolo, + 'ruolo_custom' => $this->collegamentoRuoloCustom, ], [ - 'stabile_id' => ['required', 'integer'], + 'stabile_id' => ['required', 'integer'], 'unita_immobiliare_id' => ['nullable', 'integer'], - 'ruolo_standard' => ['required', 'string', 'max:120'], - 'ruolo_custom' => ['nullable', 'string', 'max:255'], + 'ruolo_standard' => ['required', 'string', 'max:120'], + 'ruolo_custom' => ['nullable', 'string', 'max:255'], ])->validate(); $this->persistCollegamentoRubrica( @@ -390,7 +390,7 @@ private function persistCollegamentoRubrica( return; } - $user = Auth::user(); + $user = Auth::user(); $isSuperAdmin = $user instanceof User && method_exists($user, 'hasRole') ? (bool) $user->hasRole('super-admin') : false; @@ -423,12 +423,12 @@ private function persistCollegamentoRubrica( } $ruoloStandard = trim($ruoloStandard) !== '' ? trim($ruoloStandard) : 'altro'; - $ruoloCustom = trim($ruoloCustom); + $ruoloCustom = trim($ruoloCustom); $attributes = [ - 'rubrica_id' => (int) $this->rubrica->id, - 'ruolo_standard' => $ruoloStandard, - 'stabile_id' => (int) $stabile->id, + 'rubrica_id' => (int) $this->rubrica->id, + 'ruolo_standard' => $ruoloStandard, + 'stabile_id' => (int) $stabile->id, 'unita_immobiliare_id' => $unita?->id, ]; @@ -446,11 +446,11 @@ private function persistCollegamentoRubrica( $payload = [ 'ruolo_custom' => $ruoloCustom !== '' ? $ruoloCustom : null, - 'is_attivo' => true, + 'is_attivo' => true, 'is_preferito' => $existing ? (bool) ($existing->is_preferito ?? false) : count($this->ruoliAttivi) === 0, - 'data_inizio' => $existing?->data_inizio ?: now()->toDateString(), - 'meta' => array_filter([ - 'source' => $source, + 'data_inizio' => $existing?->data_inizio ?: now()->toDateString(), + 'meta' => array_filter([ + 'source' => $source, 'linked_from' => 'rubrica_scheda', ], fn($value) => $value !== null && $value !== ''), ]; @@ -463,12 +463,12 @@ private function persistCollegamentoRubrica( } $this->rubrica->riferimento_stabile = $this->buildLocalStabileReference($stabile); - $this->rubrica->riferimento_unita = $unita ? $this->buildLocalUnitReference($unita) : $this->rubrica->riferimento_unita; + $this->rubrica->riferimento_unita = $unita ? $this->buildLocalUnitReference($unita) : $this->rubrica->riferimento_unita; if (in_array($ruoloStandard, ['condomino', 'inquilino', 'fornitore', 'amministratore', 'altro'], true)) { $this->rubrica->tipo_utenza_call = $ruoloStandard; } $this->rubrica->data_ultima_modifica = now()->toDateString(); - $this->rubrica->modificato_da = Auth::id(); + $this->rubrica->modificato_da = Auth::id(); $this->rubrica->save(); $this->mount((int) $this->rubrica->id); @@ -483,7 +483,7 @@ private function persistCollegamentoRubrica( /** @return array */ private function resolveCollegamentoSuggerito(): array { - $legacy = $this->parseCollegamentoLegacyMeta((string) ($this->rubrica->note ?? '')); + $legacy = $this->parseCollegamentoLegacyMeta((string) ($this->rubrica->note ?? '')); $stabile = $this->resolveCollegamentoStabile($legacy['cod_stabile'] ?? null, (string) ($this->rubrica->riferimento_stabile ?? '')); if (empty($stabile['id'])) { return []; @@ -496,14 +496,14 @@ private function resolveCollegamentoSuggerito(): array ); return array_filter([ - 'stabile_id' => (int) $stabile['id'], - 'stabile_label' => $this->buildCollegamentoStabileReference($stabile), - 'unita_id' => (int) ($unit['unit_id'] ?? 0) ?: null, - 'unita_label' => (string) ($unit['unit_label'] ?? ''), + 'stabile_id' => (int) $stabile['id'], + 'stabile_label' => $this->buildCollegamentoStabileReference($stabile), + 'unita_id' => (int) ($unit['unit_id'] ?? 0) ?: null, + 'unita_label' => (string) ($unit['unit_label'] ?? ''), 'unita_reference' => (string) ($unit['unit_reference'] ?? ''), - 'role_standard' => $this->mapCollegamentoRole($legacy['ruolo'] ?? $this->rubrica->tipo_utenza_call), - 'role_custom' => null, - 'source' => (string) ($unit['source'] ?? 'riferimento_stabile'), + 'role_standard' => $this->mapCollegamentoRole($legacy['ruolo'] ?? $this->rubrica->tipo_utenza_call), + 'role_custom' => null, + 'source' => (string) ($unit['source'] ?? 'riferimento_stabile'), 'requires_review' => (bool) ($unit['source'] ?? false), ], fn($value) => $value !== null && $value !== ''); } @@ -513,8 +513,8 @@ private function parseCollegamentoLegacyMeta(string $note): array { $payload = [ 'cod_stabile' => null, - 'cod_cond' => null, - 'ruolo' => null, + 'cod_cond' => null, + 'ruolo' => null, ]; foreach (array_keys($payload) as $key) { @@ -538,7 +538,7 @@ private function resolveCollegamentoStabile(?string $legacyCode, string $referen } $trimmed = ltrim($code, '0'); - $row = Stabile::query() + $row = Stabile::query() ->when($this->adminId > 0, fn(Builder $query) => $query->where('amministratore_id', $this->adminId)) ->where(function (Builder $query) use ($code, $trimmed): void { $query->where('codice_stabile', $code) @@ -554,10 +554,10 @@ private function resolveCollegamentoStabile(?string $legacyCode, string $referen if ($row) { return [ - 'id' => (int) $row->id, + '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, + 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, + 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, ]; } } @@ -574,10 +574,10 @@ private function resolveCollegamentoStabile(?string $legacyCode, string $referen $row = $rows->first(); return [ - 'id' => (int) $row->id, + '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, + 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, + 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, ]; } } @@ -602,10 +602,10 @@ private function resolveCollegamentoUnita(int $stabileId, array $legacy, string $row = $rows->first(); return [ - 'unit_id' => (int) $row->id, - 'unit_label' => $this->buildLocalUnitLabel($row), + 'unit_id' => (int) $row->id, + 'unit_label' => $this->buildLocalUnitLabel($row), 'unit_reference' => $this->buildLocalUnitReference($row), - 'source' => 'riferimento_unita', + 'source' => 'riferimento_unita', ]; } } @@ -623,8 +623,8 @@ private function resolveCollegamentoUnita(int $stabileId, array $legacy, string foreach ($rows as $row) { $unit = $this->resolveUnitFromImportRow($stabileId, [ 'nom_cond' => (string) ($row->nom_cond ?? ''), - 'scala' => (string) ($row->scala ?? ''), - 'interno' => (string) ($row->interno ?? ''), + 'scala' => (string) ($row->scala ?? ''), + 'interno' => (string) ($row->interno ?? ''), ]); if (! $unit) { @@ -632,10 +632,10 @@ private function resolveCollegamentoUnita(int $stabileId, array $legacy, string } $candidates[(int) $unit->unit_id] = [ - 'unit_id' => (int) $unit->unit_id, - 'unit_label' => $this->buildLocalUnitLabel($unit), + 'unit_id' => (int) $unit->unit_id, + 'unit_label' => $this->buildLocalUnitLabel($unit), 'unit_reference' => $this->buildLocalUnitReference($unit), - 'source' => 'gescon_import', + 'source' => 'gescon_import', ]; } @@ -690,7 +690,7 @@ private function buildCollegamentoStabileReference(array $stabile): string private function buildLocalUnitReference(object $unit): string { - $code = trim((string) data_get($unit, 'codice_unita', '')); + $code = trim((string) data_get($unit, 'codice_unita', '')); $label = trim((string) data_get($unit, 'denominazione', '')); return trim($code . ($label !== '' ? ' - ' . $label : '')); @@ -715,11 +715,11 @@ private function mapCollegamentoRole(?string $value): ?string return match ($normalized) { 'c', 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino', 'i', 'inquilino', 'conduttore', 'locatario' => 'inquilino', - 'fornitore' => 'fornitore', + 'fornitore' => 'fornitore', 'amministratore' => 'amministratore', - 'collaboratore' => 'collaboratore', - 'altro' => 'altro', - default => null, + 'collaboratore' => 'collaboratore', + 'altro' => 'altro', + default => null, }; } @@ -729,7 +729,7 @@ private function resolveUnitFromImportRow(int $stabileId, array $row): ?object ->where('stabile_id', $stabileId) ->select(['id as unit_id', 'codice_unita', 'denominazione', 'scala', 'interno']); - $scala = trim((string) ($row['scala'] ?? '')); + $scala = trim((string) ($row['scala'] ?? '')); $interno = trim((string) ($row['interno'] ?? '')); if ($scala !== '' || $interno !== '') { @@ -770,7 +770,7 @@ private function hasGesconImportCondomin(): bool private function normalizeCollegamentoText(string $value): string { - $upper = mb_strtoupper(trim($value)); + $upper = mb_strtoupper(trim($value)); $normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper); return is_string($normalized) ? $normalized : ''; @@ -1102,7 +1102,7 @@ protected function getHeaderActions(): array ->send(); }), Action::make('contatti') - ->label('Contatti') + ->label('Modifica anagrafica') ->icon('heroicon-o-phone') ->action(function (): void { $this->startInlineEdit(); @@ -1158,7 +1158,7 @@ protected function getHeaderActions(): array Action::make('contatti_avanzati') ->label('Contatti avanzati') ->icon('heroicon-o-rectangle-stack') - ->visible(fn(): bool => Schema::hasTable('rubrica_contatto_canali')) + ->visible(fn(): bool => Schema::hasTable('rubrica_contatti_canali')) ->modalWidth('7xl') ->form([ Repeater::make('canali') @@ -2098,6 +2098,47 @@ protected function getTitoliOptions(): array ->all(); } + /** @return array */ + public function getTipoContattoOptions(): array + { + return [ + 'persona_fisica' => 'Persona fisica', + 'persona_giuridica' => 'Persona giuridica', + ]; + } + + /** @return array */ + public function getCategoriaOptions(): array + { + return [ + 'condomino' => 'Condomino', + 'inquilino' => 'Inquilino', + 'fornitore' => 'Fornitore', + 'collaboratore' => 'Collaboratore', + 'amministratore' => 'Amministratore', + 'azienda' => 'Azienda / ente', + 'ufficio' => 'Ufficio / reparto', + 'ente_pubblico' => 'Ente pubblico', + 'banca' => 'Banca', + 'assicurazione' => 'Assicurazione', + 'altro' => 'Altro', + ]; + } + + /** @return array */ + public function getTipoUtenzaCallOptions(): array + { + return [ + 'condomino' => 'Condomino', + 'inquilino' => 'Inquilino', + 'fornitore' => 'Fornitore', + 'collaboratore' => 'Collaboratore', + 'amministratore' => 'Amministratore', + 'ufficio' => 'Ufficio', + 'altro' => 'Altro', + ]; + } + /** @return array */ public function getUnitaOptions($stabileId): array { diff --git a/app/Filament/Pages/Strumenti/PostIt.php b/app/Filament/Pages/Strumenti/PostIt.php index 4fcfc74..7cda950 100644 --- a/app/Filament/Pages/Strumenti/PostIt.php +++ b/app/Filament/Pages/Strumenti/PostIt.php @@ -4,6 +4,7 @@ use App\Models\ChiamataPostIt; use App\Models\Fornitore; use App\Models\RubricaUniversale; +use App\Services\Anagrafiche\RubricaPhoneLookupService; use App\Models\Stabile; use App\Models\Ticket; use App\Models\User; @@ -496,21 +497,7 @@ private function creaTicketDirettoDaForm(): void private function findRubricaByPhone(string $value): ?RubricaUniversale { - $normalized = PhoneNumber::normalizeForMatch($value); - if ($normalized === '') { - return null; - } - - return RubricaUniversale::query() - ->where(function ($q) use ($normalized): void { - $q->where('telefono_cellulare', $normalized) - ->orWhere('telefono_ufficio', $normalized) - ->orWhere('telefono_casa', $normalized) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", ['%' . preg_replace('/\D+/', '', $normalized) . '%']) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", ['%' . preg_replace('/\D+/', '', $normalized) . '%']) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", ['%' . preg_replace('/\D+/', '', $normalized) . '%']); - }) - ->first(); + return app(RubricaPhoneLookupService::class)->findByPhone($value); } private function createPostItRecord(): ChiamataPostIt diff --git a/app/Filament/Pages/Strumenti/PostItGestione.php b/app/Filament/Pages/Strumenti/PostItGestione.php index 96b2cf8..cf12b00 100644 --- a/app/Filament/Pages/Strumenti/PostItGestione.php +++ b/app/Filament/Pages/Strumenti/PostItGestione.php @@ -8,6 +8,7 @@ use App\Models\RubricaUniversale; use App\Models\Ticket; use App\Models\User; +use App\Services\Anagrafiche\RubricaPhoneLookupService; use BackedEnum; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -65,9 +66,12 @@ class PostItGestione extends Page /** @var array */ private array $pbxExtensionCache = []; - /** @var array */ + /** @var array */ private array $postItMessageCache = []; + /** @var array */ + private array $monitoredResponseGroups = ['601', '603']; + public function mount(): void { $focus = (int) request()->query('focus_post_it', 0); @@ -325,19 +329,19 @@ public function createRubricaDraftFromGroup(string $modalKey, string $mode = 'op $rubrica = RubricaUniversale::query()->create([ 'amministratore_id' => $adminId > 0 ? $adminId : null, - 'nome' => $identity['nome'], - 'cognome' => $identity['cognome'], - 'ragione_sociale' => $identity['ragione_sociale'], - 'tipo_contatto' => $identity['tipo_contatto'], - 'telefono_cellulare' => $this->isLikelyMobilePhone($phoneDigits) ? $phoneDigits : null, - 'telefono_ufficio' => $this->isLikelyMobilePhone($phoneDigits) ? null : $phoneDigits, - 'categoria' => 'altro', - 'stato' => 'attivo', - 'data_inserimento' => now()->toDateString(), - 'data_ultima_modifica'=> now()->toDateString(), - 'creato_da' => Auth::id(), - 'modificato_da' => Auth::id(), - 'note_segreteria' => 'Bozza creata da Lavagna chiamate per gestire richiamo e identificazione del contatto.', + 'nome' => $identity['nome'], + 'cognome' => $identity['cognome'], + 'ragione_sociale' => $identity['ragione_sociale'], + 'tipo_contatto' => $identity['tipo_contatto'], + 'telefono_cellulare' => $this->isLikelyMobilePhone($phoneDigits) ? $phoneDigits : null, + 'telefono_ufficio' => $this->isLikelyMobilePhone($phoneDigits) ? null : $phoneDigits, + 'categoria' => 'altro', + 'stato' => 'attivo', + 'data_inserimento' => now()->toDateString(), + 'data_ultima_modifica' => now()->toDateString(), + 'creato_da' => Auth::id(), + 'modificato_da' => Auth::id(), + 'note_segreteria' => 'Bozza creata da Lavagna chiamate per gestire richiamo e identificazione del contatto.', ]); $itemIds = collect($group['items']) @@ -686,17 +690,17 @@ private function buildTicketDescriptionFromPostIt(int $postItId, ChiamataPostIt private function buildGroupedPostItKey(ChiamataPostIt $postIt): string { - $rubricaId = $this->resolveGroupedRubricaId($postIt); - if ($rubricaId !== null) { - return 'rubrica:' . $rubricaId; - } - $phone = $this->normalizeGroupedPhone((string) ($postIt->telefono ?? '')); if ($phone !== '') { return 'phone:' . $phone; } - return 'caller:' . mb_strtolower(trim((string) ($postIt->nome_chiamante ?? 'sconosciuto'))); + $rubricaId = $this->resolveGroupedRubricaId($postIt); + if ($rubricaId !== null) { + return 'rubrica:' . $rubricaId; + } + + return 'caller:' . $this->normalizeGroupedCallerKey((string) ($postIt->nome_chiamante ?? 'sconosciuto')); } private function isInternalPostIt(ChiamataPostIt $postIt): bool @@ -734,21 +738,38 @@ private function isLegacyFilteredSmdrPostIt(ChiamataPostIt $postIt): bool private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?CommunicationMessage { - $originId = (int) ($postIt->origine_id ?? 0); - if ($originId <= 0) { + $originKey = trim((string) ($postIt->origine_id ?? '')); + if ($originKey === '') { return null; } - if (array_key_exists($originId, $this->postItMessageCache)) { - return $this->postItMessageCache[$originId]; + if (array_key_exists($originKey, $this->postItMessageCache)) { + return $this->postItMessageCache[$originKey]; } - $message = CommunicationMessage::query()->find($originId); - $this->postItMessageCache[$originId] = $message; + $message = null; + if (ctype_digit($originKey)) { + $message = CommunicationMessage::query()->find((int) $originKey); + } elseif ((string) ($postIt->origine ?? '') === 'smdr') { + $message = CommunicationMessage::query() + ->where('channel', 'smdr') + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.smdr.fingerprint')) = ?", [$originKey]) + ->orderByDesc('id') + ->first(); + } + + $this->postItMessageCache[$originKey] = $message; return $message; } + private function normalizeGroupedCallerKey(string $value): string + { + $normalized = preg_replace('/\s+/', ' ', trim(mb_strtolower($value))) ?? ''; + + return $normalized !== '' ? $normalized : 'sconosciuto'; + } + private function normalizeGroupedPhone(string $phone): string { $digits = preg_replace('/\D+/', '', $phone) ?: ''; @@ -851,22 +872,22 @@ private function buildGroupedCollection($items) return ($right->chiamata_il?->getTimestamp() ?? 0) <=> ($left->chiamata_il?->getTimestamp() ?? 0); }); - $group['latest'] = $this->resolveLatestGroupedPostIt($group['items']); + $group['latest'] = $this->resolveLatestGroupedPostIt($group['items']); $group['primary_post_it'] = $this->resolvePrimaryGroupedPostIt($group['items']); - $oldest = collect($group['items']) + $oldest = collect($group['items']) ->sortBy(fn(ChiamataPostIt $item) => $item->chiamata_il?->getTimestamp() ?? 0) ->first(); - $group['first_at'] = optional($oldest?->chiamata_il)->format('d/m/Y H:i') ?: '-'; - $group['last_at'] = optional($group['latest']->chiamata_il)->format('d/m/Y H:i') ?: '-'; - $group['caller_label'] = $this->resolveGroupedCallerLabel($group['items']); - $group['phone'] = $this->resolveGroupedPhoneLabel($group['items']); - $group['rubrica_url'] = $this->getRubricaUrlByPhone((string) $group['phone']); - $group['answered_count']= collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isAnsweredPostIt($item))->count(); - $group['missed_count'] = collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isMissedPostIt($item))->count(); - $group['can_create_rubrica'] = ! $group['rubrica_url'] && $this->canCreateRubricaFromPhone((string) $group['phone']); + $group['first_at'] = optional($oldest?->chiamata_il)->format('d/m/Y H:i') ?: '-'; + $group['last_at'] = optional($group['latest']->chiamata_il)->format('d/m/Y H:i') ?: '-'; + $group['caller_label'] = $this->resolveGroupedCallerLabel($group['items']); + $group['phone'] = $this->resolveGroupedPhoneLabel($group['items']); + $group['rubrica_url'] = $this->getRubricaUrlByPhone((string) $group['phone']); + $group['answered_count'] = collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isAnsweredPostIt($item))->count(); + $group['missed_count'] = collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isMissedPostIt($item))->count(); + $group['can_create_rubrica'] = ! $group['rubrica_url'] && $this->canCreateRubricaFromPhone((string) $group['phone']); $group['primary_action_label'] = $group['answered_count'] > 0 ? 'Apri Post-it' : 'Apri richiamo'; - $group['visible_items'] = collect($group['items'])->take(4)->values()->all(); - $group['hidden_count'] = max(0, count($group['items']) - count($group['visible_items'])); + $group['visible_items'] = collect($group['items'])->take(4)->values()->all(); + $group['hidden_count'] = max(0, count($group['items']) - count($group['visible_items'])); return $group; }) @@ -1024,25 +1045,8 @@ private function resolveRubricaIdByPhone(?string $phone): ?int } $adminId = (int) (Auth::user()?->amministratore?->id ?? 0); - - $query = RubricaUniversale::query(); - if ($adminId > 0 && Schema::hasColumn('rubrica_universale', 'amministratore_id')) { - $query->where(function ($q) use ($adminId): void { - $q->where('amministratore_id', $adminId) - ->orWhereNull('amministratore_id'); - }); - } - - $rubricaId = $query - ->where(function ($q) use ($digits): void { - $q->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']); - }) - ->orderByDesc('id') - ->value('id'); - - $result = is_numeric($rubricaId) ? (int) $rubricaId : null; + $result = app(RubricaPhoneLookupService::class)->findByPhone($digits, $adminId)?->id; + $result = is_numeric($result) ? (int) $result : null; $this->rubricaPhoneCache[$digits] = $result; return $result; @@ -1095,6 +1099,10 @@ private function normalizePostItOutcome(?string $outcome): string public function isAnsweredPostIt(ChiamataPostIt $postIt): bool { + if ($this->isMissedResponseGroupPostIt($postIt)) { + return false; + } + $outcome = $this->normalizePostItOutcome($postIt->esito); if (($postIt->durata_secondi ?? 0) > 0) { @@ -1106,6 +1114,10 @@ public function isAnsweredPostIt(ChiamataPostIt $postIt): bool private function isMissedPostIt(ChiamataPostIt $postIt): bool { + if ($this->isMissedResponseGroupPostIt($postIt)) { + return true; + } + $outcome = $this->normalizePostItOutcome($postIt->esito); if (in_array($outcome, ['persa', 'missed', 'no_answer', 'unanswered', 'non_risposta'], true)) { @@ -1115,6 +1127,48 @@ private function isMissedPostIt(ChiamataPostIt $postIt): bool return (string) $postIt->direzione === 'in_arrivo' && ! $this->isAnsweredPostIt($postIt) && (int) ($postIt->durata_secondi ?? 0) === 0; } + private function isMissedResponseGroupPostIt(ChiamataPostIt $postIt): bool + { + if ((string) $postIt->direzione !== 'in_arrivo') { + return false; + } + + $message = $this->resolveMessageFromPostIt($postIt); + if (! $message instanceof CommunicationMessage || (string) $message->channel !== 'smdr') { + return false; + } + + $groupExtension = $this->normalizeExtension((string) data_get($message->metadata, 'smdr.extension', '')); + if (! $this->isMonitoredResponseGroupExtension($groupExtension)) { + $groupExtension = $this->normalizeExtension((string) ($message->target_extension ?? '')); + } + if (! $this->isMonitoredResponseGroupExtension($groupExtension)) { + return false; + } + + $answerExtensions = array_values(array_unique(array_filter([ + $this->normalizeExtension((string) data_get($message->metadata, 'smdr.target_extension', '')), + $this->normalizeExtension((string) ($message->target_extension ?? '')), + ]))); + + foreach ($answerExtensions as $answerExtension) { + if (! $this->isMonitoredResponseGroupExtension($answerExtension)) { + return false; + } + } + + return true; + } + + private function isMonitoredResponseGroupExtension(?string $extension): bool + { + if ($extension === null || $extension === '') { + return false; + } + + return in_array($extension, $this->monitoredResponseGroups, true); + } + public function getPostItCallStateMeta(ChiamataPostIt $postIt): array { if ($this->isMissedPostIt($postIt)) { diff --git a/app/Filament/Pages/UnitaImmobiliarePage.php b/app/Filament/Pages/UnitaImmobiliarePage.php index 08277ee..352d661 100644 --- a/app/Filament/Pages/UnitaImmobiliarePage.php +++ b/app/Filament/Pages/UnitaImmobiliarePage.php @@ -834,7 +834,7 @@ public function hydrateNominativiStorici(): void })->all(); } - private function hydrateRecapitiServizio(): void + public function hydrateRecapitiServizio(): void { $this->recapitiServizio = []; diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index 18f6e89..1cc3ce5 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -6,6 +6,7 @@ use App\Models\CommunicationMessage; use App\Models\PbxClickToCallRequest; use App\Models\RubricaUniversale; +use App\Services\Anagrafiche\RubricaPhoneLookupService; use App\Services\Cti\PbxRoutingService; use App\Support\PhoneNumber; use Carbon\CarbonInterface; @@ -491,25 +492,7 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array private function findRubricaByPhone(string $phone): ?RubricaUniversale { - $digits = PhoneNumber::normalizeDigits($phone); - if ($digits === '') { - return null; - } - - $tail = substr($digits, -7); - if ($tail === false || $tail === '') { - return null; - } - - return RubricaUniversale::query() - ->where(function ($q) use ($tail) { - $q->orWhere('telefono_cellulare', 'like', '%' . $tail . '%') - ->orWhere('telefono_ufficio', 'like', '%' . $tail . '%') - ->orWhere('telefono_casa', 'like', '%' . $tail . '%') - ->orWhere('telefono_cellulare_nazionale', 'like', '%' . $tail . '%'); - }) - ->orderByDesc('updated_at') - ->first(); + return app(RubricaPhoneLookupService::class)->findByPhone($phone); } private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt diff --git a/app/Services/Anagrafiche/RubricaPhoneLookupService.php b/app/Services/Anagrafiche/RubricaPhoneLookupService.php new file mode 100644 index 0000000..2a4c5d9 --- /dev/null +++ b/app/Services/Anagrafiche/RubricaPhoneLookupService.php @@ -0,0 +1,91 @@ += 7 ? substr($digits, -7) : $digits; + if ($tail === '') { + return null; + } + + foreach ([[$digits, true], [$digits, false], [$tail, true], [$tail, false]] as [$needle, $directFirst]) { + $found = $directFirst + ? ($this->directQuery((string) $needle, $adminId)->first() ?: $this->channelQuery((string) $needle, $adminId)->first()) + : ($this->channelQuery((string) $needle, $adminId)->first() ?: $this->directQuery((string) $needle, $adminId)->first()); + + if ($found instanceof RubricaUniversale) { + return $found; + } + } + + return null; + } + + private function directQuery(string $digits, ?int $adminId) + { + return RubricaUniversale::query() + ->when($this->hasAmministratoreColumn() && $adminId && $adminId > 0, function ($query) use ($adminId): void { + $query->where(function ($inner) use ($adminId): void { + $inner->where('amministratore_id', $adminId) + ->orWhereNull('amministratore_id'); + }); + }) + ->where(function ($query) use ($digits): void { + $query + ->orWhereRaw($this->normalizedDigitsSql('telefono_cellulare') . ' LIKE ?', ['%' . $digits . '%']) + ->orWhereRaw($this->normalizedDigitsSql('telefono_ufficio') . ' LIKE ?', ['%' . $digits . '%']) + ->orWhereRaw($this->normalizedDigitsSql('telefono_casa') . ' LIKE ?', ['%' . $digits . '%']); + + if (Schema::hasColumn('rubrica_universale', 'telefono_cellulare_nazionale')) { + $query->orWhereRaw($this->normalizedDigitsSql('telefono_cellulare_nazionale') . ' LIKE ?', ['%' . $digits . '%']); + } + }) + ->orderByDesc('updated_at') + ->orderByDesc('id'); + } + + private function channelQuery(string $digits, ?int $adminId) + { + if (! Schema::hasTable('rubrica_contatti_canali')) { + return RubricaUniversale::query()->whereRaw('1 = 0'); + } + + return RubricaUniversale::query() + ->select('rubrica_universale.*') + ->join('rubrica_contatti_canali as rcc', 'rcc.rubrica_id', '=', 'rubrica_universale.id') + ->when($this->hasAmministratoreColumn() && $adminId && $adminId > 0, function ($query) use ($adminId): void { + $query->where(function ($inner) use ($adminId): void { + $inner->where('rubrica_universale.amministratore_id', $adminId) + ->orWhereNull('rubrica_universale.amministratore_id'); + }); + }) + ->whereIn('rcc.tipo', ['telefono', 'cellulare']) + ->whereRaw($this->normalizedDigitsSql('rcc.valore') . ' LIKE ?', ['%' . $digits . '%']) + ->orderByDesc('rcc.is_principale') + ->orderByDesc('rcc.updated_at') + ->orderByDesc('rubrica_universale.updated_at') + ->orderByDesc('rubrica_universale.id'); + } + + private function hasAmministratoreColumn(): bool + { + return Schema::hasColumn('rubrica_universale', 'amministratore_id'); + } + + private function normalizedDigitsSql(string $column): string + { + return "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE({$column}, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '')"; + } +} \ No newline at end of file diff --git a/app/Services/Cti/LiveIncomingCallService.php b/app/Services/Cti/LiveIncomingCallService.php index 38a5675..1fb0f79 100644 --- a/app/Services/Cti/LiveIncomingCallService.php +++ b/app/Services/Cti/LiveIncomingCallService.php @@ -6,6 +6,7 @@ use App\Models\RubricaUniversale; use App\Models\Soggetto; use App\Models\User; +use App\Services\Anagrafiche\RubricaPhoneLookupService; class LiveIncomingCallService { @@ -43,20 +44,20 @@ public function getForUser(User $user): ?array $latest = $candidates ->sortByDesc(function (CommunicationMessage $message): int { - $metadata = is_array($message->metadata) ? $message->metadata : []; - $eventType = strtolower(trim((string) ($metadata['event_type'] ?? ''))); - $endedAt = trim((string) ($metadata['ended_at'] ?? '')); + $metadata = is_array($message->metadata) ? $message->metadata : []; + $eventType = strtolower(trim((string) ($metadata['event_type'] ?? ''))); + $endedAt = trim((string) ($metadata['ended_at'] ?? '')); $isActiveCsta = (string) $message->channel === 'panasonic_csta' - && (string) ($message->status ?? '') !== 'completed' - && $endedAt === '' - && ! in_array($eventType, ['connectioncleared', 'cleared', 'released'], true); + && (string) ($message->status ?? '') !== 'completed' + && $endedAt === '' + && ! in_array($eventType, ['connectioncleared', 'cleared', 'released'], true); if ($isActiveCsta) { return 5000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); } $isRecentCsta = (string) $message->channel === 'panasonic_csta' - && (int) ($message->received_at?->getTimestamp() ?? 0) >= now()->subSeconds(45)->getTimestamp(); + && (int) ($message->received_at?->getTimestamp() ?? 0) >= now()->subSeconds(45)->getTimestamp(); if ($isRecentCsta) { return 4000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); @@ -119,25 +120,7 @@ private function normalizePhoneDigits(string $value): string private function matchRubricaByPhone(string $digits): ?RubricaUniversale { - if ($digits === '') { - return null; - } - - $needle = '%' . $digits . '%'; - $tail = strlen($digits) >= 7 ? substr($digits, -7) : $digits; - $tailNeedle = '%' . $tail . '%'; - - return RubricaUniversale::query() - ->where(function ($query) use ($needle, $tailNeedle): void { - $query->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]) - ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]); - }) - ->orderByDesc('updated_at') - ->first(); + return app(RubricaPhoneLookupService::class)->findByPhone($digits); } private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto diff --git a/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md b/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md index a019978..b53324e 100644 --- a/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md +++ b/docs/000-IMPORT/01-GESCON/GUIDA-IMPORT-GESCON-MDB.md @@ -51,6 +51,83 @@ ## Finestra temporale smoke test Per il primo passaggio operativo non importare tutto lo storico. +## Criterio autorevole unita e nominativi + +- Lo snapshot corrente di `unita`, `soggetti` e `diritti` deve derivare sempre dall'ultimo anno legacy disponibile per lo stabile, non dall'anno storico che si sta rileggendo in quel momento. +- La chiave certa di allineamento tra staging e dominio e `condomin.cod_cond`, che nel dominio va mantenuta in `unita_immobiliari.legacy_cond_id`. +- `scala`, `interno` e `catasto_sub` servono per esporre il codice mnemonico operatore e per le verifiche di riconciliazione, ma non devono sostituire `cod_cond` come chiave interna. +- Se si rilancia un'importazione su un anno precedente, il preflight deve comunque precaricare anche l'ultima annualita disponibile per evitare che lo snapshot corrente venga ricostruito con un anno vecchio. +- Non vanno creati fallback artificiali come `interno = 0`, unita sintetiche o nominativi fittizi per colmare buchi che non esistono nella sorgente. + +## Regola storico anni precedenti + +- Gli anni precedenti arricchiscono `unita_immobiliare_nominativi` come cronologia delle variazioni. +- Non devono sovrascrivere il nominativo corrente dell'ultimo anno disponibile. +- Le compresenze di piu comproprietari nello stesso intervallo sono lecite e vanno distinte dai duplicati veri dello stesso nominativo. + +## Bonifica anagrafiche post-import + +- Dopo gli step `unita`, `soggetti` e `diritti`, eseguire la bonifica recapiti con `php artisan gescon:bonifica-rubrica-canali --stabile= --apply`. +- La bonifica spezza telefoni, email e PEC salvati nello stesso campo usando separatori legacy come `;`, `,`, newline o `/`. +- I recapiti vengono materializzati in `rubrica_contatti_canali` con tipo esplicito (`telefono`, `cellulare`, `email`, `pec`) e flag `is_principale`. +- Regola di classificazione attuale: + - numeri che iniziano con `3` => `cellulare` + - numeri che iniziano con `0` => `telefono` + - gli altri restano sul tipo di origine se gia noto +- La push/sync Google deve leggere e scrivere anche i canali aggiuntivi, non solo i campi primari della rubrica. + +## Regola codice unita + +- `unita_immobiliari.codice_unita` e limitato a `varchar(20)` nel dominio corrente. +- I codici generati dall'import devono quindi essere compatti e deterministici: + - rimozione dei separatori interni non significativi nei segmenti legacy + - se il codice supera ancora il limite, uso di suffisso hash corto stabile invece di troncare in modo ambiguo +- Questa regola e necessaria per stabili come `0013`, dove gli interni legacy aggregati possono produrre stringhe del tipo `0013-NGOT-107/109-C47`. + +## Piloti verificati 08/04/2026 + +### `0002` - Carlo Alberto Racchia 2 + +- anno autorevole importato: `2024` (`0038`) +- `unita`: `61` +- `soggetti`: `52` +- `diritti`: `82` inseriti +- QA `netgescon:qa-unita-nominativi --stabile_id=8`: + - unita attive `61` + - unita senza nominativi `0` + - duplicati periodi `0` + - nessuna anomalia bloccante +- caso da tenere distinto dai buchi veri: + - `cod_cond = 62` nello snapshot `0038` risulta come record comune (`nome = CONDOMINIO (magazzino)`) e non ha `scala`, `interno`, `catasto_sub`, `piano` + - quindi oggi viene correttamente escluso dalla costruzione delle UI ordinarie se si pretende un identificativo fisico autorevole + +### `0013` - Ottaviano 105 / Giulio Cesare 171 + +- anno autorevole importato: `2026` (`0015`) +- fix necessario prima del rilancio: + - compattazione deterministica di `codice_unita` per non superare il limite DB +- esito finale dopo fix: + - `unita`: `26` processate, `12` aggiornate + - `soggetti`: `50` + - `diritti`: `90` inseriti, `3` aggiornati, `39` ignorati + - QA `netgescon:qa-unita-nominativi --stabile_id=16`: nessuna anomalia bloccante +- attenzione funzionale aperta: + - stabile con unita fisicamente/catastalmente uniche ma spezzate nel legacy per gestire piu titolari + - la futura unione UI deve appoggiarsi a dati catastali e a logiche di cumulo/quote, non a merge distruttivi dei record legacy + +### `0023` - Germanico 79 (legacy `8`) + +- anno autorevole importato: `2025` (`0096`) +- `unita`: `27` processate, `16` aggiornate +- `soggetti`: `20` +- `diritti`: `25` inseriti, `15` aggiornati, `2` ignorati +- QA `netgescon:qa-unita-nominativi --stabile_id=24`: nessuna anomalia bloccante + +## Regola straordinarie + +- Le spese straordinarie vanno considerate aperte nella gestione in cui vengono lette. +- Codici legacy uguali presenti in anni diversi non vanno collassati tra loro: devono restare distinti almeno per anno gestione e, se serve, per numero progressivo straordinaria. + ### Import base - anno corrente legacy piu recente disponibile @@ -122,6 +199,9 @@ ### E. QA dominio post-import - stabile creato/aggiornato correttamente - unita importate - rubrica/nominativi deduplicati +- snapshot corrente allineato all'ultima annualita disponibile +- `legacy_cond_id` valorizzato e coerente con `condomin.cod_cond` +- nessuna unita attiva con `interno = 0` o codice sintetico inventato - rate/incassi coerenti per gli anni caricati - nessun crash nelle pagine operative principali diff --git a/docs/NETGESCON-MODIFICHE.md b/docs/NETGESCON-MODIFICHE.md index 7194426..5fa9a1f 100644 --- a/docs/NETGESCON-MODIFICHE.md +++ b/docs/NETGESCON-MODIFICHE.md @@ -6,6 +6,11 @@ # Modifiche NetGescon | Data | Versione | Area | Descrizione | Tipo | | --- | --- | --- | --- | --- | +| 08/04/2026 | 0.8.x-dev | Rubrica / PBX / Google | Introdotto `gescon:bonifica-rubrica-canali`, allineato il lookup telefonico PBX ai canali multipli di rubrica e estesa la sync Google ai recapiti secondari (`rubrica_contatti_canali`) per email, PEC e telefoni multipli sullo stesso nominativo | [U] | +| 08/04/2026 | 0.8.x-dev | Gescon / Import / Unita | Corretta la generazione di `codice_unita` in `gescon:import-full` con compattazione deterministica entro il limite DB (`varchar(20)`), sbloccando i casi reali di unita aggregate come `0013` | [U] | +| 08/04/2026 | 0.8.x-dev | Gescon / Import / Piloti | Validati in sola anagrafica i piloti `0002` (Racchia 2), `0013` (Ottaviano 105 / Giulio Cesare 171) e `0023` (Germanico 79 legacy 8), con QA bloccante pulito e evidenza del caso speciale `0002/cod_cond 62` come record comune senza identificativo fisico autorevole | [U] | +| 08/04/2026 | 0.8.x-dev | Gescon / Import / Unita-Nominativi | Rifattorizzato `gescon:import-full` per usare l'ultima annualita legacy come snapshot corrente autorevole di unita, soggetti e diritti, agganciando le UI per `cod_cond/legacy_cond_id`, precaricando l'anno autorevole anche se si importa uno storico e bloccando la creazione di unita fittizie con `interno=0` | [U] | +| 08/04/2026 | 0.8.x-dev | QA Database | Aggiornato `netgescon:qa-unita-nominativi` per distinguere i duplicati veri dello stesso nominativo dalle compresenze lecite di piu intestatari sulla stessa UI | [U] | | 01/04/2026 | 0.8.x-dev | Gescon / Import / Anagrafica unica | Introdotto `gescon:materialize-persone-relazioni` per materializzare `persone` e `persone_unita_relazioni` da `unita_immobiliare_nominativi`, con innesto dello step `relazioni` nel `gescon:import-full` | [P] | | 01/04/2026 | 0.8.x-dev | Supporto / Modifiche / Git staging | Spostato il flusso `Aggiornamento staging da Gitea` nella tab `Aggiornamenti + Bug`, con riepilogo esplicito di cosa aggiorna la UI e del comando predisposto `netgescon:git-sync` | [U] | | 01/04/2026 | 0.8.x-dev | Rubrica / Stabili / Relazioni | Riallineato il caso reale `GERMANICO 96` materializzando 191 relazioni persona-unita e rilanciando la sync ruoli rubrica dal dato legacy gia importato | [U] | diff --git a/docs/sviluppo/stabili_import.md b/docs/sviluppo/stabili_import.md index c44b08e..663da7a 100644 --- a/docs/sviluppo/stabili_import.md +++ b/docs/sviluppo/stabili_import.md @@ -1,17 +1,28 @@ -## Flusso rapido import MDB (DEV) +# Flusso rapido import MDB (DEV) + +## Regola aggiornata 08/04/2026 + +- Per `unita`, `soggetti` e `diritti`, il corrente non si costruisce piu dall'anno passato in `--anno` se esiste un'annualita legacy piu recente. +- Per `0021`, lo snapshot corrente autorevole e `0004`. +- Gli anni precedenti, come `0003`, restano sorgente di cronologia e quadratura, ma non devono riaprire codici vecchi tipo `A/0`, `A/42`, `A/43`, `A/215`, `A/216`. +- Il matching tra staging e dominio va letto cosi: + - `condomin.cod_cond` -> `unita_immobiliari.legacy_cond_id` + - `scala/interno` -> codice mnemonico operatore e controllo di riconciliazione +- Le straordinarie con stesso codice su anni diversi restano distinte per anno/numero progressivo e vanno considerate aperte nella gestione in cui vengono lette. - Usa solo **/home/michele/netgescon/netgescon-laravel** per i comandi (niente /var/www/netgescon). - Carica lo staging `gescon_import.condomin` con mdb-tools: - `php artisan gescon:load-mdb --mdb="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --table=condomin --stabile=0021 --anno=0003 --truncate` + `php artisan gescon:load-mdb --mdb="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --table=condomin --stabile=0021 --anno=0003 --truncate` - Reimporta le sole unità: - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=unita` + `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=unita` - Prosegui con soggetti, diritti, millesimi (dopo l’import unità): - - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=soggetti` - - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=diritti` - - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=millesimi` + - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=soggetti` + - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=diritti` + - `php artisan gescon:import-full --stabile=0021 --anno=3 --mdb-condomin="/mnt/gescon-archives/gescon/0021/0003/singolo_anno.mdb" --solo=millesimi` - Se devi ricalcolare piano/scala: prima pulisci `unita_immobiliari` (e `unita_pertinenze`) per quello stabile, poi riesegui i due comandi sopra. ### Esito corrente (stabile 0021 / anno 0003 in DEV) + - `load-mdb condomin`: 268 righe staging - `import-full --solo=unita`: 212 record - `import-full --solo=soggetti`: 55 record @@ -20,22 +31,80 @@ ### Esito corrente (stabile 0021 / anno 0003 in DEV) Ripeti la stessa sequenza per gli altri anni/cartelle cambiando `--stabile` e `--anno` e il path del singolo_anno.mdb. -il primo file da importare è /home/michele/netgescon/docs/02-architettura-laravel/Gescon/cartellestabili_e_anni.mdb qui trovi le prime info per le varie gestioni e cartelle +### Verifica reale aggiornata (stabile 0021 / snapshot corrente 0004) -il secondo file è /home/michele/netgescon/docs/02-architettura-laravel/Gescon/dbc/Stabili.mdb qui trovi le indicazioni di tutti gli stabili +- `import-full --solo=unita --anno=0004`: `140` record processati, `138` aggiornati +- `import-full --solo=soggetti --anno=0004`: `2` record processati +- `import-full --solo=diritti --anno=0004`: `6` inseriti, `3` aggiornati, `270` ignorati +- QA `netgescon:qa-unita-nominativi --stabile_id=25`: + - unita attive `214` + - unita senza nominativi `0` + - tutte le `214` unita attive mappano su `condomin 0021/0004` tramite `legacy_cond_id = cod_cond` + - solo `210` mappano ancora su `0003`, confermando che `0004` e il riferimento corrente corretto + - nessuna unita attiva con `interno = 0` + - i vecchi codici `A/0`, `A/42`, `A/43`, `A/215`, `A/216` non sono piu attivi; restano solo come righe soft-deleted storiche -e poi da li trovi tutti gli archivi che sono principalmente 2 dentro le cartelle -/home/michele/netgescon/docs/02-architettura-laravel/Gescon/0021/generale_stabile_sf.mdb per le info generali -e /home/michele/netgescon/docs/02-architettura-laravel/Gescon/0021/0001/singolo_anno.ldb per i dati di ogni singolo anno, naturlmente sono tutti collegati tra di loro in varie maniere +### Bonifica recapiti rubrica 08/04/2026 +- nuovo comando operativo: `php artisan gescon:bonifica-rubrica-canali --stabile= --apply` +- scopo: + - separare email/PEC multiple salvate in un solo campo + - separare telefoni multipli e classificarli in `telefono` o `cellulare` + - popolare `rubrica_contatti_canali` per PBX, Google Contacts e UI rubrica +- verifica reale su `0021`: + - `192` rubriche analizzate + - `133` rubriche aggiornate + - `360` canali creati + - caso ATER (`rubrica_id=2151`) riallineato con `5` email e `2` telefoni distinti +### Piloti anagrafici aggiuntivi 08/04/2026 + +#### `0002` - Carlo Alberto Racchia 2 + +- anno autorevole: `2024` (`0038`) +- `unita`: `61` +- `soggetti`: `52` +- `diritti`: `82` inseriti +- QA `stabile_id=8`: nessuna anomalia bloccante +- nota importante: + - `cod_cond 62` nello snapshot autorevole e un record comune (`CONDOMINIO (magazzino)`) senza `scala/interno/sub/piano` + - non va trattato come buco dati dell'unita privata ma come voce comune priva di identificativo fisico utile alla UI unita + +#### `0013` - Ottaviano 105 / Giulio Cesare 171 + +- anno autorevole: `2026` (`0015`) +- inizialmente l'import `unita` falliva per overflow di `codice_unita` +- fix applicato: compattazione deterministica del codice entro il limite `varchar(20)` +- esito finale: + - `unita`: `26` processate, `12` aggiornate + - `soggetti`: `50` + - `diritti`: `90` inseriti, `3` aggiornati, `39` ignorati + - QA `stabile_id=16`: nessuna anomalia bloccante +- da modellare a livello dominio/UI: + - unita legacy spezzate per proprietari multipli ma fisicamente/catastalmente uniche + - futura unione per dati catastali con riparto quote/millesimi separato + +#### `0023` - Germanico 79 (legacy `8`) + +- anno autorevole: `2025` (`0096`) +- `unita`: `27` processate, `16` aggiornate +- `soggetti`: `20` +- `diritti`: `25` inseriti, `15` aggiornati, `2` ignorati +- QA `stabile_id=24`: nessuna anomalia bloccante + +il primo file da importare è /home/michele/netgescon/docs/02-architettura-laravel/Gescon/cartellestabili_e_anni.mdb qui trovi le prime info per le varie gestioni e cartelle + +il secondo file è /home/michele/netgescon/docs/02-architettura-laravel/Gescon/dbc/Stabili.mdb qui trovi le indicazioni di tutti gli stabili + +e poi da li trovi tutti gli archivi che sono principalmente 2 dentro le cartelle +/home/michele/netgescon/docs/02-architettura-laravel/Gescon/0021/generale_stabile_sf.mdb per le info generali +e /home/michele/netgescon/docs/02-architettura-laravel/Gescon/0021/0001/singolo_anno.ldb per i dati di ogni singolo anno, naturlmente sono tutti collegati tra di loro in varie maniere Trovi dentro la cartella /home/michele/netgescon/docs/02-architettura-laravel/Gescon e ti trovi dentro la cartella Gescon da dove importare i dati, Per la visualizzazione delle gestioni puoi utilizzare il riferimento che ti allego come immagine mettendo la visualizzazione anche del periodo che trovi dentro la cartella 0021 nel file GENERALE_STABILI.MDB nella tabella anni e poi i relativi campi sono semplici da trovare la cartella che devi considerare per l'importazione è la mappatura dei campi per la successiava automazione del sistema d'importazione è in docs 000-IMPORT -La costruzione delle viste è dei menù devi utilizzare il manuale 90-UI-interfaccia-unica +La costruzione delle viste è dei menù devi utilizzare il manuale 90-UI-interfaccia-unica I manuali per i database è 91-database dove trovi anche le credenziali. - -Ci sono alcuni campi che dobbiamo tenre in considerazione per l'im portazione primo va c \ No newline at end of file +Ci sono alcuni campi che dobbiamo tenre in considerazione per l'im portazione primo va c diff --git a/resources/views/filament/components/topbar-context.blade.php b/resources/views/filament/components/topbar-context.blade.php index cb27069..b4868e6 100644 --- a/resources/views/filament/components/topbar-context.blade.php +++ b/resources/views/filament/components/topbar-context.blade.php @@ -1,6 +1,37 @@ @php $user = \Illuminate\Support\Facades\Auth::user(); + $stabileHasRiscaldamento = static function (?\App\Models\Stabile $stabile): bool { + if (! $stabile) { + return false; + } + + if ((bool) ($stabile->riscaldamento_centralizzato ?? false)) { + return true; + } + + $rateRiscaldamento = $stabile->rate_riscaldamento_mesi ?? []; + if (is_string($rateRiscaldamento)) { + $rateRiscaldamento = json_decode($rateRiscaldamento, true); + } + if (is_array($rateRiscaldamento) && $rateRiscaldamento !== []) { + return true; + } + + $config = $stabile->configurazione_avanzata ?? []; + if (is_string($config)) { + $config = json_decode($config, true); + } + if (is_array($config) && array_key_exists('mostra_riscaldamento', $config) && $config['mostra_riscaldamento']) { + return true; + } + + return \Illuminate\Support\Facades\DB::table('gestioni_contabili') + ->where('stabile_id', $stabile->id) + ->where('tipo_gestione', 'riscaldamento') + ->exists(); + }; + $stabileLabel = static function (?\App\Models\Stabile $stabile): string { if (! $stabile) { return 'Seleziona stabile'; @@ -32,7 +63,7 @@ $labelGestione = $gestioni[$gestioneAttiva] ?? (string) $gestioneAttiva; - $haRiscaldamento = (bool) ($stabileAttivo?->riscaldamento_centralizzato ?? false); + $haRiscaldamento = $stabileHasRiscaldamento($stabileAttivo); @endphp
diff --git a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php index 26371a3..1912a5e 100644 --- a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php +++ b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php @@ -2,7 +2,7 @@ @if($isInlineEditing) Modifica Contatto Inline - Modifica i campi direttamente in pagina, senza modal. + Modifica i campi principali senza lasciare aperti i pannelli laterali. Per numeri, email e PEC multiple usa i contatti avanzati.