netgescon-day0/app/Filament/Pages/Fornitore/TicketInterventoScheda.php

1141 lines
47 KiB
PHP

<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductOffer;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Models\TicketIntervento;
use App\Models\TicketInterventoSessione;
use App\Models\User;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Livewire\WithFileUploads;
use UnitEnum;
class TicketInterventoScheda extends Page
{
use ResolvesOperatoreContext;
use WithFileUploads;
protected static ?string $title = 'Scheda intervento';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?string $slug = 'fornitore/tickets/{record}';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.pages.fornitore.ticket-intervento-scheda';
public TicketIntervento $intervento;
public Fornitore $fornitore;
public ?FornitoreDipendente $dipendente = null;
/** @var array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string} */
public array $caller = [
'contatto' => '-',
'telefono' => '',
'email' => '',
'problema' => '',
'sorgente' => '',
'riferimento' => '',
];
/** @var array<int, array<string, mixed>> */
public array $storicoRows = [];
/** @var array<int, array<string, mixed>> */
public array $dipendentiOptions = [];
public ?int $dipendenteId = null;
public string $rapportoFornitore = '';
public ?int $tempoMinuti = null;
/** @var array<int, string> */
public array $articoliUtilizzati = ['', '', ''];
/** @var array<int, array<string, mixed>> */
public array $materialiUtilizzati = [];
public string $materialSearch = '';
/** @var array<int, array<string, mixed>> */
public array $materialSuggestions = [];
public string $apparatoMarca = '';
public string $apparatoModello = '';
public string $apparatoSeriale = '';
/** @var array<int, mixed> */
public array $fotoLavoro = [];
/** @var array<int, mixed> */
public array $documentiLavoro = [];
/** @var array<int, string> */
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<int, array<string, mixed>> */
public array $sessionRows = [];
/** @var array<string, mixed>|null */
public ?array $lastGeoSnapshot = null;
/** @var array<string, mixed>|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)),
];
$this->materialSearch = '';
$this->materialSuggestions = [];
}
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 = [];
}
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<int, array<string, mixed>>
*/
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<string,mixed>}
*/
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<string, mixed>|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}
*/
protected function resolveCallerData(?Ticket $ticket): array
{
$parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? ''));
$caller = [
'contatto' => $parsed['contatto'],
'telefono' => $parsed['telefono'],
'email' => '',
'problema' => $parsed['problema'] !== '' ? $parsed['problema'] : (string) ($ticket?->titolo ?? ''),
'sorgente' => 'Descrizione ticket',
'riferimento' => $this->buildCallerReference($ticket),
];
$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['sorgente'] = 'Richiedente collegato al ticket';
return $caller;
}
$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
{
if (! $ticket instanceof Ticket) {
return '';
}
$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;
}
return implode(' · ', array_unique($parts));
}
}