Rubrica cleanup, PBX/Google contact sync, and import pilot validation

This commit is contained in:
michele 2026-04-08 14:48:10 +00:00
parent 8e8f95b7c6
commit a9204e125b
17 changed files with 1447 additions and 287 deletions

View File

@ -0,0 +1,401 @@
<?php
namespace App\Console\Commands;
use App\Models\RubricaContattoCanale;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Support\PhoneNumber;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
class GesconBonificaRubricaCanaliCommand extends Command
{
protected $signature = 'gescon:bonifica-rubrica-canali
{--stabile= : Codice stabile da bonificare}
{--admin-id= : Limita la bonifica a un amministratore}
{--limit=5000 : Numero massimo di anagrafiche da analizzare}
{--apply : Applica realmente le modifiche}';
protected $description = 'Normalizza telefoni, email e PEC multi-valore della rubrica e li materializza in rubrica_contatti_canali.';
public function handle(): int
{
if (! Schema::hasTable('rubrica_universale')) {
$this->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<int, int> */
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<string,mixed>, channels: array<int,array<string,mixed>>} */
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<int, string> */
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<int, string> */
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<int, string> $values
* @return array<int, array{value:string,type:string}>
*/
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<int, string> $values
* @return array<int, string>
*/
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<string,mixed> */
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<int,array<string,mixed>> $channels
* @return array<int,array<string,mixed>>
*/
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;
}
}

View File

@ -3,12 +3,14 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\RubricaContattoCanale;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Support\PhoneNumber; use App\Support\PhoneNumber;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
class GooglePushRubricaContactsCommand extends Command class GooglePushRubricaContactsCommand extends Command
{ {
@ -86,8 +88,10 @@ public function handle(): int
$errors = 0; $errors = 0;
foreach ($records as $record) { foreach ($records as $record) {
$email = trim((string) ($record->email ?? '')); $emails = $this->emailsForGoogle($record);
$phone = $this->mobileForGoogle($record) ?? ''; $phones = $this->phonesForGoogle($record);
$email = $emails[0]['value'] ?? '';
$phone = $phones[0]['value'] ?? '';
$phoneNormalized = $this->normalizePhone($phone); $phoneNormalized = $this->normalizePhone($phone);
[$givenName, $familyName, $fullName] = $this->buildGoogleNameParts($record); [$givenName, $familyName, $fullName] = $this->buildGoogleNameParts($record);
@ -100,11 +104,11 @@ public function handle(): int
]], ]],
]; ];
if ($email !== '') { if ($emails !== []) {
$payload['emailAddresses'] = [['value' => $email]]; $payload['emailAddresses'] = $emails;
} }
if ($phone !== '') { if ($phones !== []) {
$payload['phoneNumbers'] = [['value' => $phone, 'type' => 'mobile', 'formattedType' => 'Cellulare']]; $payload['phoneNumbers'] = $phones;
} }
$resourceName = null; $resourceName = null;
@ -282,16 +286,19 @@ private function loadGoogleContactIndex(Amministratore $amministratore, array $g
foreach ($connections as $person) { foreach ($connections as $person) {
$resourceName = trim((string) Arr::get($person, 'resourceName', '')); $resourceName = trim((string) Arr::get($person, 'resourceName', ''));
$email = trim((string) Arr::get($person, 'emailAddresses.0.value', '')); foreach ((array) Arr::get($person, 'emailAddresses', []) as $emailRow) {
$phone = trim((string) Arr::get($person, 'phoneNumbers.0.value', '')); $email = trim((string) Arr::get($emailRow, 'value', ''));
if ($email !== '' && $resourceName !== '') {
if ($email !== '' && $resourceName !== '') { $existingEmails[strtolower($email)] = $resourceName;
$existingEmails[strtolower($email)] = $resourceName; }
} }
if ($phone !== '' && $resourceName !== '') { foreach ((array) Arr::get($person, 'phoneNumbers', []) as $phoneRow) {
$normalized = $this->normalizePhone($phone); $phone = trim((string) Arr::get($phoneRow, 'value', ''));
if ($normalized !== '') { if ($phone !== '' && $resourceName !== '') {
$existingPhones[$normalized] = $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 PhoneNumber::toE164Italy($candidate);
} }
/** @return array<int, array<string, string>> */
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<int, array<string, string>> */
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<int, string> */
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<int, string> */
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} * @return array{0:string,1:string,2:string}
*/ */

View File

@ -2,12 +2,14 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\RubricaContattoCanale;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Support\PhoneNumber; use App\Support\PhoneNumber;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
class GoogleSyncRubricaContactsCommand extends Command class GoogleSyncRubricaContactsCommand extends Command
{ {
@ -194,9 +196,13 @@ public function handle(): int
$existing->modificato_da = $adminUserId; $existing->modificato_da = $adminUserId;
} }
$existing->save(); $existing->save();
$this->syncAdditionalGoogleChannels($existing, $person);
} }
$updated++; $updated++;
} else { } else {
if (! $dryRun) {
$this->syncAdditionalGoogleChannels($existing, $person);
}
$skipped++; $skipped++;
} }
@ -208,7 +214,7 @@ public function handle(): int
continue; continue;
} }
RubricaUniversale::create([ $createdRubrica = RubricaUniversale::create([
'nome' => $nome, 'nome' => $nome,
'cognome' => $cognome, 'cognome' => $cognome,
'tipo_contatto' => $tipoContatto, 'tipo_contatto' => $tipoContatto,
@ -225,6 +231,7 @@ public function handle(): int
'creato_da' => $adminUserId > 0 ? $adminUserId : null, 'creato_da' => $adminUserId > 0 ? $adminUserId : null,
'modificato_da' => $adminUserId > 0 ? $adminUserId : null, 'modificato_da' => $adminUserId > 0 ? $adminUserId : null,
]); ]);
$this->syncAdditionalGoogleChannels($createdRubrica, $person);
} }
$nextPageToken = $response->json('nextPageToken'); $nextPageToken = $response->json('nextPageToken');
@ -400,4 +407,66 @@ private function shouldSyncField(?string $current, ?string $incoming, bool $isGo
return mb_strtolower($current) !== mb_strtolower($incoming); 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'],
]
);
}
}
} }

View File

