fix: stabilizza ticket mobile e bonifica nominativi

This commit is contained in:
michele 2026-04-06 21:48:20 +00:00
parent 372ed81bdd
commit 0820e06772
4 changed files with 474 additions and 152 deletions

View File

@ -0,0 +1,270 @@
<?php
namespace App\Console\Commands;
use App\Models\RubricaUniversale;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class GesconRepairNominativiContactsCommand extends Command
{
protected $signature = 'gescon:repair-nominativi-contacts
{--stabile= : Codice stabile da bonificare}
{--limit=5000 : Max persone da analizzare}
{--apply : Applica realmente le modifiche}';
protected $description = 'Bonifica persone/rubrica dei nominativi materializzati adottando telefoni e recapiti dai duplicati rubrica piu ricchi.';
public function handle(): int
{
if (! Schema::hasTable('persone') || ! Schema::hasTable('persone_unita_relazioni') || ! Schema::hasTable('unita_immobiliari') || ! Schema::hasTable('stabili') || ! Schema::hasTable('rubrica_universale')) {
$this->error('Tabelle richieste non disponibili.');
return self::FAILURE;
}
$apply = (bool) $this->option('apply');
$limit = max(1, min((int) ($this->option('limit') ?? 5000), 50000));
$stabile = trim((string) ($this->option('stabile') ?? ''));
$query = DB::table('persone_unita_relazioni as pur')
->join('persone as p', 'p.id', '=', 'pur.persona_id')
->join('unita_immobiliari as ui', 'ui.id', '=', 'pur.unita_id')
->join('stabili as s', 's.id', '=', 'ui.stabile_id')
->select([
'p.id as persona_id',
'p.nome',
'p.cognome',
'p.ragione_sociale',
'p.telefono_principale',
'p.telefono_secondario',
'p.email_principale',
'p.email_pec',
'p.note',
's.id as stabile_id',
's.codice_stabile',
's.amministratore_id',
])
->distinct()
->orderBy('s.codice_stabile')
->orderBy('p.id')
->limit($limit);
if ($stabile !== '') {
$trimmed = ltrim($stabile, '0');
$query->where(function ($builder) use ($stabile, $trimmed): void {
$builder->where('s.codice_stabile', $stabile);
if ($trimmed !== '' && $trimmed !== $stabile) {
$builder->orWhere('s.codice_stabile', $trimmed);
}
});
}
$rows = $query->get();
if ($rows->isEmpty()) {
$this->info('Nessuna persona da bonificare.');
return self::SUCCESS;
}
$stats = [
'processed' => 0,
'persona_updated' => 0,
'rubrica_updated' => 0,
'no_match' => 0,
'phone_conflicts' => 0,
];
foreach ($rows as $row) {
$stats['processed']++;
$identityLabel = trim((string) ($row->ragione_sociale ?: trim(($row->nome ?? '') . ' ' . ($row->cognome ?? ''))));
$tokenKey = $this->normalizeTokenSetKey($identityLabel);
if ($tokenKey === null) {
$stats['no_match']++;
continue;
}
$rubriche = RubricaUniversale::query()
->when((int) ($row->amministratore_id ?? 0) > 0, fn($builder) => $builder->where(function ($query) use ($row): void {
$query->where('amministratore_id', (int) $row->amministratore_id)
->orWhereNull('amministratore_id');
}))
->get()
->filter(fn(RubricaUniversale $rubrica): bool => $this->normalizeTokenSetKey($this->buildRubricaIdentityLabel($rubrica)) === $tokenKey)
->sortByDesc(fn(RubricaUniversale $rubrica): int => $this->scoreRubrica($rubrica))
->values();
if ($rubriche->isEmpty()) {
$stats['no_match']++;
continue;
}
/** @var RubricaUniversale $best */
$best = $rubriche->first();
$canonicalRubricaId = $this->extractRubricaIdFromPersonaNote((string) ($row->note ?? ''));
$canonicalRubrica = $canonicalRubricaId > 0 ? RubricaUniversale::query()->find($canonicalRubricaId) : null;
if (! $canonicalRubrica instanceof RubricaUniversale) {
$canonicalRubrica = $best;
}
$personaUpdates = $this->buildPersonaUpdates($row, $best, $stats);
$rubricaUpdates = $this->buildRubricaUpdates($canonicalRubrica, $best);
if ($apply && $personaUpdates !== []) {
try {
DB::table('persone')->where('id', (int) $row->persona_id)->update($personaUpdates + ['updated_at' => now()]);
$stats['persona_updated']++;
} catch (\Throwable $e) {
$this->warn('Skip persona #' . (int) $row->persona_id . ': ' . $e->getMessage());
}
} elseif (! $apply && $personaUpdates !== []) {
$stats['persona_updated']++;
}
if ($apply && $rubricaUpdates !== []) {
$canonicalRubrica->fill($rubricaUpdates);
$canonicalRubrica->save();
$stats['rubrica_updated']++;
} elseif (! $apply && $rubricaUpdates !== []) {
$stats['rubrica_updated']++;
}
foreach ($rubriche as $candidateRubrica) {
if (! $candidateRubrica instanceof RubricaUniversale || (int) $candidateRubrica->id === (int) $best->id) {
continue;
}
$candidateUpdates = $this->buildRubricaUpdates($candidateRubrica, $best);
if ($apply && $candidateUpdates !== []) {
$candidateRubrica->fill($candidateUpdates);
$candidateRubrica->save();
$stats['rubrica_updated']++;
} elseif (! $apply && $candidateUpdates !== []) {
$stats['rubrica_updated']++;
}
}
}
$mode = $apply ? 'Bonifica eseguita' : 'Dry-run bonifica';
$this->info($mode . ': ' . json_encode($stats, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
private function buildRubricaIdentityLabel(RubricaUniversale $rubrica): string
{
return trim(implode(' ', array_filter([
(string) ($rubrica->nome ?? ''),
(string) ($rubrica->cognome ?? ''),
(string) ($rubrica->ragione_sociale ?? ''),
])));
}
private function normalizeTokenSetKey(?string $value): ?string
{
$tokens = preg_split('/\s+/', Str::upper(trim((string) $value))) ?: [];
$tokens = array_values(array_filter(array_map(function (string $token): string {
return (string) preg_replace('/[^A-Z0-9]+/u', '', $token);
}, $tokens)));
if ($tokens === []) {
return null;
}
sort($tokens, SORT_STRING);
return implode('|', $tokens);
}
private function normalizePhone(?string $value): ?string
{
$normalized = preg_replace('/[^\d+]/', '', (string) ($value ?? ''));
return is_string($normalized) && $normalized !== '' ? $normalized : null;
}
private function normalizeEmail(?string $value): ?string
{
$normalized = strtolower(trim((string) ($value ?? '')));
$normalized = preg_replace('/\s+/', '', $normalized);
return is_string($normalized) && $normalized !== '' ? $normalized : null;
}
private function scoreRubrica(RubricaUniversale $rubrica): int
{
$score = 0;
foreach (['telefono_cellulare', 'telefono_ufficio', 'telefono_casa', 'email', 'pec', 'codice_fiscale', 'partita_iva', 'riferimento_stabile', 'riferimento_unita'] as $field) {
if (filled($rubrica->{$field})) {
$score += in_array($field, ['telefono_cellulare', 'email', 'codice_fiscale', 'partita_iva'], true) ? 10 : 4;
}
}
return $score;
}
private function extractRubricaIdFromPersonaNote(string $note): int
{
if (preg_match('/rubrica_id=(\d+)/', $note, $matches)) {
return (int) ($matches[1] ?? 0);
}
return 0;
}
/**
* @param object $row
* @return array<string, mixed>
*/
private function buildPersonaUpdates(object $row, RubricaUniversale $best, array &$stats): array
{
$updates = [];
$bestMobile = $this->normalizePhone((string) ($best->telefono_cellulare ?: $best->telefono_ufficio ?: $best->telefono_casa));
$bestOffice = $this->normalizePhone((string) ($best->telefono_ufficio ?: $best->telefono_casa));
$bestEmail = $this->normalizeEmail((string) ($best->email ?? ''));
$bestPec = $this->normalizeEmail((string) ($best->pec ?? ''));
if (! filled($row->telefono_principale) && $bestMobile !== null && ! $this->phoneExistsOnOtherPersona($bestMobile, (int) $row->persona_id)) {
$updates['telefono_principale'] = $bestMobile;
} elseif (! filled($row->telefono_principale) && $bestMobile !== null) {
$stats['phone_conflicts']++;
}
if (! filled($row->telefono_secondario) && $bestOffice !== null && $bestOffice !== ($updates['telefono_principale'] ?? $row->telefono_principale)) {
$updates['telefono_secondario'] = $bestOffice;
}
if (! filled($row->email_principale) && $bestEmail !== null) {
$updates['email_principale'] = $bestEmail;
}
if (! filled($row->email_pec) && $bestPec !== null) {
$updates['email_pec'] = $bestPec;
}
return $updates;
}
private function phoneExistsOnOtherPersona(string $phone, int $personaId): bool
{
return DB::table('persone')
->where('telefono_principale', $phone)
->where('id', '!=', $personaId)
->exists();
}
/**
* @return array<string, mixed>
*/
private function buildRubricaUpdates(RubricaUniversale $canonical, RubricaUniversale $best): array
{
$updates = [];
foreach (['telefono_cellulare', 'telefono_ufficio', 'telefono_casa', 'email', 'pec', 'codice_fiscale', 'partita_iva', 'prefisso_cellulare', 'telefono_cellulare_nazionale'] as $field) {
if (! filled($canonical->{$field}) && filled($best->{$field})) {
$updates[$field] = $best->{$field};
}
}
return $updates;
}
}

View File

@ -115,6 +115,8 @@ class TicketInterventoScheda extends Page
/** @var array<string, mixed>|null */
public ?array $googleWorkspaceStatus = null;
public bool $sessionTrackingAvailable = false;
public static function canAccess(): bool
{
$user = Auth::user();
@ -209,6 +211,11 @@ public function startIntervento(): void
public function startInterventoWithGeo(array $geo = []): void
{
if (! $this->sessionTrackingAvailable) {
Notification::make()->title('Sessioni intervento non disponibili')->body('Esegui prima la migrazione del database per attivare start/stop e checkpoint avanzati.')->warning()->send();
return;
}
$activeSession = $this->getActiveSession();
if ($activeSession instanceof TicketInterventoSessione) {
Notification::make()->title('Esiste gia una sessione aperta')->body('Ferma prima la sessione attiva o registra un checkpoint.')->warning()->send();
@ -256,6 +263,11 @@ public function stopIntervento(): void
public function stopInterventoWithGeo(array $geo = []): void
{
if (! $this->sessionTrackingAvailable) {
Notification::make()->title('Sessioni intervento non disponibili')->body('Esegui prima la migrazione del database per attivare start/stop e checkpoint avanzati.')->warning()->send();
return;
}
$activeSession = $this->getActiveSession();
if (! $activeSession instanceof TicketInterventoSessione) {
Notification::make()->title('Avvia prima il timer di intervento')->warning()->send();
@ -293,6 +305,11 @@ public function stopInterventoWithGeo(array $geo = []): void
public function addPresenceCheckpoint(array $geo = []): void
{
if (! $this->sessionTrackingAvailable) {
Notification::make()->title('Sessioni intervento non disponibili')->body('Esegui prima la migrazione del database per attivare start/stop e checkpoint avanzati.')->warning()->send();
return;
}
$geoData = $this->normalizeGeoPayload($geo);
$stamp = now();
@ -349,8 +366,7 @@ public function addSuggestedMaterial(int $productId): void
}
$identifier = $product->identifiers
->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id)
?? $product->identifiers->first();
->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id) ?? $product->identifiers->first();
$offer = $product->offers->first();
@ -771,7 +787,9 @@ protected function buildStoredUploadDisplayName(int $index, bool $isImage, strin
protected function reloadIntervento(): void
{
$this->intervento->load([
$this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni');
$relations = [
'ticket.stabile',
'ticket.unitaImmobiliare',
'ticket.soggettoRichiedente',
@ -781,22 +799,16 @@ protected function reloadIntervento(): void
'ticket.assegnatoAUser',
'ticket.assegnatoAFornitore',
'eseguitoDaDipendente',
'sessioni',
]);
];
if ($this->sessionTrackingAvailable) {
$relations[] = 'sessioni';
}
$this->intervento->load($relations);
$this->intervento->refresh();
$this->intervento->load([
'ticket.stabile',
'ticket.unitaImmobiliare',
'ticket.soggettoRichiedente',
'ticket.messages.user',
'ticket.attachments.user',
'ticket.apertoDaUser',
'ticket.assegnatoAUser',
'ticket.assegnatoAFornitore',
'eseguitoDaDipendente',
'sessioni',
]);
$this->intervento->load($relations);
$this->caller = $this->resolveCallerData($this->intervento->ticket);
$this->storicoRows = TicketIntervento::query()
@ -850,7 +862,9 @@ protected function reloadIntervento(): void
])
->values()
->all();
$this->sessionRows = $this->intervento->sessioni
$sessionCollection = $this->sessionTrackingAvailable ? $this->intervento->sessioni : collect();
$this->sessionRows = $sessionCollection
->sortByDesc(fn(TicketInterventoSessione $sessione) => $sessione->started_at?->getTimestamp() ?? 0)
->values()
->map(function (TicketInterventoSessione $sessione): array {
@ -866,7 +880,7 @@ protected function reloadIntervento(): void
];
})
->all();
$latestSession = $this->intervento->sessioni
$latestSession = $sessionCollection
->sortByDesc(fn(TicketInterventoSessione $sessione) => $sessione->updated_at?->getTimestamp() ?? 0)
->first();
$this->lastGeoSnapshot = $latestSession instanceof TicketInterventoSessione
@ -883,6 +897,10 @@ protected function reloadIntervento(): void
protected function getActiveSession(): ?TicketInterventoSessione
{
if (! $this->sessionTrackingAvailable) {
return null;
}
return TicketInterventoSessione::query()
->where('ticket_intervento_id', (int) $this->intervento->id)
->where('session_type', 'work')
@ -893,6 +911,10 @@ protected function getActiveSession(): ?TicketInterventoSessione
protected function calculateTrackedMinutes(): ?int
{
if (! $this->sessionTrackingAvailable) {
return $this->intervento->tempo_minuti ? (int) $this->intervento->tempo_minuti : null;
}
$total = (int) TicketInterventoSessione::query()
->where('ticket_intervento_id', (int) $this->intervento->id)
->sum('duration_minutes');
@ -996,11 +1018,9 @@ protected function refreshMaterialSuggestions(): void
$this->materialSuggestions = $products->map(function (Product $product): array {
$identifier = $product->identifiers
->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id)
?? $product->identifiers->first();
->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id) ?? $product->identifiers->first();
$offer = $product->offers
->first(fn(ProductOffer $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id)
?? $product->offers->first();
->first(fn(ProductOffer $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id) ?? $product->offers->first();
return [
'id' => (int) $product->id,

View File

@ -795,13 +795,15 @@ private function resolveAttachmentDescription(int $index, array $metadata = [],
$parts[] = 'Allegato da Ticket Mobile';
}
if (filled($displayName)) {
$parts[] = 'File: ' . trim((string) $displayName);
}
$lat = $metadata['gps_decimal']['lat'] ?? null;
$lng = $metadata['gps_decimal']['lng'] ?? null;
if (is_numeric($lat) && is_numeric($lng)) {
$parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', '');
$sourceName = trim((string) $sourceOriginalName);
if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) {
$parts[] = 'Orig: ' . $sourceName;
$mapsUrl = trim((string) ($metadata['google_maps_url'] ?? ''));
if ($mapsUrl !== '') {
$parts[] = 'Maps: ' . $mapsUrl;
}
}
$exifDate = trim((string) ($metadata['exif_datetime'] ?? ''));
@ -814,15 +816,13 @@ private function resolveAttachmentDescription(int $index, array $metadata = [],
$parts[] = 'Device: ' . $device;
}
$lat = $metadata['gps_decimal']['lat'] ?? null;
$lng = $metadata['gps_decimal']['lng'] ?? null;
if (is_numeric($lat) && is_numeric($lng)) {
$parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', '');
$mapsUrl = trim((string) ($metadata['google_maps_url'] ?? ''));
if ($mapsUrl !== '') {
$parts[] = 'Maps: ' . $mapsUrl;
if (filled($displayName)) {
$parts[] = 'File: ' . trim((string) $displayName);
}
$sourceName = trim((string) $sourceOriginalName);
if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) {
$parts[] = 'Orig: ' . $sourceName;
}
return Str::limit(implode(' | ', array_filter($parts)), 255);

View File

@ -256,6 +256,17 @@
draftSequence: {{ (int) $newTicketDraftSequence }},
localCameraPreviews: [],
localAttachmentPreviews: [],
init() {
window.__ticketMobilePreviewState = window.__ticketMobilePreviewState || { camera: [], attachment: [] };
this.localCameraPreviews = [...(window.__ticketMobilePreviewState.camera || [])];
this.localAttachmentPreviews = [...(window.__ticketMobilePreviewState.attachment || [])];
},
persist() {
window.__ticketMobilePreviewState = {
camera: [...this.localCameraPreviews],
attachment: [...this.localAttachmentPreviews],
};
},
revoke(items) {
items.forEach((item) => {
if (item.url) {
@ -285,6 +296,7 @@
const target = kind === 'camera' ? 'localCameraPreviews' : 'localAttachmentPreviews';
this.revoke(this[target]);
this[target] = this.mapFiles(event?.target?.files || [], kind);
this.persist();
},
clearPreviews(kind = null) {
if (kind === null || kind === 'camera') {
@ -295,6 +307,13 @@
this.revoke(this.localAttachmentPreviews);
this.localAttachmentPreviews = [];
}
this.persist();
},
previewFor(draftName, originalName) {
const pool = [...this.localCameraPreviews, ...this.localAttachmentPreviews];
const match = pool.find((item) => item.draftName === draftName || item.name === originalName);
return match?.url || null;
},
formatSize(bytes) {
if (!bytes) {
@ -392,12 +411,25 @@
@if(count($this->selectedUploads) > 0)
<div class="md:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Coda allegati pronta per il ticket</div>
<div class="mb-2 text-[11px] text-gray-500">Le immagini locali restano visibili fino all'invio del ticket. La rinomina definitiva viene applicata solo al momento del salvataggio.</div>
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if($upload['is_image'] && !empty($upload['preview_url']))
<img src="{{ $upload['preview_url'] }}" alt="{{ $upload['name'] }}" class="h-full w-full rounded object-cover" />
@elseif($upload['is_image'])
<template x-if="previewFor(@js($upload['name']), @js($upload['original_name']))">
<img :src="previewFor(@js($upload['name']), @js($upload['original_name']))" alt="{{ $upload['name'] }}" class="h-full w-full rounded object-cover" />
</template>
<template x-if="!previewFor(@js($upload['name']), @js($upload['original_name']))">
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">IMG</div>
<div class="mt-1 text-[11px]">Anteprima locale in attesa</div>
</div>
</div>
</template>
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>