'-', 'telefono' => '', 'email' => '', 'problema' => '', 'sorgente' => '', 'riferimento' => '', 'rubrica_url' => null, 'estratto_url'=> null, 'stabile_url' => null, 'unita_label' => '', ]; /** @var array> */ public array $storicoRows = []; /** @var array> */ public array $dipendentiOptions = []; public ?int $dipendenteId = null; public string $rapportoFornitore = ''; public ?int $tempoMinuti = null; /** @var array */ public array $articoliUtilizzati = ['', '', '']; /** @var array> */ public array $materialiUtilizzati = []; public string $materialSearch = ''; /** @var array> */ public array $materialSuggestions = []; public string $apparatoMarca = ''; public string $apparatoModello = ''; public string $apparatoSeriale = ''; /** @var array */ public array $fotoLavoro = []; /** @var array */ public array $documentiLavoro = []; /** @var array */ public array $descrizioneFile = ['', '', '', '', '', '']; public string $messaggioVeloce = ''; public bool $lavoroStandard = false; public bool $richiestaProforma = false; public string $proformaCodice = ''; public string $proformaNote = ''; public string $qrToken = ''; public ?array $attachmentPreview = null; /** @var array> */ public array $sessionRows = []; /** @var array|null */ public ?array $lastGeoSnapshot = null; /** @var array|null */ public ?array $googleWorkspaceStatus = null; public bool $sessionTrackingAvailable = false; public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']); } public function mount(int | string $record): void { $recordId = (int) $record; abort_unless($recordId > 0, 404); $user = Auth::user(); $isAdmin = $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); if ($isAdmin) { $intervento = $this->resolveInterventoRecord($recordId); abort_unless($intervento instanceof TicketIntervento, 404); [$fornitore, $dipendente] = $this->resolveOperatoreContext((int) $intervento->fornitore_id); } else { [$fornitore, $dipendente] = $this->resolveOperatoreContext(); abort_unless($fornitore instanceof Fornitore, 403, 'Profilo fornitore non collegato.'); $intervento = $this->resolveInterventoRecord($recordId, $fornitore, $dipendente); abort_unless($intervento instanceof TicketIntervento, 404, 'Ticket/intervento non disponibile per questo fornitore.'); } $this->intervento = $intervento; $this->fornitore = $fornitore; $this->dipendente = $dipendente; $this->authorizeIntervento($this->intervento, $this->fornitore); $this->authorizeDipendenteIntervento($this->intervento, $this->dipendente); $this->reloadIntervento(); } public function assignDipendente(): void { if ($this->dipendente instanceof FornitoreDipendente) { Notification::make()->title('Assegnazione consentita solo al coordinatore fornitore')->warning()->send(); return; } $validated = $this->validate([ 'dipendenteId' => ['nullable', 'integer'], ]); $dipendente = null; $selectedId = (int) ($validated['dipendenteId'] ?? 0); if ($selectedId > 0) { $dipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $this->fornitore->id) ->where('attivo', true) ->find($selectedId); if (! $dipendente instanceof FornitoreDipendente) { Notification::make()->title('Collaboratore non valido per questo fornitore')->danger()->send(); return; } } $this->intervento->eseguito_da_dipendente_id = $dipendente?->id; $this->intervento->save(); $stamp = now()->format('d/m/Y H:i'); $message = $dipendente ? '[' . $stamp . '] Intervento assegnato al collaboratore fornitore: ' . $dipendente->ruolo_operativo_label . '.' : '[' . $stamp . '] Assegnazione collaboratore rimossa: intervento riportato al coordinamento del fornitore.'; $this->intervento->ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => $message, 'canale' => 'interno', 'direzione' => 'inbound', 'inviato_il' => now(), ]); $this->reloadIntervento(); Notification::make()->title('Assegnazione aggiornata')->success()->send(); } public function startIntervento(): void { $this->startInterventoWithGeo([]); } 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(); return; } $startedAt = now(); $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'], ]); $this->intervento->update([ 'stato' => 'in_corso', 'preso_in_carico_da_user_id' => Auth::id(), 'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, 'iniziato_at' => $this->intervento->iniziato_at ?? $startedAt, 'terminato_at' => null, ]); $this->intervento->ticket->update(['stato' => 'In Lavorazione']); $this->reloadIntervento(); Notification::make()->title('Intervento preso in carico')->body($this->formatGeoNotification($geoData, 'Posizione avvio'))->success()->send(); } public function stopIntervento(): void { $this->stopInterventoWithGeo([]); } 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(); return; } $endedAt = now(); $minutes = max(1, $activeSession->started_at?->diffInMinutes($endedAt) ?? 1); $geoData = $this->normalizeGeoPayload($geo); $activeSession->update([ 'ended_at' => $endedAt, 'duration_minutes' => $minutes, 'closed_by_user_id' => Auth::id(), 'end_latitude' => $geoData['lat'], 'end_longitude' => $geoData['lng'], 'end_accuracy_meters' => $geoData['accuracy'], 'end_source' => $geoData['source'], 'geo_payload' => array_merge((array) ($activeSession->geo_payload ?? []), $geoData['payload']), ]); $totalMinutes = $this->calculateTrackedMinutes(); $this->intervento->update([ 'terminato_at' => $endedAt, 'tempo_minuti' => $totalMinutes, 'stato' => $this->intervento->stato === 'assegnato' ? 'in_corso' : $this->intervento->stato, ]); $this->tempoMinuti = $totalMinutes; $this->reloadIntervento(); Notification::make()->title('Timer intervento fermato')->body('Sessione: ' . $minutes . ' minuti. Totale rilevato: ' . $totalMinutes . ' minuti. ' . $this->formatGeoNotification($geoData, 'Posizione stop'))->success()->send(); } 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(); 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(), 'closed_by_user_id' => Auth::id(), 'session_type' => 'checkpoint', 'note' => 'Checkpoint presenza', 'started_at' => $stamp, 'ended_at' => $stamp, 'duration_minutes' => 0, 'start_latitude' => $geoData['lat'], 'start_longitude' => $geoData['lng'], 'start_accuracy_meters' => $geoData['accuracy'], 'start_source' => $geoData['source'], 'geo_payload' => $geoData['payload'], ]); $this->reloadIntervento(); Notification::make()->title('Checkpoint presenza registrato')->body($this->formatGeoNotification($geoData, 'Posizione checkpoint'))->success()->send(); } public function updatedFotoLavoro(): void { $this->syncRapportoDescriptionSlots(); } public function updatedDocumentiLavoro(): void { $this->syncRapportoDescriptionSlots(); } public function updatedMaterialSearch(): void { $this->refreshMaterialSuggestions(); } public function addSuggestedMaterial(int $productId): void { $product = Product::query() ->with(['identifiers', 'offers' => fn($query) => $query->where(function ($builder): void { $builder->whereNull('fornitore_id') ->orWhere('fornitore_id', (int) $this->fornitore->id); })->orderBy('is_internal', 'desc')->orderBy('price_amount')]) ->find($productId); if (! $product instanceof Product) { return; } $identifier = $product->identifiers ->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)), ]; $description = (string) ($this->materialiUtilizzati[array_key_last($this->materialiUtilizzati)]['descrizione'] ?? 'Materiale'); $this->materialSearch = ''; $this->materialSuggestions = []; Notification::make() ->title('Materiale aggiunto al rapportino') ->body($description) ->success() ->send(); } public function addManualMaterial(): void { $term = trim($this->materialSearch); if ($term === '') { Notification::make()->title('Inserisci un prodotto o barcode')->warning()->send(); return; } $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), ]; $this->materialSearch = ''; $this->materialSuggestions = []; Notification::make() ->title('Materiale manuale aggiunto') ->body($term) ->success() ->send(); } public function removeMateriale(int $index): void { unset($this->materialiUtilizzati[$index]); $this->materialiUtilizzati = array_values($this->materialiUtilizzati); } 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'], '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'], ]); $rapporto = trim((string) $validated['rapportoFornitore']); $apparato = $this->buildApparatoSummary( (string) ($validated['apparatoMarca'] ?? ''), (string) ($validated['apparatoModello'] ?? ''), (string) ($validated['apparatoSeriale'] ?? '') ); if ($apparato !== '') { $rapporto .= "\n\n" . $apparato; } $computedTempoMinuti = $validated['tempoMinuti'] ?? null; $computedTerminatedAt = $this->intervento->terminato_at ?? now(); if (! $computedTempoMinuti && $this->intervento->iniziato_at) { $computedTempoMinuti = max(1, $this->intervento->iniziato_at->diffInMinutes($computedTerminatedAt)); } $payload = [ 'rapporto_fornitore' => $rapporto, 'tempo_minuti' => $computedTempoMinuti, 'terminato_at' => $computedTerminatedAt, 'stato' => ! empty($validated['lavoroStandard']) ? 'fatturabile' : 'in_verifica', 'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, ]; $materiali = collect($validated['materialiUtilizzati'] ?? []) ->map(function ($item): array { return [ 'product_id' => isset($item['product_id']) ? (int) $item['product_id'] : null, 'descrizione' => trim((string) ($item['descrizione'] ?? '')), 'barcode' => trim((string) ($item['barcode'] ?? '')), 'qty' => isset($item['qty']) && is_numeric($item['qty']) ? (float) $item['qty'] : 1.0, 'unit_price' => isset($item['unit_price']) && is_numeric($item['unit_price']) ? round((float) $item['unit_price'], 2) : null, 'currency' => trim((string) ($item['currency'] ?? 'EUR')) ?: 'EUR', 'source_type' => trim((string) ($item['source_type'] ?? 'manual')) ?: 'manual', 'source_label' => trim((string) ($item['source_label'] ?? '')), 'external_url' => trim((string) ($item['external_url'] ?? '')), ]; }) ->filter(fn(array $item): bool => $item['descrizione'] !== '') ->values() ->all(); $articoli = collect($validated['articoliUtilizzati'] ?? []) ->map(fn($item) => trim((string) $item)) ->filter() ->values() ->all(); if ($materiali !== []) { $payload['materiali_utilizzati'] = $materiali; $materialiLabels = collect($materiali) ->map(function (array $item): string { $label = $item['descrizione']; $qty = (float) ($item['qty'] ?? 1); return $label . ($qty > 0 ? (' x' . rtrim(rtrim(number_format($qty, 2, '.', ''), '0'), '.')) : ''); }) ->values() ->all(); $articoli = array_values(array_unique(array_merge($articoli, $materialiLabels))); } if ($articoli !== []) { $payload['articoli_utilizzati'] = $articoli; } $allFiles = array_merge($this->fotoLavoro, $this->documentiLavoro); $firstPhotoPath = null; if ($allFiles !== [] && Schema::hasTable('ticket_attachments')) { foreach ($allFiles as $index => $file) { if (! is_object($file) || ! method_exists($file, 'store')) { continue; } $originalName = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); $isImage = str_starts_with($mime, 'image/'); $stored = app(\App\Services\Support\TicketAttachmentUploadService::class)->store( $file, 'ticket-interventi/allegati/' . $this->intervento->id, [ 'stored_basename' => pathinfo($this->buildStoredUploadDisplayName($index, $isImage, $originalName), PATHINFO_FILENAME), 'display_name' => $originalName, ] ); if ($isImage && $firstPhotoPath === null) { $firstPhotoPath = (string) $stored['path']; } TicketAttachment::query()->create([ 'ticket_id' => (int) $this->intervento->ticket_id, 'ticket_update_id' => null, 'user_id' => (int) Auth::id(), 'file_path' => (string) $stored['path'], 'original_file_name' => (string) $stored['original_name'], 'mime_type' => (string) $stored['mime'], 'size' => (int) $stored['size'], 'description' => trim((string) ($this->descrizioneFile[$index] ?? '')) ?: ($isImage ? 'Foto lavoro fornitore' : 'Allegato rapporto fornitore'), ]); } } if ($firstPhotoPath !== null) { $payload['foto_path'] = $firstPhotoPath; } $tokenInserito = trim((string) ($validated['qrToken'] ?? '')); if ($tokenInserito !== '' && strcasecmp($tokenInserito, (string) $this->intervento->qr_token) === 0) { $payload['qr_scansionato_at'] = now(); } $this->intervento->update($payload); $stamp = now()->format('d/m/Y H:i'); $this->intervento->ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => '[' . $stamp . '] Rapporto intervento caricato dal fornitore.', 'canale' => 'interno', 'direzione' => 'inbound', 'inviato_il' => now(), ]); $proformaCodice = trim((string) ($validated['proformaCodice'] ?? '')); $proformaNote = trim((string) ($validated['proformaNote'] ?? '')); if (! empty($validated['richiestaProforma']) || $proformaCodice !== '' || $proformaNote !== '') { $this->intervento->ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => '[' . $stamp . '] Proforma fornitore ' . ($proformaCodice !== '' ? ('codice ' . $proformaCodice) : '(senza codice)') . ($proformaNote !== '' ? (' - ' . $proformaNote) : ''), 'canale' => 'interno', 'direzione' => 'inbound', 'inviato_il' => now(), ]); } $quickMessage = trim((string) ($validated['messaggioVeloce'] ?? '')); if ($quickMessage !== '') { $this->intervento->ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => '[' . $stamp . '] Messaggio veloce fornitore: ' . $quickMessage, 'canale' => 'interno', 'direzione' => 'inbound', 'inviato_il' => now(), ]); } $this->fotoLavoro = []; $this->documentiLavoro = []; $this->descrizioneFile = []; $this->materialiUtilizzati = []; $this->materialSearch = ''; $this->materialSuggestions = []; $this->messaggioVeloce = ''; $this->proformaCodice = ''; $this->proformaNote = ''; $this->dispatch('fornitore-local-previews-clear'); $this->reloadIntervento(); Notification::make()->title('Rapporto inviato in verifica amministratore')->success()->send(); } public function inviaSollecito(): void { $stamp = now()->format('d/m/Y H:i'); $this->intervento->ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => '[' . $stamp . '] Sollecito fornitore su ticket/intervento in corso.', 'canale' => 'interno', 'direzione' => 'inbound', 'inviato_il' => now(), ]); $this->reloadIntervento(); Notification::make()->title('Sollecito inviato allo studio amministrativo')->success()->send(); } public function getTicketsUrl(): string { return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } public function getAttachmentPublicUrl(TicketAttachment $attachment): string { return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]); } public function openAttachmentPreview(int $attachmentId): void { $attachment = $this->intervento->ticket->attachments->firstWhere('id', $attachmentId); if (! $attachment instanceof TicketAttachment) { return; } $this->attachmentPreview = [ 'id' => (int) $attachment->id, 'name' => (string) ($attachment->original_file_name ?? 'allegato'), 'description' => (string) ($attachment->description ?? ''), 'url' => $this->getAttachmentPublicUrl($attachment), 'is_image' => $attachment->isImage(), 'is_pdf' => $attachment->isPdf(), 'mime' => $attachment->resolvedMimeType(), ]; } public function closeAttachmentPreview(): void { $this->attachmentPreview = null; } public function getCollaboratoriUrl(): string { return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } public function getRubricaUrl(): string { return RubricaClienti::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } public function getProdottiUrl(): string { return ProdottiCatalogo::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } public function getGoogleConnectUrl(): string { return route('oauth.google.redirect'); } public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect'); } public function canAssignDipendente(): bool { return ! ($this->dipendente instanceof FornitoreDipendente) && count($this->dipendentiOptions) > 0; } /** * @return array> */ public function getRapportinoUploadsProperty(): array { $rows = []; foreach (array_merge($this->fotoLavoro, $this->documentiLavoro) as $index => $file) { if (! is_object($file)) { 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'); $previewUrl = null; if ($isImage && method_exists($file, 'temporaryUrl')) { try { $previewUrl = (string) $file->temporaryUrl(); } catch (\Throwable) { $previewUrl = null; } } $rows[] = [ 'index' => $index, 'name' => $name, 'size' => $size, 'mime' => $mime, 'is_image' => $isImage, 'is_pdf' => $isPdf, 'preview_url' => $previewUrl, 'description' => (string) ($this->descrizioneFile[$index] ?? ''), ]; } return $rows; } protected function resolveInterventoRecord(int $recordId, ?Fornitore $fornitore = null, ?FornitoreDipendente $dipendente = null): ?TicketIntervento { $query = TicketIntervento::query(); if ($fornitore instanceof Fornitore) { $query->where('fornitore_id', (int) $fornitore->id); } if ($dipendente instanceof FornitoreDipendente) { $query->where(function (Builder $builder) use ($dipendente): void { $builder->whereNull('eseguito_da_dipendente_id') ->orWhere('eseguito_da_dipendente_id', (int) $dipendente->id); }); } return $query ->where(function (Builder $builder) use ($recordId): void { $builder->whereKey($recordId) ->orWhere('ticket_id', $recordId); }) ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [$recordId]) ->latest('id') ->first(); } protected function syncRapportoDescriptionSlots(): void { $next = []; foreach (array_merge($this->fotoLavoro, $this->documentiLavoro) as $index => $_file) { $next[$index] = (string) ($this->descrizioneFile[$index] ?? ''); } $this->descrizioneFile = $next; } protected function buildStoredUploadDisplayName(int $index, bool $isImage, string $originalName): string { $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); return $baseName . ($extension !== '' ? '.' . $extension : ''); } protected function reloadIntervento(): void { $this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni'); $relations = [ 'ticket.stabile', 'ticket.unitaImmobiliare', 'ticket.soggettoRichiedente', 'ticket.messages.user', 'ticket.attachments.user', 'ticket.apertoDaUser', 'ticket.assegnatoAUser', 'ticket.assegnatoAFornitore', 'eseguitoDaDipendente', ]; if ($this->sessionTrackingAvailable) { $relations[] = 'sessioni'; } $this->intervento->load($relations); $this->intervento->refresh(); $this->intervento->load($relations); $this->caller = $this->resolveCallerData($this->intervento->ticket); $this->storicoRows = TicketIntervento::query() ->with(['ticket']) ->where('fornitore_id', (int) $this->fornitore->id) ->where('id', '!=', (int) $this->intervento->id) ->when( (int) ($this->intervento->ticket?->soggetto_richiedente_id ?? 0) > 0, fn($query) => $query->whereHas('ticket', fn($ticketQuery) => $ticketQuery->where('soggetto_richiedente_id', (int) $this->intervento->ticket->soggetto_richiedente_id)), fn($query) => $query->whereHas('ticket', fn($ticketQuery) => $ticketQuery->where('stabile_id', (int) ($this->intervento->ticket?->stabile_id ?? 0))) ) ->latest('id') ->limit(12) ->get() ->map(fn(TicketIntervento $old) => [ 'ticket_id' => (int) $old->ticket_id, 'titolo' => (string) ($old->ticket->titolo ?? '-'), 'stato' => (string) $old->stato, 'updated_at' => optional($old->updated_at)->format('d/m/Y H:i') ?: '-', 'note' => (string) ($old->note_amministratore ?? ''), ]) ->all(); $this->dipendentiOptions = $this->fornitore->dipendenti() ->where('attivo', true) ->with('fornitoreEsterno') ->orderBy('cognome') ->orderBy('nome') ->get() ->map(fn(FornitoreDipendente $dipendente) => [ 'id' => (int) $dipendente->id, 'label' => $dipendente->ruolo_operativo_label . ($dipendente->email ? ' · ' . $dipendente->email : ''), ]) ->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->materialiUtilizzati = collect((array) ($this->intervento->materiali_utilizzati ?? [])) ->map(fn(array $item): array=> [ 'product_id' => isset($item['product_id']) ? (int) $item['product_id'] : null, 'descrizione' => (string) ($item['descrizione'] ?? ''), 'barcode' => (string) ($item['barcode'] ?? ''), 'qty' => isset($item['qty']) ? (float) $item['qty'] : 1, 'unit_price' => isset($item['unit_price']) ? (float) $item['unit_price'] : null, 'currency' => (string) ($item['currency'] ?? 'EUR'), 'source_type' => (string) ($item['source_type'] ?? 'manual'), 'source_label' => (string) ($item['source_label'] ?? ''), 'external_url' => (string) ($item['external_url'] ?? ''), ]) ->values() ->all(); $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', '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), ]; }) ->all(); $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') ?: '-', ] : null; $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); $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') ->whereNull('ended_at') ->latest('started_at') ->first(); } 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'); $activeSession = $this->getActiveSession(); if ($activeSession instanceof TicketInterventoSessione && $activeSession->started_at) { $total += max(1, $activeSession->started_at->diffInMinutes(now())); } return $total > 0 ? $total : ($this->intervento->tempo_minuti ? (int) $this->intervento->tempo_minuti : null); } /** * @return array{lat:?float,lng:?float,accuracy:?int,source:string,payload:array} */ 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; $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'] ?? '')); return [ 'lat' => $lat, 'lng' => $lng, 'accuracy' => $accuracy, '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 !== ''), ]; } protected function formatGeoNotification(array $geoData, string $prefix): string { if ($geoData['lat'] === null || $geoData['lng'] === null) { return $prefix . ': geolocalizzazione non disponibile dal browser.'; } $message = $prefix . ': ' . number_format((float) $geoData['lat'], 5, '.', '') . ', ' . number_format((float) $geoData['lng'], 5, '.', ''); if (is_int($geoData['accuracy'])) { $message .= ' ±' . $geoData['accuracy'] . ' m'; } return $message; } protected function formatGeoPoint(mixed $lat, mixed $lng, mixed $accuracy): string { if (! is_numeric($lat) || ! is_numeric($lng)) { return '-'; } $label = number_format((float) $lat, 5, '.', '') . ', ' . number_format((float) $lng, 5, '.', ''); if (is_numeric($accuracy)) { $label .= ' ±' . (int) $accuracy . ' m'; } return $label; } protected function refreshMaterialSuggestions(): void { $term = trim($this->materialSearch); if ($term === '') { $this->materialSuggestions = []; return; } $normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? ''; $products = Product::query() ->with(['identifiers', 'offers' => fn($query) => $query->orderBy('is_internal', 'desc')->orderBy('price_amount')]) ->where('is_active', true) ->where(function ($query) use ($term, $normalized): void { $query->where('name', 'like', '%' . $term . '%') ->orWhere('brand', 'like', '%' . $term . '%') ->orWhere('model', 'like', '%' . $term . '%') ->orWhere('internal_code', 'like', '%' . $term . '%'); if ($normalized !== '') { $query->orWhereHas('identifiers', function ($identifierQuery) use ($normalized): void { $identifierQuery->where('normalized_code', $normalized) ->orWhere('code_value', 'like', '%' . $normalized . '%'); }); } }) ->where(function ($query): void { $query->whereNull('default_fornitore_id') ->orWhere('default_fornitore_id', (int) $this->fornitore->id) ->orWhereHas('offers', fn($offerQuery) => $offerQuery->where('fornitore_id', (int) $this->fornitore->id)); }) ->limit(8) ->get(); $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(); $offer = $product->offers ->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'), 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), ]; })->all(); } protected function buildAmazonSearchUrl(string $term): string { $query = ['k' => trim($term)]; $tag = trim((string) config('services.amazon.referral_tag', '')); if ($tag !== '') { $query['tag'] = $tag; } return 'https://www.amazon.it/s?' . http_build_query($query); } /** * @return array|null */ protected function resolveGoogleWorkspaceStatus(): ?array { $amministratore = $this->fornitore->amministratore; if (! $amministratore) { return null; } $oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []); return [ 'connected' => (bool) ($oauth['connected'] ?? false), 'email' => (string) ($oauth['email'] ?? ''), 'name' => (string) ($oauth['name'] ?? ''), 'connected_at' => (string) ($oauth['connected_at'] ?? ''), ]; } /** * @return array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string,rubrica_url:?string,estratto_url:?string,stabile_url:?string,unita_label:string} */ protected function resolveCallerData(?Ticket $ticket): array { $parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? '')); $caller = [ 'contatto' => $parsed['contatto'], 'telefono' => $parsed['telefono'], 'email' => $parsed['email'] ?? '', 'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''), 'sorgente' => 'Descrizione ticket', 'riferimento' => $this->buildCallerReference($ticket, (string) ($parsed['riferimento'] ?? '')), 'rubrica_url' => null, 'estratto_url'=> null, 'stabile_url' => $ticket?->stabile ? StabileScheda::getUrl(['record' => (int) $ticket->stabile->id], panel: 'admin-filament') : null, 'unita_label' => $ticket?->unitaImmobiliare ? $this->formatUnitaLabel($ticket->unitaImmobiliare) : '', ]; if (($caller['contatto'] ?? '-') === '-' || trim((string) ($caller['contatto'] ?? '')) === '') { $titleContact = $this->extractCallerNameFromTitle((string) ($ticket?->titolo ?? '')); if ($titleContact !== '') { $caller['contatto'] = $titleContact; } } $soggetto = $ticket?->soggettoRichiedente; if ($soggetto) { $label = trim((string) ($soggetto->ragione_sociale ?: trim(($soggetto->nome ?? '') . ' ' . ($soggetto->cognome ?? '')))); if ($label !== '') { $caller['contatto'] = $label; } $caller['telefono'] = trim((string) ($soggetto->telefono ?? '')) ?: $caller['telefono']; $caller['email'] = trim((string) ($soggetto->email ?? '')) ?: $caller['email']; $caller['sorgente'] = 'Richiedente collegato al ticket'; $caller['estratto_url'] = EstrattoContoSoggetto::getUrl(['record' => (int) $soggetto->id], panel: 'admin-filament'); $rubrica = $this->resolveCallerRubrica($ticket, $caller); if ($rubrica instanceof RubricaUniversale) { $caller['rubrica_url'] = RubricaUniversaleScheda::getUrl(['record' => (int) $rubrica->id], panel: 'admin-filament'); $caller['telefono'] = trim((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: $rubrica->telefono_casa ?: '')) ?: $caller['telefono']; $caller['email'] = trim((string) ($rubrica->email ?? '')) ?: $caller['email']; } return $caller; } $rubrica = $this->resolveCallerRubrica($ticket, $caller); if ($rubrica instanceof RubricaUniversale) { $label = trim((string) ($rubrica->nome_completo ?: $rubrica->ragione_sociale ?: '')); if ($label !== '') { $caller['contatto'] = $label; } $caller['telefono'] = trim((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: $rubrica->telefono_casa ?: '')) ?: $caller['telefono']; $caller['email'] = trim((string) ($rubrica->email ?? '')) ?: $caller['email']; $caller['rubrica_url'] = RubricaUniversaleScheda::getUrl(['record' => (int) $rubrica->id], panel: 'admin-filament'); $caller['sorgente'] = 'Rubrica dedotta dal ticket'; } $openedBy = $ticket?->apertoDaUser; if ($openedBy instanceof User) { $caller['contatto'] = trim((string) ($openedBy->name ?? '')) ?: $caller['contatto']; $caller['email'] = trim((string) ($openedBy->email ?? '')); $caller['sorgente'] = 'Utente che ha aperto il ticket'; } return $caller; } protected function buildCallerReference(?Ticket $ticket, string $parsedReference = ''): string { if (! $ticket instanceof Ticket) { return $parsedReference; } $parts = []; $stabile = $ticket->stabile; if ($stabile) { $denominazione = trim((string) ($stabile->denominazione ?? '')); $indirizzo = trim(implode(' ', array_filter([ $stabile->indirizzo ?? null, $stabile->cap ?? null, $stabile->citta ?? null, ]))); if ($denominazione !== '') { $parts[] = $denominazione; } if ($indirizzo !== '') { $parts[] = $indirizzo; } } $luogoIntervento = trim((string) ($ticket->luogo_intervento ?? '')); if ($luogoIntervento !== '') { $parts[] = 'Luogo: ' . $luogoIntervento; } if ($ticket->unitaImmobiliare) { $parts[] = 'Unità: ' . $this->formatUnitaLabel($ticket->unitaImmobiliare); } if ($parsedReference !== '') { $parts[] = $parsedReference; } return implode(' · ', array_unique($parts)); } private function resolveCallerRubrica(?Ticket $ticket, array $caller): ?RubricaUniversale { $adminId = (int) ($ticket?->stabile?->amministratore_id ?? 0); $queries = []; $soggetto = $ticket?->soggettoRichiedente; if ($soggetto && filled($soggetto->codice_fiscale)) { $queries[] = fn() => RubricaUniversale::query()->where('codice_fiscale', strtoupper(trim((string) $soggetto->codice_fiscale))); } $email = mb_strtolower(trim((string) ($caller['email'] ?? ''))); if ($email !== '') { $queries[] = fn() => RubricaUniversale::query()->whereRaw('LOWER(email) = ?', [$email]); } $phone = $this->normalizePhoneDigits((string) ($caller['telefono'] ?? '')); if ($phone !== '') { $queries[] = fn() => RubricaUniversale::query()->where(function ($builder) use ($phone): void { $builder->whereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') = ?", [$phone]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') = ?", [$phone]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') = ?", [$phone]); }); } $name = trim((string) ($caller['contatto'] ?? '')); if ($name !== '' && $name !== '-') { $queries[] = fn() => RubricaUniversale::query()->where(function ($builder) use ($name): void { $builder->whereRaw("LOWER(COALESCE(ragione_sociale, '')) = ?", [mb_strtolower($name)]) ->orWhereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) = ?", [mb_strtolower($name)]); }); } foreach ($queries as $factory) { $query = $factory(); if ($adminId > 0) { $query->where(function ($builder) use ($adminId): void { $builder->where('amministratore_id', $adminId) ->orWhereNull('amministratore_id'); }); } $match = $query->orderByDesc('updated_at')->orderBy('id')->first(); if ($match instanceof RubricaUniversale) { return $match; } } return null; } private function formatUnitaLabel(mixed $unita): string { if (! $unita) { return ''; } $parts = array_filter([ (string) ($unita->denominazione ?? ''), ! empty($unita->scala) ? 'Scala ' . $unita->scala : null, ! empty($unita->interno) ? 'Int. ' . $unita->interno : null, ! empty($unita->piano) ? 'Piano ' . $unita->piano : null, ]); return implode(' · ', $parts) ?: ('Unità #' . (int) ($unita->id ?? 0)); } private function normalizePhoneDigits(string $value): string { return preg_replace('/\D+/', '', $value) ?: ''; } }