@ -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} {--solo= : Step singolo: unita|relazioni|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto}
{--no-views : Non ricrea le viste QA} {--no-views : Non ricrea le viste QA}
{--force-mapping : Applica mapping anche sovrascrivendo valori esistenti} {--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} {--amministratore-id= : Forza l\'assegnazione dello stabile a questo amministratore}
'; ';
@ -80,6 +81,70 @@ private function scopeCondominQuery($query)
return $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. // Mapping campi configurato via UI (per-utente/per-stabile). Caricato on-demand.
private const BOOLEAN_TARGET_KEYS = [ private const BOOLEAN_TARGET_KEYS = [
'stabile.ascensore', 'stabile.ascensore',
@ -526,13 +591,7 @@ private function stepUnita(?int $limit): int
$batch = $limit ? min(max(1, $limit), 1000) : 1000; $batch = $limit ? min(max(1, $limit), 1000) : 1000;
$offset = 0; $offset = 0;
while (true) { while (true) {
$q = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')); $q = $this->currentSnapshotCondominQuery();
if ($this->option('stabile')) {
$q->where('cod_stabile', $this->option('stabile'));
}
if ($this->option('scala')) {
$q->where('scala', $this->option('scala'));
}
$q->limit($batch)->offset($offset); $q->limit($batch)->offset($offset);
$rows = $q->get(); $rows = $q->get();
if ($rows->isEmpty()) { if ($rows->isEmpty()) {
@ -577,8 +636,14 @@ private function stepUnita(?int $limit): int
} else { } else {
$rowStabileId = $this->stabileId; $rowStabileId = $this->stabileId;
} }
$interno = $r->interno ?? null; $interno = isset($r->interno) ? trim((string) $r->interno) : null;
$sub = $r->sub ?? $interno; 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 // Riconosci pertinenze nel campo INT o note: cantina, box/garage, posto auto, soffitta/solaio, locale tecnico
$internoStr = is_string($interno) ? trim($interno) : (string) $interno; $internoStr = is_string($interno) ? trim($interno) : (string) $interno;
$ptype = null; $ptype = null;
@ -614,12 +679,37 @@ private function stepUnita(?int $limit): int
$pianoVal = (int) $pv; $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) { if ($hasDetailedSchema) {
// Codice unita coerente: STAB-SCALA-INT (per cantine: STAB-SCALA-CAN<n>) $exists = null;
$intPart = $ptype && $pcode ? $pcode : (($interno ?? null) ?: '0'); 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<n>/BOX<n>/...)
$intPart = $normalizedInterno;
$codiceBaseStab = ($r->cod_stabile ?? $this->option('stabile')) ?: 'S0'; $codiceBaseStab = ($r->cod_stabile ?? $this->option('stabile')) ?: 'S0';
// Se stabili-per-scala, includi comunque la scala nel codice per leggibilità $codice = $intPart !== null
$codice = $codiceBaseStab . '-' . ($scalaVal ?: 'A') . '-' . $intPart; ? $this->buildLegacyUnitCode($codiceBaseStab, $scalaVal ?: 'A', (string) $intPart, $legacyCondId)
: (string) ($exists->codice_unita ?? '');
// Filtri post-normalizzazione: pertinenze e piano min/max // Filtri post-normalizzazione: pertinenze e piano min/max
if ($this->option('only-pertinenze')) { if ($this->option('only-pertinenze')) {
$opt = strtolower((string) $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) { if ($pMax !== null && $pianoVal > $pMax) {
continue; continue;
} }
$exists = null; if (! $exists && $codice !== '') {
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) {
$exists = DB::table('unita_immobiliari')->where('codice_unita', $codice)->first(); $exists = DB::table('unita_immobiliari')->where('codice_unita', $codice)->first();
if ($exists && $legacyCondId !== null && $legacyCondId !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { 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) { 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(); $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; $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 ($this->splitMode === 'palazzine') {
if (Schema::hasColumn('unita_immobiliari', 'palazzina')) { if (Schema::hasColumn('unita_immobiliari', 'palazzina')) {
if (empty($exists->palazzina) || $exists->palazzina !== $scalaVal) { if (empty($exists->palazzina) || $exists->palazzina !== $scalaVal) {
@ -692,8 +808,8 @@ private function stepUnita(?int $limit): int
'palazzina' => $this->splitMode === 'palazzine' ? $scalaVal : null, 'palazzina' => $this->splitMode === 'palazzine' ? $scalaVal : null,
'scala' => $scalaVal, 'scala' => $scalaVal,
'piano' => $pianoVal, 'piano' => $pianoVal,
'interno' => (string) ($ptype && $pcode ? $pcode : $interno), 'interno' => $normalizedInterno,
'subalterno' => (string) $sub, 'subalterno' => $sub,
'categoria_catastale' => null, 'categoria_catastale' => null,
'classe' => null, 'classe' => null,
'consistenza' => null, 'consistenza' => null,
@ -788,8 +904,7 @@ private function stepUnita(?int $limit): int
// Cleanup: rimuovi unità non presenti in condomin (evita "alieni") // Cleanup: rimuovi unità non presenti in condomin (evita "alieni")
if (! $this->isDryRun && $this->option('stabile')) { if (! $this->isDryRun && $this->option('stabile')) {
$condRows = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')) $condRows = $this->currentSnapshotCondominQuery()
->where('cod_stabile', $this->option('stabile'))
->select('scala', 'interno', 'cod_cond') ->select('scala', 'interno', 'cod_cond')
->get(); ->get();
$condSet = []; $condSet = [];
@ -920,13 +1035,7 @@ private function stepSoggetti(?int $limit): int
$batch = $limit ? min(max(1, $limit), 1000) : 1000; $batch = $limit ? min(max(1, $limit), 1000) : 1000;
$offset = 0; $offset = 0;
while (true) { while (true) {
$q = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')); $q = $this->currentSnapshotCondominQuery();
if ($this->option('stabile')) {
$q->where('cod_stabile', $this->option('stabile'));
}
if ($this->option('scala')) {
$q->where('scala', $this->option('scala'));
}
$q->limit($batch)->offset($offset); $q->limit($batch)->offset($offset);
$rows = $q->get(); $rows = $q->get();
if ($rows->isEmpty()) { if ($rows->isEmpty()) {
@ -1050,13 +1159,7 @@ private function stepDiritti(?int $limit): int
return $value ?? ''; return $value ?? '';
}; };
while (true) { while (true) {
$q = $this->scopeCondominQuery(DB::connection('gescon_import')->table('condomin')); $q = $this->currentSnapshotCondominQuery();
if ($this->option('stabile')) {
$q->where('cod_stabile', $this->option('stabile'));
}
if ($this->option('scala')) {
$q->where('scala', $this->option('scala'));
}
$q->limit($batch)->offset($offset); $q->limit($batch)->offset($offset);
$rows = $q->get(); $rows = $q->get();
if ($rows->isEmpty()) { if ($rows->isEmpty()) {
@ -1262,10 +1365,7 @@ private function stepDiritti(?int $limit): int
// Import comproprietari (se presenti) // Import comproprietari (se presenti)
if (Schema::connection('gescon_import')->hasTable('comproprietari')) { if (Schema::connection('gescon_import')->hasTable('comproprietari')) {
$q = DB::connection('gescon_import')->table('comproprietari'); $q = $this->currentSnapshotComproprietariQuery();
if ($this->option('stabile')) {
$q->where('cod_stabile', $this->option('stabile'));
}
$comprRows = $q->limit(50000)->get(); $comprRows = $q->limit(50000)->get();
foreach ($comprRows as $cr) { foreach ($comprRows as $cr) {
@ -1970,8 +2070,10 @@ private function stepMillesimi(?int $limit): int
} }
$affected = $detailsInserted + $detailsUpdated; $affected = $detailsInserted + $detailsUpdated;
// 4) Completa righe mancanti: ogni unità deve avere una riga per ogni tabella // 4) Completa righe mancanti solo se richiesto esplicitamente.
if ($detailsTable === 'dettaglio_millesimi' && Schema::hasTable('unita_immobiliari')) { // 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'); $detailCols = Schema::getColumnListing('dettaglio_millesimi');
$unitaIds = DB::table('unita_immobiliari') $unitaIds = DB::table('unita_immobiliari')
->where('stabile_id', $this->stabileId) ->where('stabile_id', $this->stabileId)
@ -2166,6 +2268,19 @@ private function stepVoci(?int $limit): int
$tabellaQuery->where('anno_gestione', $annoGestione); $tabellaQuery->where('anno_gestione', $annoGestione);
} }
$tabellaId = $tabellaQuery->value('id'); $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); $gestioneId = $this->resolveGestioneContabileIdForStabile($stabileId, $legacyYear, $tipoLegacy, $numeroStraordinaria);
$data = [ $data = [
@ -3762,7 +3877,9 @@ private function preflightStaging(): void
return; 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. // Autoderiva --mdb-condomin se non fornito, preferendo l'anno richiesto e poi l'ultima annualità disponibile.
if (! $this->option('mdb-condomin')) { if (! $this->option('mdb-condomin')) {
@ -3783,12 +3900,10 @@ private function preflightStaging(): void
)); ));
} }
if ($legacyYearOpt === null) { if (! empty($resolved['legacy_year'])) {
if (! empty($resolved['anno_o'])) { $legacyYearOpt = (string) $resolved['legacy_year'];
$legacyYearOpt = (string) $resolved['anno_o']; } elseif ($legacyYearOpt === null && ! empty($resolved['anno_o'])) {
} elseif (! empty($resolved['legacy_year'])) { $legacyYearOpt = (string) $resolved['anno_o'];
$legacyYearOpt = (string) $resolved['legacy_year'];
}
} }
} }
} }
@ -3798,7 +3913,7 @@ private function preflightStaging(): void
return; return;
} }
$needsLoad = $this->stagingTableNeedsLoadForStabile('condomin', $stab); $needsLoad = $this->stagingTableNeedsLoadForStabile('condomin', $stab, $legacyYearOpt);
if ($needsLoad) { if ($needsLoad) {
$mdbCondomin = $this->option('mdb-condomin'); $mdbCondomin = $this->option('mdb-condomin');
if ($mdbCondomin && is_file($mdbCondomin)) { 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. // 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 // Carica tabelle aggiuntive utili (millesimi, voci) se vuote per lo stabile
@ -3828,7 +3963,7 @@ private function preflightStaging(): void
if ($mdbCondomin && is_file($mdbCondomin)) { if ($mdbCondomin && is_file($mdbCondomin)) {
// dett_tab // dett_tab
if (Schema::connection('gescon_import')->hasTable('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) { if ($need) {
$this->callSilently('gescon:load-mdb', [ $this->callSilently('gescon:load-mdb', [
'--mdb' => $mdbCondomin, '--mdb' => $mdbCondomin,
@ -3840,7 +3975,7 @@ private function preflightStaging(): void
} }
// tabelle_millesimali // tabelle_millesimali
if (Schema::connection('gescon_import')->hasTable('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) { if ($need) {
$this->callSilently('gescon:load-mdb', [ $this->callSilently('gescon:load-mdb', [
'--mdb' => $mdbCondomin, '--mdb' => $mdbCondomin,
@ -3852,7 +3987,7 @@ private function preflightStaging(): void
} }
// voc_spe (voci spesa) // voc_spe (voci spesa)
if (Schema::connection('gescon_import')->hasTable('voc_spe')) { if (Schema::connection('gescon_import')->hasTable('voc_spe')) {
$need = $this->stagingTableNeedsLoadForStabile('voc_spe', $stab); $need = $this->stagingTableNeedsLoadForStabile('voc_spe', $stab, $legacyYearOpt);
if ($need) { if ($need) {
$this->callSilently('gescon:load-mdb', [ $this->callSilently('gescon:load-mdb', [
'--mdb' => $mdbCondomin, '--mdb' => $mdbCondomin,
@ -3864,7 +3999,7 @@ private function preflightStaging(): void
} }
// comproprietari // comproprietari
if (Schema::connection('gescon_import')->hasTable('comproprietari')) { if (Schema::connection('gescon_import')->hasTable('comproprietari')) {
$need = $this->stagingTableNeedsLoadForStabile('comproprietari', $stab); $need = $this->stagingTableNeedsLoadForStabile('comproprietari', $stab, $legacyYearOpt);
if ($need) { if ($need) {
$this->callSilently('gescon:load-mdb', [ $this->callSilently('gescon:load-mdb', [
'--mdb' => $mdbCondomin, '--mdb' => $mdbCondomin,
@ -3876,7 +4011,7 @@ private function preflightStaging(): void
} }
// Cre_Deb_preced (crediti/debiti pregressi) // Cre_Deb_preced (crediti/debiti pregressi)
if (Schema::connection('gescon_import')->hasTable('cre_deb_preced')) { 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) { if ($need) {
$this->callSilently('gescon:load-mdb', [ $this->callSilently('gescon:load-mdb', [
'--mdb' => $mdbCondomin, '--mdb' => $mdbCondomin,
@ -3898,7 +4033,7 @@ private function preflightStaging(): void
]; ];
foreach ($more as $tb) { foreach ($more as $tb) {
if (Schema::connection('gescon_import')->hasTable($tb)) { if (Schema::connection('gescon_import')->hasTable($tb)) {
$need = $this->stagingTableNeedsLoadForStabile($tb, $stab); $need = $this->stagingTableNeedsLoadForStabile($tb, $stab, $legacyYearOpt);
if ($need) { if ($need) {
$this->callSilently('gescon:load-mdb', [ $this->callSilently('gescon:load-mdb', [
'--mdb' => $mdbCondomin, '--mdb' => $mdbCondomin,
@ -4021,7 +4156,7 @@ private function resolveCondominMdbForStabile(string $stab, ?string $legacyYear
return Arr::except($candidates[0], ['sort_key']); 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)) { if (! Schema::connection('gescon_import')->hasTable($table)) {
return false; return false;
@ -4029,7 +4164,12 @@ private function stagingTableNeedsLoadForStabile(string $table, string $stab): b
$conn = DB::connection('gescon_import'); $conn = DB::connection('gescon_import');
if (Schema::connection('gescon_import')->hasColumn($table, 'cod_stabile')) { 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; return $conn->table($table)->count() === 0;
@ -4243,9 +4383,24 @@ private function findUnitaByStagingRow($r)
return null; 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 = []; $crit = [];
if ($this->stabileId !== null && Schema::hasColumn('unita_immobiliari', 'stabile_id')) { if ($stabileId !== null && Schema::hasColumn('unita_immobiliari', 'stabile_id')) {
$crit['stabile_id'] = $this->stabileId; $crit['stabile_id'] = $stabileId;
} }
if (Schema::hasColumn('unita_immobiliari', 'scala') && isset($r->scala)) { if (Schema::hasColumn('unita_immobiliari', 'scala') && isset($r->scala)) {
$crit['scala'] = $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)) { if (is_string($int) && preg_match('/^\s*CAN\s*(\d+)\s*$/i', $int, $m)) {
$int = 'CAN' . $m[1]; $int = 'CAN' . $m[1];
} }
if (($int === null || trim($int) === '') && isset($r->cod_cond)) {
$int = (string) $r->cod_cond;
}
if ($int !== null) { if ($int !== null) {
$int = trim($int);
}
if ($int !== null && $int !== '' && ! preg_match('/^0+$/', $int)) {
$crit['interno'] = $int; $crit['interno'] = $int;
} }
} }
if (! empty($crit)) { 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; return null;
} }
@ -4719,6 +4877,35 @@ private function generateStableCondKey(string $prefix, $stabile, $cond, int $len
return $prefix . $digest; 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 private function nextProtocolNumber(): int
{ {
return ++$this->protocolCounter; return ++$this->protocolCounter;
@ -5111,13 +5298,16 @@ private function buildComproprietariHistoryPayloads(array $historyRows, ?string
$currentNameKey = $this->normalizeLegacyPartyName($current['nominativo'] ?? null); $currentNameKey = $this->normalizeLegacyPartyName($current['nominativo'] ?? null);
$currentRightKey = $this->normalizeLegacyPartyName($current['diritto_reale'] ?? $current['diritto_label'] ?? null); $currentRightKey = $this->normalizeLegacyPartyName($current['diritto_reale'] ?? $current['diritto_label'] ?? null);
$sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey; $sameSeries = ! empty($snapshot['id_compr']) && ! empty($current['id_compr'])
$sameRight = $snapshotRightKey === $currentRightKey; && (int) $snapshot['id_compr'] === (int) $current['id_compr'];
$samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null $sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey;
$sameRight = $snapshotRightKey === $currentRightKey;
$samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null
|| abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005; || 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['data_inizio'] = $snapshotStart ?? $current['data_inizio'];
$current['percentuale'] = $current['percentuale'] ?? $snapshot['percentuale'];
if (! empty($snapshot['legacy_year'])) { if (! empty($snapshot['legacy_year'])) {
$current['legacy_years'][] = $snapshot['legacy_year']; $current['legacy_years'][] = $snapshot['legacy_year'];
} }

View File

@ -73,7 +73,11 @@ public function handle(): int
->when($stabileId > 0, fn($q) => $q->where('ui.stabile_id', $stabileId)) ->when($stabileId > 0, fn($q) => $q->where('ui.stabile_id', $stabileId))
->count(); ->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') ? DB::table('unita_immobiliare_nominativi as a')
->join('unita_immobiliare_nominativi as b', function ($join) : void { ->join('unita_immobiliare_nominativi as b', function ($join) : void {
$join->on('a.unita_immobiliare_id', '=', 'b.unita_immobiliare_id') $join->on('a.unita_immobiliare_id', '=', 'b.unita_immobiliare_id')
@ -81,9 +85,27 @@ public function handle(): int
->whereColumn('a.id', '<', 'b.id'); ->whereColumn('a.id', '<', 'b.id');
}) })
->join('unita_immobiliari as ui', 'ui.id', '=', 'a.unita_immobiliare_id') ->join('unita_immobiliari as ui', 'ui.id', '=', 'a.unita_immobiliare_id')
->where(function ($q): void { ->whereIn('a.fonte', ['legacy_condomin', 'legacy_comproprietari'])
$q->whereNull('a.data_fine')->orWhereNull('b.data_inizio')->orWhereColumn('a.data_fine', '>=', 'b.data_inizio'); ->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)) ->when($stabileId > 0, fn($q) => $q->where('ui.stabile_id', $stabileId))
->count() ->count()
: 0; : 0;
@ -116,7 +138,8 @@ public function handle(): int
$this->line('Unita senza storico nominativi: ' . $unitaSenzaNominativi); $this->line('Unita senza storico nominativi: ' . $unitaSenzaNominativi);
$this->line('Ruoli con stabile mismatch: ' . $mismatchRuoliStabile . ($applyFix ? ' (fix applicato)' : '')); $this->line('Ruoli con stabile mismatch: ' . $mismatchRuoliStabile . ($applyFix ? ' (fix applicato)' : ''));
$this->line('Ruoli attivi senza cellulare: ' . $ruoliAttiviSenzaCell); $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)) { if (! empty($legacyYears)) {
$this->line('Anni legacy disponibili (condomin):'); $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.'); $this->warn('QA completato con anomalie da lavorare.');
return self::INVALID; return self::INVALID;
} }

View File

@ -323,10 +323,10 @@ private function hydrateCollegamentoWorkspace(): void
{ {
$suggestion = $this->resolveCollegamentoSuggerito(); $suggestion = $this->resolveCollegamentoSuggerito();
$this->collegamentoSuggerito = $suggestion; $this->collegamentoSuggerito = $suggestion;
$this->collegamentoStabileId = ! empty($suggestion['stabile_id']) ? (int) $suggestion['stabile_id'] : null; $this->collegamentoStabileId = ! empty($suggestion['stabile_id']) ? (int) $suggestion['stabile_id'] : null;
$this->collegamentoUnitaId = ! empty($suggestion['unita_id']) ? (int) $suggestion['unita_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->collegamentoRuolo = ! empty($suggestion['role_standard']) ? (string) $suggestion['role_standard'] : 'altro';
$this->collegamentoRuoloCustom = ! empty($suggestion['role_custom']) ? (string) $suggestion['role_custom'] : ''; $this->collegamentoRuoloCustom = ! empty($suggestion['role_custom']) ? (string) $suggestion['role_custom'] : '';
} }
@ -353,15 +353,15 @@ public function collegaCollegamentoSuggerito(): void
public function salvaCollegamentoManuale(): void public function salvaCollegamentoManuale(): void
{ {
$data = validator([ $data = validator([
'stabile_id' => $this->collegamentoStabileId, 'stabile_id' => $this->collegamentoStabileId,
'unita_immobiliare_id' => $this->collegamentoUnitaId, 'unita_immobiliare_id' => $this->collegamentoUnitaId,
'ruolo_standard' => $this->collegamentoRuolo, 'ruolo_standard' => $this->collegamentoRuolo,
'ruolo_custom' => $this->collegamentoRuoloCustom, 'ruolo_custom' => $this->collegamentoRuoloCustom,
], [ ], [
'stabile_id' => ['required', 'integer'], 'stabile_id' => ['required', 'integer'],
'unita_immobiliare_id' => ['nullable', 'integer'], 'unita_immobiliare_id' => ['nullable', 'integer'],
'ruolo_standard' => ['required', 'string', 'max:120'], 'ruolo_standard' => ['required', 'string', 'max:120'],
'ruolo_custom' => ['nullable', 'string', 'max:255'], 'ruolo_custom' => ['nullable', 'string', 'max:255'],
])->validate(); ])->validate();
$this->persistCollegamentoRubrica( $this->persistCollegamentoRubrica(
@ -390,7 +390,7 @@ private function persistCollegamentoRubrica(
return; return;
} }
$user = Auth::user(); $user = Auth::user();
$isSuperAdmin = $user instanceof User && method_exists($user, 'hasRole') $isSuperAdmin = $user instanceof User && method_exists($user, 'hasRole')
? (bool) $user->hasRole('super-admin') ? (bool) $user->hasRole('super-admin')
: false; : false;
@ -423,12 +423,12 @@ private function persistCollegamentoRubrica(
} }
$ruoloStandard = trim($ruoloStandard) !== '' ? trim($ruoloStandard) : 'altro'; $ruoloStandard = trim($ruoloStandard) !== '' ? trim($ruoloStandard) : 'altro';
$ruoloCustom = trim($ruoloCustom); $ruoloCustom = trim($ruoloCustom);
$attributes = [ $attributes = [
'rubrica_id' => (int) $this->rubrica->id, 'rubrica_id' => (int) $this->rubrica->id,
'ruolo_standard' => $ruoloStandard, 'ruolo_standard' => $ruoloStandard,
'stabile_id' => (int) $stabile->id, 'stabile_id' => (int) $stabile->id,
'unita_immobiliare_id' => $unita?->id, 'unita_immobiliare_id' => $unita?->id,
]; ];
@ -446,11 +446,11 @@ private function persistCollegamentoRubrica(
$payload = [ $payload = [
'ruolo_custom' => $ruoloCustom !== '' ? $ruoloCustom : null, 'ruolo_custom' => $ruoloCustom !== '' ? $ruoloCustom : null,
'is_attivo' => true, 'is_attivo' => true,
'is_preferito' => $existing ? (bool) ($existing->is_preferito ?? false) : count($this->ruoliAttivi) === 0, 'is_preferito' => $existing ? (bool) ($existing->is_preferito ?? false) : count($this->ruoliAttivi) === 0,
'data_inizio' => $existing?->data_inizio ?: now()->toDateString(), 'data_inizio' => $existing?->data_inizio ?: now()->toDateString(),
'meta' => array_filter([ 'meta' => array_filter([
'source' => $source, 'source' => $source,
'linked_from' => 'rubrica_scheda', 'linked_from' => 'rubrica_scheda',
], fn($value) => $value !== null && $value !== ''), ], fn($value) => $value !== null && $value !== ''),
]; ];
@ -463,12 +463,12 @@ private function persistCollegamentoRubrica(
} }
$this->rubrica->riferimento_stabile = $this->buildLocalStabileReference($stabile); $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)) { if (in_array($ruoloStandard, ['condomino', 'inquilino', 'fornitore', 'amministratore', 'altro'], true)) {
$this->rubrica->tipo_utenza_call = $ruoloStandard; $this->rubrica->tipo_utenza_call = $ruoloStandard;
} }
$this->rubrica->data_ultima_modifica = now()->toDateString(); $this->rubrica->data_ultima_modifica = now()->toDateString();
$this->rubrica->modificato_da = Auth::id(); $this->rubrica->modificato_da = Auth::id();
$this->rubrica->save(); $this->rubrica->save();
$this->mount((int) $this->rubrica->id); $this->mount((int) $this->rubrica->id);
@ -483,7 +483,7 @@ private function persistCollegamentoRubrica(
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function resolveCollegamentoSuggerito(): 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 ?? '')); $stabile = $this->resolveCollegamentoStabile($legacy['cod_stabile'] ?? null, (string) ($this->rubrica->riferimento_stabile ?? ''));
if (empty($stabile['id'])) { if (empty($stabile['id'])) {
return []; return [];
@ -496,14 +496,14 @@ private function resolveCollegamentoSuggerito(): array
); );
return array_filter([ return array_filter([
'stabile_id' => (int) $stabile['id'], 'stabile_id' => (int) $stabile['id'],
'stabile_label' => $this->buildCollegamentoStabileReference($stabile), 'stabile_label' => $this->buildCollegamentoStabileReference($stabile),
'unita_id' => (int) ($unit['unit_id'] ?? 0) ?: null, 'unita_id' => (int) ($unit['unit_id'] ?? 0) ?: null,
'unita_label' => (string) ($unit['unit_label'] ?? ''), 'unita_label' => (string) ($unit['unit_label'] ?? ''),
'unita_reference' => (string) ($unit['unit_reference'] ?? ''), 'unita_reference' => (string) ($unit['unit_reference'] ?? ''),
'role_standard' => $this->mapCollegamentoRole($legacy['ruolo'] ?? $this->rubrica->tipo_utenza_call), 'role_standard' => $this->mapCollegamentoRole($legacy['ruolo'] ?? $this->rubrica->tipo_utenza_call),
'role_custom' => null, 'role_custom' => null,
'source' => (string) ($unit['source'] ?? 'riferimento_stabile'), 'source' => (string) ($unit['source'] ?? 'riferimento_stabile'),
'requires_review' => (bool) ($unit['source'] ?? false), 'requires_review' => (bool) ($unit['source'] ?? false),
], fn($value) => $value !== null && $value !== ''); ], fn($value) => $value !== null && $value !== '');
} }
@ -513,8 +513,8 @@ private function parseCollegamentoLegacyMeta(string $note): array
{ {
$payload = [ $payload = [
'cod_stabile' => null, 'cod_stabile' => null,
'cod_cond' => null, 'cod_cond' => null,
'ruolo' => null, 'ruolo' => null,
]; ];
foreach (array_keys($payload) as $key) { foreach (array_keys($payload) as $key) {
@ -538,7 +538,7 @@ private function resolveCollegamentoStabile(?string $legacyCode, string $referen
} }
$trimmed = ltrim($code, '0'); $trimmed = ltrim($code, '0');
$row = Stabile::query() $row = Stabile::query()
->when($this->adminId > 0, fn(Builder $query) => $query->where('amministratore_id', $this->adminId)) ->when($this->adminId > 0, fn(Builder $query) => $query->where('amministratore_id', $this->adminId))
->where(function (Builder $query) use ($code, $trimmed): void { ->where(function (Builder $query) use ($code, $trimmed): void {
$query->where('codice_stabile', $code) $query->where('codice_stabile', $code)
@ -554,10 +554,10 @@ private function resolveCollegamentoStabile(?string $legacyCode, string $referen
if ($row) { if ($row) {
return [ return [
'id' => (int) $row->id, 'id' => (int) $row->id,
'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null, 'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null,
'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null,
'denominazione' => $row->denominazione ? (string) $row->denominazione : null, 'denominazione' => $row->denominazione ? (string) $row->denominazione : null,
]; ];
} }
} }
@ -574,10 +574,10 @@ private function resolveCollegamentoStabile(?string $legacyCode, string $referen
$row = $rows->first(); $row = $rows->first();
return [ return [
'id' => (int) $row->id, 'id' => (int) $row->id,
'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null, 'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null,
'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null,
'denominazione' => $row->denominazione ? (string) $row->denominazione : null, 'denominazione' => $row->denominazione ? (string) $row->denominazione : null,
]; ];
} }
} }
@ -602,10 +602,10 @@ private function resolveCollegamentoUnita(int $stabileId, array $legacy, string
$row = $rows->first(); $row = $rows->first();
return [ return [
'unit_id' => (int) $row->id, 'unit_id' => (int) $row->id,
'unit_label' => $this->buildLocalUnitLabel($row), 'unit_label' => $this->buildLocalUnitLabel($row),
'unit_reference' => $this->buildLocalUnitReference($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) { foreach ($rows as $row) {
$unit = $this->resolveUnitFromImportRow($stabileId, [ $unit = $this->resolveUnitFromImportRow($stabileId, [
'nom_cond' => (string) ($row->nom_cond ?? ''), 'nom_cond' => (string) ($row->nom_cond ?? ''),
'scala' => (string) ($row->scala ?? ''), 'scala' => (string) ($row->scala ?? ''),
'interno' => (string) ($row->interno ?? ''), 'interno' => (string) ($row->interno ?? ''),
]); ]);
if (! $unit) { if (! $unit) {
@ -632,10 +632,10 @@ private function resolveCollegamentoUnita(int $stabileId, array $legacy, string
} }
$candidates[(int) $unit->unit_id] = [ $candidates[(int) $unit->unit_id] = [
'unit_id' => (int) $unit->unit_id, 'unit_id' => (int) $unit->unit_id,
'unit_label' => $this->buildLocalUnitLabel($unit), 'unit_label' => $this->buildLocalUnitLabel($unit),
'unit_reference' => $this->buildLocalUnitReference($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 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', '')); $label = trim((string) data_get($unit, 'denominazione', ''));
return trim($code . ($label !== '' ? ' - ' . $label : '')); return trim($code . ($label !== '' ? ' - ' . $label : ''));
@ -715,11 +715,11 @@ private function mapCollegamentoRole(?string $value): ?string
return match ($normalized) { return match ($normalized) {
'c', 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino', 'c', 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino',
'i', 'inquilino', 'conduttore', 'locatario' => 'inquilino', 'i', 'inquilino', 'conduttore', 'locatario' => 'inquilino',
'fornitore' => 'fornitore', 'fornitore' => 'fornitore',
'amministratore' => 'amministratore', 'amministratore' => 'amministratore',
'collaboratore' => 'collaboratore', 'collaboratore' => 'collaboratore',
'altro' => 'altro', 'altro' => 'altro',
default => null, default => null,
}; };
} }
@ -729,7 +729,7 @@ private function resolveUnitFromImportRow(int $stabileId, array $row): ?object
->where('stabile_id', $stabileId) ->where('stabile_id', $stabileId)
->select(['id as unit_id', 'codice_unita', 'denominazione', 'scala', 'interno']); ->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'] ?? '')); $interno = trim((string) ($row['interno'] ?? ''));
if ($scala !== '' || $interno !== '') { if ($scala !== '' || $interno !== '') {
@ -770,7 +770,7 @@ private function hasGesconImportCondomin(): bool
private function normalizeCollegamentoText(string $value): string private function normalizeCollegamentoText(string $value): string
{ {
$upper = mb_strtoupper(trim($value)); $upper = mb_strtoupper(trim($value));
$normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper); $normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper);
return is_string($normalized) ? $normalized : ''; return is_string($normalized) ? $normalized : '';
@ -1102,7 +1102,7 @@ protected function getHeaderActions(): array
->send(); ->send();
}), }),
Action::make('contatti') Action::make('contatti')
->label('Contatti') ->label('Modifica anagrafica')
->icon('heroicon-o-phone') ->icon('heroicon-o-phone')
->action(function (): void { ->action(function (): void {
$this->startInlineEdit(); $this->startInlineEdit();
@ -1158,7 +1158,7 @@ protected function getHeaderActions(): array
Action::make('contatti_avanzati') Action::make('contatti_avanzati')
->label('Contatti avanzati') ->label('Contatti avanzati')
->icon('heroicon-o-rectangle-stack') ->icon('heroicon-o-rectangle-stack')
->visible(fn(): bool => Schema::hasTable('rubrica_contatto_canali')) ->visible(fn(): bool => Schema::hasTable('rubrica_contatti_canali'))
->modalWidth('7xl') ->modalWidth('7xl')
->form([ ->form([
Repeater::make('canali') Repeater::make('canali')
@ -2098,6 +2098,47 @@ protected function getTitoliOptions(): array
->all(); ->all();
} }
/** @return array<string, string> */
public function getTipoContattoOptions(): array
{
return [
'persona_fisica' => 'Persona fisica',
'persona_giuridica' => 'Persona giuridica',
];
}
/** @return array<string, string> */
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<string, string> */
public function getTipoUtenzaCallOptions(): array
{
return [
'condomino' => 'Condomino',
'inquilino' => 'Inquilino',
'fornitore' => 'Fornitore',
'collaboratore' => 'Collaboratore',
'amministratore' => 'Amministratore',
'ufficio' => 'Ufficio',
'altro' => 'Altro',
];
}
/** @return array<int, string> */ /** @return array<int, string> */
public function getUnitaOptions($stabileId): array public function getUnitaOptions($stabileId): array
{ {

View File

@ -4,6 +4,7 @@
use App\Models\ChiamataPostIt; use App\Models\ChiamataPostIt;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Services\Anagrafiche\RubricaPhoneLookupService;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
@ -496,21 +497,7 @@ private function creaTicketDirettoDaForm(): void
private function findRubricaByPhone(string $value): ?RubricaUniversale private function findRubricaByPhone(string $value): ?RubricaUniversale
{ {
$normalized = PhoneNumber::normalizeForMatch($value); return app(RubricaPhoneLookupService::class)->findByPhone($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();
} }
private function createPostItRecord(): ChiamataPostIt private function createPostItRecord(): ChiamataPostIt

View File

@ -8,6 +8,7 @@
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Services\Anagrafiche\RubricaPhoneLookupService;
use BackedEnum; use BackedEnum;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -65,9 +66,12 @@ class PostItGestione extends Page
/** @var array<string, array{id:int|null,name:string|null}> */ /** @var array<string, array{id:int|null,name:string|null}> */
private array $pbxExtensionCache = []; private array $pbxExtensionCache = [];
/** @var array<int, CommunicationMessage|null> */ /** @var array<int|string, CommunicationMessage|null> */
private array $postItMessageCache = []; private array $postItMessageCache = [];
/** @var array<int,string> */
private array $monitoredResponseGroups = ['601', '603'];
public function mount(): void public function mount(): void
{ {
$focus = (int) request()->query('focus_post_it', 0); $focus = (int) request()->query('focus_post_it', 0);
@ -325,19 +329,19 @@ public function createRubricaDraftFromGroup(string $modalKey, string $mode = 'op
$rubrica = RubricaUniversale::query()->create([ $rubrica = RubricaUniversale::query()->create([
'amministratore_id' => $adminId > 0 ? $adminId : null, 'amministratore_id' => $adminId > 0 ? $adminId : null,
'nome' => $identity['nome'], 'nome' => $identity['nome'],
'cognome' => $identity['cognome'], 'cognome' => $identity['cognome'],
'ragione_sociale' => $identity['ragione_sociale'], 'ragione_sociale' => $identity['ragione_sociale'],
'tipo_contatto' => $identity['tipo_contatto'], 'tipo_contatto' => $identity['tipo_contatto'],
'telefono_cellulare' => $this->isLikelyMobilePhone($phoneDigits) ? $phoneDigits : null, 'telefono_cellulare' => $this->isLikelyMobilePhone($phoneDigits) ? $phoneDigits : null,
'telefono_ufficio' => $this->isLikelyMobilePhone($phoneDigits) ? null : $phoneDigits, 'telefono_ufficio' => $this->isLikelyMobilePhone($phoneDigits) ? null : $phoneDigits,
'categoria' => 'altro', 'categoria' => 'altro',
'stato' => 'attivo', 'stato' => 'attivo',
'data_inserimento' => now()->toDateString(), 'data_inserimento' => now()->toDateString(),
'data_ultima_modifica'=> now()->toDateString(), 'data_ultima_modifica' => now()->toDateString(),
'creato_da' => Auth::id(), 'creato_da' => Auth::id(),
'modificato_da' => Auth::id(), 'modificato_da' => Auth::id(),
'note_segreteria' => 'Bozza creata da Lavagna chiamate per gestire richiamo e identificazione del contatto.', 'note_segreteria' => 'Bozza creata da Lavagna chiamate per gestire richiamo e identificazione del contatto.',
]); ]);
$itemIds = collect($group['items']) $itemIds = collect($group['items'])
@ -686,17 +690,17 @@ private function buildTicketDescriptionFromPostIt(int $postItId, ChiamataPostIt
private function buildGroupedPostItKey(ChiamataPostIt $postIt): string private function buildGroupedPostItKey(ChiamataPostIt $postIt): string
{ {
$rubricaId = $this->resolveGroupedRubricaId($postIt);
if ($rubricaId !== null) {
return 'rubrica:' . $rubricaId;
}
$phone = $this->normalizeGroupedPhone((string) ($postIt->telefono ?? '')); $phone = $this->normalizeGroupedPhone((string) ($postIt->telefono ?? ''));
if ($phone !== '') { if ($phone !== '') {
return 'phone:' . $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 private function isInternalPostIt(ChiamataPostIt $postIt): bool
@ -734,21 +738,38 @@ private function isLegacyFilteredSmdrPostIt(ChiamataPostIt $postIt): bool
private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?CommunicationMessage private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?CommunicationMessage
{ {
$originId = (int) ($postIt->origine_id ?? 0); $originKey = trim((string) ($postIt->origine_id ?? ''));
if ($originId <= 0) { if ($originKey === '') {
return null; return null;
} }
if (array_key_exists($originId, $this->postItMessageCache)) { if (array_key_exists($originKey, $this->postItMessageCache)) {
return $this->postItMessageCache[$originId]; return $this->postItMessageCache[$originKey];
} }
$message = CommunicationMessage::query()->find($originId); $message = null;
$this->postItMessageCache[$originId] = $message; 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; 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 private function normalizeGroupedPhone(string $phone): string
{ {
$digits = preg_replace('/\D+/', '', $phone) ?: ''; $digits = preg_replace('/\D+/', '', $phone) ?: '';
@ -851,22 +872,22 @@ private function buildGroupedCollection($items)
return ($right->chiamata_il?->getTimestamp() ?? 0) <=> ($left->chiamata_il?->getTimestamp() ?? 0); 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']); $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) ->sortBy(fn(ChiamataPostIt $item) => $item->chiamata_il?->getTimestamp() ?? 0)
->first(); ->first();
$group['first_at'] = optional($oldest?->chiamata_il)->format('d/m/Y H:i') ?: '-'; $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['last_at'] = optional($group['latest']->chiamata_il)->format('d/m/Y H:i') ?: '-';
$group['caller_label'] = $this->resolveGroupedCallerLabel($group['items']); $group['caller_label'] = $this->resolveGroupedCallerLabel($group['items']);
$group['phone'] = $this->resolveGroupedPhoneLabel($group['items']); $group['phone'] = $this->resolveGroupedPhoneLabel($group['items']);
$group['rubrica_url'] = $this->getRubricaUrlByPhone((string) $group['phone']); $group['rubrica_url'] = $this->getRubricaUrlByPhone((string) $group['phone']);
$group['answered_count']= collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isAnsweredPostIt($item))->count(); $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['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['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['primary_action_label'] = $group['answered_count'] > 0 ? 'Apri Post-it' : 'Apri richiamo';
$group['visible_items'] = collect($group['items'])->take(4)->values()->all(); $group['visible_items'] = collect($group['items'])->take(4)->values()->all();
$group['hidden_count'] = max(0, count($group['items']) - count($group['visible_items'])); $group['hidden_count'] = max(0, count($group['items']) - count($group['visible_items']));
return $group; return $group;
}) })
@ -1024,25 +1045,8 @@ private function resolveRubricaIdByPhone(?string $phone): ?int
} }
$adminId = (int) (Auth::user()?->amministratore?->id ?? 0); $adminId = (int) (Auth::user()?->amministratore?->id ?? 0);
$result = app(RubricaPhoneLookupService::class)->findByPhone($digits, $adminId)?->id;
$query = RubricaUniversale::query(); $result = is_numeric($result) ? (int) $result : null;
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;
$this->rubricaPhoneCache[$digits] = $result; $this->rubricaPhoneCache[$digits] = $result;
return $result; return $result;
@ -1095,6 +1099,10 @@ private function normalizePostItOutcome(?string $outcome): string
public function isAnsweredPostIt(ChiamataPostIt $postIt): bool public function isAnsweredPostIt(ChiamataPostIt $postIt): bool
{ {
if ($this->isMissedResponseGroupPostIt($postIt)) {
return false;
}
$outcome = $this->normalizePostItOutcome($postIt->esito); $outcome = $this->normalizePostItOutcome($postIt->esito);
if (($postIt->durata_secondi ?? 0) > 0) { if (($postIt->durata_secondi ?? 0) > 0) {
@ -1106,6 +1114,10 @@ public function isAnsweredPostIt(ChiamataPostIt $postIt): bool
private function isMissedPostIt(ChiamataPostIt $postIt): bool private function isMissedPostIt(ChiamataPostIt $postIt): bool
{ {
if ($this->isMissedResponseGroupPostIt($postIt)) {
return true;
}
$outcome = $this->normalizePostItOutcome($postIt->esito); $outcome = $this->normalizePostItOutcome($postIt->esito);
if (in_array($outcome, ['persa', 'missed', 'no_answer', 'unanswered', 'non_risposta'], true)) { 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; 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 public function getPostItCallStateMeta(ChiamataPostIt $postIt): array
{ {
if ($this->isMissedPostIt($postIt)) { if ($this->isMissedPostIt($postIt)) {

View File

@ -834,7 +834,7 @@ public function hydrateNominativiStorici(): void
})->all(); })->all();
} }
private function hydrateRecapitiServizio(): void public function hydrateRecapitiServizio(): void
{ {
$this->recapitiServizio = []; $this->recapitiServizio = [];

View File

@ -6,6 +6,7 @@
use App\Models\CommunicationMessage; use App\Models\CommunicationMessage;
use App\Models\PbxClickToCallRequest; use App\Models\PbxClickToCallRequest;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Services\Anagrafiche\RubricaPhoneLookupService;
use App\Services\Cti\PbxRoutingService; use App\Services\Cti\PbxRoutingService;
use App\Support\PhoneNumber; use App\Support\PhoneNumber;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
@ -491,25 +492,7 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array
private function findRubricaByPhone(string $phone): ?RubricaUniversale private function findRubricaByPhone(string $phone): ?RubricaUniversale
{ {
$digits = PhoneNumber::normalizeDigits($phone); return app(RubricaPhoneLookupService::class)->findByPhone($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();
} }
private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt

View File

@ -0,0 +1,91 @@
<?php
namespace App\Services\Anagrafiche;
use App\Models\RubricaUniversale;
use App\Support\PhoneNumber;
use Illuminate\Support\Facades\Schema;
class RubricaPhoneLookupService
{
public function findByPhone(?string $phone, ?int $adminId = null): ?RubricaUniversale
{
$digits = PhoneNumber::normalizeDigits((string) $phone);
if ($digits === '') {
return null;
}
$tail = strlen($digits) >= 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}, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '')";
}
}

View File

@ -6,6 +6,7 @@
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Models\Soggetto; use App\Models\Soggetto;
use App\Models\User; use App\Models\User;
use App\Services\Anagrafiche\RubricaPhoneLookupService;
class LiveIncomingCallService class LiveIncomingCallService
{ {
@ -43,20 +44,20 @@ public function getForUser(User $user): ?array
$latest = $candidates $latest = $candidates
->sortByDesc(function (CommunicationMessage $message): int { ->sortByDesc(function (CommunicationMessage $message): int {
$metadata = is_array($message->metadata) ? $message->metadata : []; $metadata = is_array($message->metadata) ? $message->metadata : [];
$eventType = strtolower(trim((string) ($metadata['event_type'] ?? ''))); $eventType = strtolower(trim((string) ($metadata['event_type'] ?? '')));
$endedAt = trim((string) ($metadata['ended_at'] ?? '')); $endedAt = trim((string) ($metadata['ended_at'] ?? ''));
$isActiveCsta = (string) $message->channel === 'panasonic_csta' $isActiveCsta = (string) $message->channel === 'panasonic_csta'
&& (string) ($message->status ?? '') !== 'completed' && (string) ($message->status ?? '') !== 'completed'
&& $endedAt === '' && $endedAt === ''
&& ! in_array($eventType, ['connectioncleared', 'cleared', 'released'], true); && ! in_array($eventType, ['connectioncleared', 'cleared', 'released'], true);
if ($isActiveCsta) { if ($isActiveCsta) {
return 5000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); return 5000000000 + (int) ($message->received_at?->getTimestamp() ?? 0);
} }
$isRecentCsta = (string) $message->channel === 'panasonic_csta' $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) { if ($isRecentCsta) {
return 4000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); return 4000000000 + (int) ($message->received_at?->getTimestamp() ?? 0);
@ -119,25 +120,7 @@ private function normalizePhoneDigits(string $value): string
private function matchRubricaByPhone(string $digits): ?RubricaUniversale private function matchRubricaByPhone(string $digits): ?RubricaUniversale
{ {
if ($digits === '') { return app(RubricaPhoneLookupService::class)->findByPhone($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();
} }
private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto

View File

@ -51,6 +51,83 @@ ## Finestra temporale smoke test
Per il primo passaggio operativo non importare tutto lo storico. 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=<codice> --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 ### Import base
- anno corrente legacy piu recente disponibile - anno corrente legacy piu recente disponibile
@ -122,6 +199,9 @@ ### E. QA dominio post-import
- stabile creato/aggiornato correttamente - stabile creato/aggiornato correttamente
- unita importate - unita importate
- rubrica/nominativi deduplicati - 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 - rate/incassi coerenti per gli anni caricati
- nessun crash nelle pagine operative principali - nessun crash nelle pagine operative principali

View File

@ -6,6 +6,11 @@ # Modifiche NetGescon
| Data | Versione | Area | Descrizione | Tipo | | 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 | 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 | 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] | | 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] |

View File

@ -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). - Usa solo **/home/michele/netgescon/netgescon-laravel** per i comandi (niente /var/www/netgescon).
- Carica lo staging `gescon_import.condomin` con mdb-tools: - 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à: - 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 limport unità): - Prosegui con soggetti, diritti, millesimi (dopo limport 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=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=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=millesimi`
- Se devi ricalcolare piano/scala: prima pulisci `unita_immobiliari` (e `unita_pertinenze`) per quello stabile, poi riesegui i due comandi sopra. - 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) ### Esito corrente (stabile 0021 / anno 0003 in DEV)
- `load-mdb condomin`: 268 righe staging - `load-mdb condomin`: 268 righe staging
- `import-full --solo=unita`: 212 record - `import-full --solo=unita`: 212 record
- `import-full --solo=soggetti`: 55 record - `import-full --solo=soggetti`: 55 record
@ -20,6 +31,67 @@ ### 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. Ripeti la stessa sequenza per gli altri anni/cartelle cambiando `--stabile` e `--anno` e il path del singolo_anno.mdb.
### Verifica reale aggiornata (stabile 0021 / snapshot corrente 0004)
- `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
### Bonifica recapiti rubrica 08/04/2026
- nuovo comando operativo: `php artisan gescon:bonifica-rubrica-canali --stabile=<codice> --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 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 il secondo file è /home/michele/netgescon/docs/02-architettura-laravel/Gescon/dbc/Stabili.mdb qui trovi le indicazioni di tutti gli stabili
@ -28,8 +100,6 @@ ### Esito corrente (stabile 0021 / anno 0003 in DEV)
/home/michele/netgescon/docs/02-architettura-laravel/Gescon/0021/generale_stabile_sf.mdb per le info generali /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 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 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 cartella che devi considerare per l'importazione è la mappatura dei campi per la successiava automazione del sistema d'importazione è in docs 000-IMPORT
@ -37,5 +107,4 @@ ### Esito corrente (stabile 0021 / anno 0003 in DEV)
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. 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 Ci sono alcuni campi che dobbiamo tenre in considerazione per l'im portazione primo va c

View File

@ -1,6 +1,37 @@
@php @php
$user = \Illuminate\Support\Facades\Auth::user(); $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 { $stabileLabel = static function (?\App\Models\Stabile $stabile): string {
if (! $stabile) { if (! $stabile) {
return 'Seleziona stabile'; return 'Seleziona stabile';
@ -32,7 +63,7 @@
$labelGestione = $gestioni[$gestioneAttiva] ?? (string) $gestioneAttiva; $labelGestione = $gestioni[$gestioneAttiva] ?? (string) $gestioneAttiva;
$haRiscaldamento = (bool) ($stabileAttivo?->riscaldamento_centralizzato ?? false); $haRiscaldamento = $stabileHasRiscaldamento($stabileAttivo);
@endphp @endphp
<div class="flex min-w-0 items-center gap-2 ps-3"> <div class="flex min-w-0 items-center gap-2 ps-3">

View File

@ -2,7 +2,7 @@
@if($isInlineEditing) @if($isInlineEditing)
<x-filament::section> <x-filament::section>
<x-slot name="heading">Modifica Contatto Inline</x-slot> <x-slot name="heading">Modifica Contatto Inline</x-slot>
<x-slot name="description">Modifica i campi direttamente in pagina, senza modal.</x-slot> <x-slot name="description">Modifica i campi principali senza lasciare aperti i pannelli laterali. Per numeri, email e PEC multiple usa i contatti avanzati.</x-slot>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3"> <div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<label class="text-sm"> <label class="text-sm">
@ -20,11 +20,21 @@
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-gray-600">Tipo contatto</span> <span class="mb-1 block text-gray-600">Tipo contatto</span>
<input type="text" wire:model.defer="inlineForm.tipo_contatto" class="w-full rounded-md border-gray-300 text-sm" /> <select wire:model.defer="inlineForm.tipo_contatto" class="w-full rounded-md border-gray-300 text-sm">
<option value="">Seleziona</option>
@foreach($this->getTipoContattoOptions() as $optionValue => $optionLabel)
<option value="{{ $optionValue }}">{{ $optionLabel }}</option>
@endforeach
</select>
</label> </label>
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-gray-600">Categoria</span> <span class="mb-1 block text-gray-600">Categoria</span>
<input type="text" wire:model.defer="inlineForm.categoria" class="w-full rounded-md border-gray-300 text-sm" /> <select wire:model.defer="inlineForm.categoria" class="w-full rounded-md border-gray-300 text-sm">
<option value="">Seleziona</option>
@foreach($this->getCategoriaOptions() as $optionValue => $optionLabel)
<option value="{{ $optionValue }}">{{ $optionLabel }}</option>
@endforeach
</select>
</label> </label>
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-gray-600">Codice fiscale</span> <span class="mb-1 block text-gray-600">Codice fiscale</span>
@ -105,7 +115,12 @@
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-gray-600">Profilo chiamante</span> <span class="mb-1 block text-gray-600">Profilo chiamante</span>
<input type="text" wire:model.defer="inlineForm.tipo_utenza_call" class="w-full rounded-md border-gray-300 text-sm" /> <select wire:model.defer="inlineForm.tipo_utenza_call" class="w-full rounded-md border-gray-300 text-sm">
<option value="">Seleziona</option>
@foreach($this->getTipoUtenzaCallOptions() as $optionValue => $optionLabel)
<option value="{{ $optionValue }}">{{ $optionLabel }}</option>
@endforeach
</select>
</label> </label>
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-gray-600">Rif. stabile</span> <span class="mb-1 block text-gray-600">Rif. stabile</span>
@ -128,10 +143,12 @@
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<x-filament::button type="button" wire:click="saveInlineEdit">Salva modifiche</x-filament::button> <x-filament::button type="button" wire:click="saveInlineEdit">Salva modifiche</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="mountAction('contatti_avanzati')">Contatti avanzati</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="mountAction('email_multiple')">Email aggiuntive</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="cancelInlineEdit">Annulla</x-filament::button> <x-filament::button type="button" color="gray" wire:click="cancelInlineEdit">Annulla</x-filament::button>
</div> </div>
</x-filament::section> </x-filament::section>
@endif @else
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@ -673,7 +690,15 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<x-filament::button type="button" color="gray" wire:click="mountAction('contatti')"> <x-filament::button type="button" color="gray" wire:click="mountAction('contatti')">
Contatti aggiuntivi Modifica anagrafica inline
</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="mountAction('contatti_avanzati')">
Numeri, email e PEC multiple
</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="mountAction('email_multiple')">
Email aggiuntive persona
</x-filament::button> </x-filament::button>
<x-filament::button type="button" color="gray" wire:click="mountAction('ruoli_legami')"> <x-filament::button type="button" color="gray" wire:click="mountAction('ruoli_legami')">
@ -746,4 +771,5 @@
</div> </div>
@endif @endif
</x-filament::section> </x-filament::section>
@endif
</x-filament-panels::page> </x-filament-panels::page>