From 0820e06772041588788e659fe0bd81eae1323499 Mon Sep 17 00:00:00 2001 From: michele Date: Mon, 6 Apr 2026 21:48:20 +0000 Subject: [PATCH] fix: stabilizza ticket mobile e bonifica nominativi --- .../GesconRepairNominativiContactsCommand.php | 270 ++++++++++++++++ .../Fornitore/TicketInterventoScheda.php | 292 ++++++++++-------- app/Filament/Pages/Supporto/TicketMobile.php | 32 +- .../pages/supporto/ticket-mobile.blade.php | 32 ++ 4 files changed, 474 insertions(+), 152 deletions(-) create mode 100644 app/Console/Commands/GesconRepairNominativiContactsCommand.php diff --git a/app/Console/Commands/GesconRepairNominativiContactsCommand.php b/app/Console/Commands/GesconRepairNominativiContactsCommand.php new file mode 100644 index 0000000..ff7d796 --- /dev/null +++ b/app/Console/Commands/GesconRepairNominativiContactsCommand.php @@ -0,0 +1,270 @@ +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 + */ + 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 + */ + 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; + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php index f187a2c..e0945fe 100644 --- a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php +++ b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php @@ -115,6 +115,8 @@ class TicketInterventoScheda extends Page /** @var array|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(); @@ -219,19 +226,19 @@ public function startInterventoWithGeo(array $geo = []): void $geoData = $this->normalizeGeoPayload($geo); TicketInterventoSessione::query()->create([ - 'ticket_intervento_id' => (int) $this->intervento->id, - 'ticket_id' => (int) $this->intervento->ticket_id, - 'fornitore_id' => (int) $this->fornitore->id, - 'fornitore_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, - 'created_by_user_id' => Auth::id(), - 'session_type' => 'work', - 'note' => 'Avvio intervento', - 'started_at' => $startedAt, - 'start_latitude' => $geoData['lat'], - 'start_longitude' => $geoData['lng'], - 'start_accuracy_meters' => $geoData['accuracy'], - 'start_source' => $geoData['source'], - 'geo_payload' => $geoData['payload'], + 'ticket_intervento_id' => (int) $this->intervento->id, + 'ticket_id' => (int) $this->intervento->ticket_id, + 'fornitore_id' => (int) $this->fornitore->id, + 'fornitore_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, + 'created_by_user_id' => Auth::id(), + 'session_type' => 'work', + 'note' => 'Avvio intervento', + 'started_at' => $startedAt, + 'start_latitude' => $geoData['lat'], + 'start_longitude' => $geoData['lng'], + 'start_accuracy_meters' => $geoData['accuracy'], + 'start_source' => $geoData['source'], + 'geo_payload' => $geoData['payload'], ]); $this->intervento->update([ @@ -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,21 +366,20 @@ 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(); $this->materialiUtilizzati[] = [ - 'product_id' => (int) $product->id, - 'descrizione' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), - 'barcode' => (string) ($identifier->code_value ?? ''), - 'qty' => 1, - 'unit_price' => $offer?->price_amount !== null ? (float) $offer->price_amount : null, - 'currency' => (string) ($offer->currency ?? 'EUR'), - 'source_type' => 'catalog', - 'source_label' => (string) ($offer?->source_name ?: 'Catalogo fornitore'), - 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), + 'product_id' => (int) $product->id, + 'descrizione' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), + 'barcode' => (string) ($identifier->code_value ?? ''), + 'qty' => 1, + 'unit_price' => $offer?->price_amount !== null ? (float) $offer->price_amount : null, + 'currency' => (string) ($offer->currency ?? 'EUR'), + 'source_type' => 'catalog', + 'source_label' => (string) ($offer?->source_name ?: 'Catalogo fornitore'), + 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), ]; $this->materialSearch = ''; @@ -379,15 +395,15 @@ public function addManualMaterial(): void } $this->materialiUtilizzati[] = [ - 'product_id' => null, - 'descrizione' => $term, - 'barcode' => '', - 'qty' => 1, - 'unit_price' => null, - 'currency' => 'EUR', - 'source_type' => 'manual', - 'source_label' => 'Inserimento manuale', - 'external_url' => $this->buildAmazonSearchUrl($term), + 'product_id' => null, + 'descrizione' => $term, + 'barcode' => '', + 'qty' => 1, + 'unit_price' => null, + 'currency' => 'EUR', + 'source_type' => 'manual', + 'source_label' => 'Inserimento manuale', + 'external_url' => $this->buildAmazonSearchUrl($term), ]; $this->materialSearch = ''; @@ -403,35 +419,35 @@ public function removeMateriale(int $index): void public function saveRapporto(): void { $validated = $this->validate([ - 'rapportoFornitore' => ['required', 'string', 'max:5000'], - 'tempoMinuti' => ['nullable', 'integer', 'min:1', 'max:1440'], - 'articoliUtilizzati' => ['nullable', 'array'], - 'articoliUtilizzati.*' => ['nullable', 'string', 'max:120'], - 'materialiUtilizzati' => ['nullable', 'array'], - 'materialiUtilizzati.*.product_id' => ['nullable', 'integer'], - 'materialiUtilizzati.*.descrizione' => ['required', 'string', 'max:255'], - 'materialiUtilizzati.*.barcode' => ['nullable', 'string', 'max:120'], - 'materialiUtilizzati.*.qty' => ['nullable', 'numeric', 'min:0.01', 'max:9999'], - 'materialiUtilizzati.*.unit_price' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], - 'materialiUtilizzati.*.currency' => ['nullable', 'string', 'max:8'], - 'materialiUtilizzati.*.source_type' => ['nullable', 'string', 'max:32'], + 'rapportoFornitore' => ['required', 'string', 'max:5000'], + 'tempoMinuti' => ['nullable', 'integer', 'min:1', 'max:1440'], + 'articoliUtilizzati' => ['nullable', 'array'], + 'articoliUtilizzati.*' => ['nullable', 'string', 'max:120'], + 'materialiUtilizzati' => ['nullable', 'array'], + 'materialiUtilizzati.*.product_id' => ['nullable', 'integer'], + 'materialiUtilizzati.*.descrizione' => ['required', 'string', 'max:255'], + 'materialiUtilizzati.*.barcode' => ['nullable', 'string', 'max:120'], + 'materialiUtilizzati.*.qty' => ['nullable', 'numeric', 'min:0.01', 'max:9999'], + 'materialiUtilizzati.*.unit_price' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + 'materialiUtilizzati.*.currency' => ['nullable', 'string', 'max:8'], + 'materialiUtilizzati.*.source_type' => ['nullable', 'string', 'max:32'], 'materialiUtilizzati.*.source_label' => ['nullable', 'string', 'max:120'], 'materialiUtilizzati.*.external_url' => ['nullable', 'string', 'max:1000'], - 'fotoLavoro' => ['nullable', 'array', 'max:10'], - 'fotoLavoro.*' => ['nullable', 'image', 'max:10240'], - 'documentiLavoro' => ['nullable', 'array', 'max:10'], - 'documentiLavoro.*' => ['nullable', 'file', 'max:10240', 'mimes:pdf,doc,docx,xls,xlsx,txt,jpg,jpeg,png,webp'], - 'descrizioneFile' => ['nullable', 'array'], - 'descrizioneFile.*' => ['nullable', 'string', 'max:255'], - 'apparatoMarca' => ['nullable', 'string', 'max:120'], - 'apparatoModello' => ['nullable', 'string', 'max:120'], - 'apparatoSeriale' => ['nullable', 'string', 'max:120'], - 'messaggioVeloce' => ['nullable', 'string', 'max:1500'], - 'lavoroStandard' => ['nullable', 'boolean'], - 'richiestaProforma' => ['nullable', 'boolean'], - 'proformaCodice' => ['nullable', 'string', 'max:80'], - 'proformaNote' => ['nullable', 'string', 'max:2000'], - 'qrToken' => ['nullable', 'string', 'max:40'], + 'fotoLavoro' => ['nullable', 'array', 'max:10'], + 'fotoLavoro.*' => ['nullable', 'image', 'max:10240'], + 'documentiLavoro' => ['nullable', 'array', 'max:10'], + 'documentiLavoro.*' => ['nullable', 'file', 'max:10240', 'mimes:pdf,doc,docx,xls,xlsx,txt,jpg,jpeg,png,webp'], + 'descrizioneFile' => ['nullable', 'array'], + 'descrizioneFile.*' => ['nullable', 'string', 'max:255'], + 'apparatoMarca' => ['nullable', 'string', 'max:120'], + 'apparatoModello' => ['nullable', 'string', 'max:120'], + 'apparatoSeriale' => ['nullable', 'string', 'max:120'], + 'messaggioVeloce' => ['nullable', 'string', 'max:1500'], + 'lavoroStandard' => ['nullable', 'boolean'], + 'richiestaProforma' => ['nullable', 'boolean'], + 'proformaCodice' => ['nullable', 'string', 'max:80'], + 'proformaNote' => ['nullable', 'string', 'max:2000'], + 'qrToken' => ['nullable', 'string', 'max:40'], ]); $rapporto = trim((string) $validated['rapportoFornitore']); @@ -445,7 +461,7 @@ public function saveRapporto(): void $rapporto .= "\n\n" . $apparato; } - $computedTempoMinuti = $validated['tempoMinuti'] ?? null; + $computedTempoMinuti = $validated['tempoMinuti'] ?? null; $computedTerminatedAt = $this->intervento->terminato_at ?? now(); if (! $computedTempoMinuti && $this->intervento->iniziato_at) { @@ -504,7 +520,7 @@ public function saveRapporto(): void $payload['articoli_utilizzati'] = $articoli; } - $allFiles = array_merge($this->fotoLavoro, $this->documentiLavoro); + $allFiles = array_merge($this->fotoLavoro, $this->documentiLavoro); $firstPhotoPath = null; if ($allFiles !== [] && Schema::hasTable('ticket_attachments')) { @@ -586,15 +602,15 @@ public function saveRapporto(): void ]); } - $this->fotoLavoro = []; - $this->documentiLavoro = []; - $this->descrizioneFile = []; + $this->fotoLavoro = []; + $this->documentiLavoro = []; + $this->descrizioneFile = []; $this->materialiUtilizzati = []; - $this->materialSearch = ''; + $this->materialSearch = ''; $this->materialSuggestions = []; - $this->messaggioVeloce = ''; - $this->proformaCodice = ''; - $this->proformaNote = ''; + $this->messaggioVeloce = ''; + $this->proformaCodice = ''; + $this->proformaNote = ''; $this->dispatch('fornitore-local-previews-clear'); $this->reloadIntervento(); @@ -693,11 +709,11 @@ public function getRapportinoUploadsProperty(): array continue; } - $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); - $size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0); - $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); - $isImage = str_starts_with($mime, 'image/'); - $isPdf = $mime === 'application/pdf' || str_ends_with(strtolower($name), '.pdf'); + $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); + $size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0); + $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); + $isImage = str_starts_with($mime, 'image/'); + $isPdf = $mime === 'application/pdf' || str_ends_with(strtolower($name), '.pdf'); $previewUrl = null; if ($isImage && method_exists($file, 'temporaryUrl')) { @@ -763,15 +779,17 @@ protected function buildStoredUploadDisplayName(int $index, bool $isImage, strin { $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); $baseName = 'ticket-' . str_pad((string) $this->intervento->ticket_id, 6, '0', STR_PAD_LEFT) - . '-int-' . str_pad((string) $this->intervento->id, 6, '0', STR_PAD_LEFT) - . '-' . ($isImage ? 'foto-' : 'doc-') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT); + . '-int-' . str_pad((string) $this->intervento->id, 6, '0', STR_PAD_LEFT) + . '-' . ($isImage ? 'foto-' : 'doc-') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT); return $baseName . ($extension !== '' ? '.' . $extension : ''); } 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() @@ -832,12 +844,12 @@ protected function reloadIntervento(): void ]) ->all(); - $this->dipendenteId = (int) ($this->intervento->eseguito_da_dipendente_id ?? 0) ?: null; - $this->rapportoFornitore = (string) ($this->intervento->rapporto_fornitore ?? ''); - $this->tempoMinuti = $this->calculateTrackedMinutes(); - $this->articoliUtilizzati = array_pad((array) ($this->intervento->articoli_utilizzati ?? []), 3, ''); + $this->dipendenteId = (int) ($this->intervento->eseguito_da_dipendente_id ?? 0) ?: null; + $this->rapportoFornitore = (string) ($this->intervento->rapporto_fornitore ?? ''); + $this->tempoMinuti = $this->calculateTrackedMinutes(); + $this->articoliUtilizzati = array_pad((array) ($this->intervento->articoli_utilizzati ?? []), 3, ''); $this->materialiUtilizzati = collect((array) ($this->intervento->materiali_utilizzati ?? [])) - ->map(fn(array $item): array => [ + ->map(fn(array $item): array=> [ 'product_id' => isset($item['product_id']) ? (int) $item['product_id'] : null, 'descrizione' => (string) ($item['descrizione'] ?? ''), 'barcode' => (string) ($item['barcode'] ?? ''), @@ -850,39 +862,45 @@ 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 { return [ - 'id' => (int) $sessione->id, - 'type' => (string) $sessione->session_type, - 'note' => (string) ($sessione->note ?? ''), - 'started_at' => optional($sessione->started_at)->format('d/m/Y H:i:s') ?: '-', - 'ended_at' => optional($sessione->ended_at)->format('d/m/Y H:i:s') ?: 'Aperta', + 'id' => (int) $sessione->id, + 'type' => (string) $sessione->session_type, + 'note' => (string) ($sessione->note ?? ''), + 'started_at' => optional($sessione->started_at)->format('d/m/Y H:i:s') ?: '-', + 'ended_at' => optional($sessione->ended_at)->format('d/m/Y H:i:s') ?: 'Aperta', 'duration_minutes' => $sessione->duration_minutes, - 'start_geo' => $this->formatGeoPoint($sessione->start_latitude, $sessione->start_longitude, $sessione->start_accuracy_meters), - 'end_geo' => $this->formatGeoPoint($sessione->end_latitude, $sessione->end_longitude, $sessione->end_accuracy_meters), + 'start_geo' => $this->formatGeoPoint($sessione->start_latitude, $sessione->start_longitude, $sessione->start_accuracy_meters), + 'end_geo' => $this->formatGeoPoint($sessione->end_latitude, $sessione->end_longitude, $sessione->end_accuracy_meters), ]; }) ->all(); - $latestSession = $this->intervento->sessioni + $latestSession = $sessionCollection ->sortByDesc(fn(TicketInterventoSessione $sessione) => $sessione->updated_at?->getTimestamp() ?? 0) ->first(); $this->lastGeoSnapshot = $latestSession instanceof TicketInterventoSessione ? [ - 'label' => (string) ($latestSession->session_type === 'checkpoint' ? 'Ultimo checkpoint' : 'Ultimo evento sessione'), - 'start_geo' => $this->formatGeoPoint($latestSession->start_latitude, $latestSession->start_longitude, $latestSession->start_accuracy_meters), - 'end_geo' => $this->formatGeoPoint($latestSession->end_latitude, $latestSession->end_longitude, $latestSession->end_accuracy_meters), - 'updated_at' => optional($latestSession->updated_at)->format('d/m/Y H:i:s') ?: '-', - ] + 'label' => (string) ($latestSession->session_type === 'checkpoint' ? 'Ultimo checkpoint' : 'Ultimo evento sessione'), + 'start_geo' => $this->formatGeoPoint($latestSession->start_latitude, $latestSession->start_longitude, $latestSession->start_accuracy_meters), + 'end_geo' => $this->formatGeoPoint($latestSession->end_latitude, $latestSession->end_longitude, $latestSession->end_accuracy_meters), + 'updated_at' => optional($latestSession->updated_at)->format('d/m/Y H:i:s') ?: '-', + ] : null; $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); - $this->qrToken = ''; + $this->qrToken = ''; } 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'); @@ -910,23 +932,23 @@ protected function calculateTrackedMinutes(): ?int */ protected function normalizeGeoPayload(array $geo): array { - $lat = isset($geo['lat']) && is_numeric($geo['lat']) ? round((float) $geo['lat'], 7) : null; - $lng = isset($geo['lng']) && is_numeric($geo['lng']) ? round((float) $geo['lng'], 7) : null; + $lat = isset($geo['lat']) && is_numeric($geo['lat']) ? round((float) $geo['lat'], 7) : null; + $lng = isset($geo['lng']) && is_numeric($geo['lng']) ? round((float) $geo['lng'], 7) : null; $accuracy = isset($geo['accuracy']) && is_numeric($geo['accuracy']) ? max(0, (int) round((float) $geo['accuracy'])) : null; - $source = trim((string) ($geo['source'] ?? 'browser')) ?: 'browser'; - $error = trim((string) ($geo['error'] ?? '')); + $source = trim((string) ($geo['source'] ?? 'browser')) ?: 'browser'; + $error = trim((string) ($geo['error'] ?? '')); return [ - 'lat' => $lat, - 'lng' => $lng, + 'lat' => $lat, + 'lng' => $lng, 'accuracy' => $accuracy, - 'source' => $source, - 'payload' => array_filter([ - 'lat' => $lat, - 'lng' => $lng, - 'accuracy' => $accuracy, - 'source' => $source, - 'error' => $error !== '' ? $error : null, + 'source' => $source, + 'payload' => array_filter([ + 'lat' => $lat, + 'lng' => $lng, + 'accuracy' => $accuracy, + 'source' => $source, + 'error' => $error !== '' ? $error : null, 'captured_at' => now()->toDateTimeString(), ], fn($value) => $value !== null && $value !== ''), ]; @@ -996,18 +1018,16 @@ 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, - 'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), - 'barcode' => (string) ($identifier->code_value ?? ''), - 'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : null, - 'source' => (string) ($offer?->source_name ?: 'Catalogo'), + 'id' => (int) $product->id, + 'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), + 'barcode' => (string) ($identifier->code_value ?? ''), + 'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : null, + 'source' => (string) ($offer?->source_name ?: 'Catalogo'), 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), ]; })->all(); @@ -1037,9 +1057,9 @@ protected function resolveGoogleWorkspaceStatus(): ?array $oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []); return [ - 'connected' => (bool) ($oauth['connected'] ?? false), - 'email' => (string) ($oauth['email'] ?? ''), - 'name' => (string) ($oauth['name'] ?? ''), + 'connected' => (bool) ($oauth['connected'] ?? false), + 'email' => (string) ($oauth['email'] ?? ''), + 'name' => (string) ($oauth['name'] ?? ''), 'connected_at' => (string) ($oauth['connected_at'] ?? ''), ]; } diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index cecdf8a..1113374 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -645,7 +645,7 @@ public function updatedPendingTicketAttachments(): void public function removeNewTicketFile(int $index): void { - $file = null; + $file = null; $cameraCount = count($this->newTicketCameraShots); if ($index < $cameraCount) { @@ -654,7 +654,7 @@ public function removeNewTicketFile(int $index): void $this->newTicketCameraShots = array_values($this->newTicketCameraShots); } else { $docIndex = $index - $cameraCount; - $file = $this->newTicketAttachments[$docIndex] ?? null; + $file = $this->newTicketAttachments[$docIndex] ?? null; unset($this->newTicketAttachments[$docIndex]); $this->newTicketAttachments = array_values($this->newTicketAttachments); } @@ -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, '.', ''); + if (filled($displayName)) { + $parts[] = 'File: ' . trim((string) $displayName); + } - $mapsUrl = trim((string) ($metadata['google_maps_url'] ?? '')); - if ($mapsUrl !== '') { - $parts[] = 'Maps: ' . $mapsUrl; - } + $sourceName = trim((string) $sourceOriginalName); + if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) { + $parts[] = 'Orig: ' . $sourceName; } return Str::limit(implode(' | ', array_filter($parts)), 255); diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index 46ddaf3..d55a833 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -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)
Coda allegati pronta per il ticket
+
Le immagini locali restano visibili fino all'invio del ticket. La rinomina definitiva viene applicata solo al momento del salvataggio.
@foreach($this->selectedUploads as $upload)
@if($upload['is_image'] && !empty($upload['preview_url'])) {{ $upload['name'] }} + @elseif($upload['is_image']) + + @else