*/ public array $newWaterPhotos = []; /** @var array */ public array $pendingWaterPhotos = []; /** @var array */ public array $pendingWaterLibraryPhotos = []; /** @var array */ public array $waterPhotoDescriptions = []; /** @var array */ public array $waterUploadCodes = []; public string $waterDraftProtocol = ''; public int $waterDraftSequence = 1; /** @var array */ public array $stabileOptions = []; /** @var array */ public array $servizioOptions = []; /** @var array */ public array $unitaOptions = []; /** @var array|null */ public ?array $previousReading = null; public string $unitaSearch = ''; public ?string $suggestedReaderName = null; /** @var array> */ public array $waterClientMetadata = []; public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void { $user = Auth::user(); $this->dataLettura = now()->toDateString(); $this->rilevatoreNome = $user instanceof User ? trim((string) $user->name) : null; $this->resetWaterDraftUploadState(); $this->loadStabileOptions(); $this->bootstrapDefaultSelection(); $this->loadServiceOptions(); $this->loadUnitOptions(); $this->loadPreviousReading(); $this->syncSuggestedReaderName(); } public function updatedStabileId(): void { $this->unitaSearch = ''; $this->loadServiceOptions(); $this->loadUnitOptions(); $this->loadPreviousReading(); $this->syncSuggestedReaderName(); } public function updatedServizioId(): void { $this->loadPreviousReading(); } public function updatedUnitaId(): void { $this->loadPreviousReading(); $this->syncSuggestedReaderName(); } public function updatedUnitaSearch(): void { $needle = trim($this->unitaSearch); if ($needle === '') { return; } $filtered = $this->getFilteredUnitaOptionsProperty(); if (count($filtered) === 1) { $this->applySelectedUnita((int) ($filtered[0]['id'] ?? 0)); } } public function selectUnita(int $unitaId): void { $this->applySelectedUnita($unitaId, true); } public function updatedPendingWaterPhotos(): void { $this->appendPendingWaterPhotos(); } public function updatedPendingWaterLibraryPhotos(): void { $this->appendPendingWaterLibraryPhotos(); } public function removeWaterPhoto(int $index): void { $file = $this->newWaterPhotos[$index] ?? null; unset($this->newWaterPhotos[$index]); $this->newWaterPhotos = array_values($this->newWaterPhotos); if (is_object($file)) { unset($this->waterUploadCodes[$this->resolveUploadQueueKey($file, $index)]); } $this->syncWaterDescriptionSlots(); } /** * @param array> $items */ public function updateWaterClientMetadata(array $items): void { $this->waterClientMetadata = array_values(array_filter(array_map(function (mixed $item): ?array { if (! is_array($item)) { return null; } $name = trim((string) ($item['name'] ?? '')); $size = (int) ($item['size'] ?? 0); if ($name === '' || $size < 0) { return null; } return [ 'source' => trim((string) ($item['source'] ?? '')), 'name' => $name, 'size' => $size, 'captured_at' => trim((string) ($item['captured_at'] ?? '')), 'device' => trim((string) ($item['device'] ?? '')), 'gps_decimal' => is_array($item['gps_decimal'] ?? null) ? $item['gps_decimal'] : [], 'gps_accuracy_meters' => $item['gps_accuracy_meters'] ?? null, ]; }, $items))); } /** * @return array}> */ public function getSelectedWaterUploadsProperty(): array { $rows = []; foreach ($this->newWaterPhotos as $index => $file) { if (! is_object($file)) { continue; } $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('foto-' . $index)); $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); $protocol = $this->resolveDraftUploadCode($file, $index); $metadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( app(TicketAttachmentUploadService::class)->inspectUpload($file), $this->resolveClientCaptureMetadata($name, (int) (method_exists($file, 'getSize') ? $file->getSize() : 0)), ); $preview = null; if (method_exists($file, 'temporaryUrl')) { try { $preview = (string) $file->temporaryUrl(); } catch (Throwable) { $preview = null; } } $rows[] = [ 'index' => (int) $index, 'name' => $this->buildDraftUploadDisplayName($protocol, $name), 'original_name' => $name, 'protocol' => $protocol, 'mime' => $mime, 'is_image' => str_starts_with($mime, 'image/'), 'preview_url' => $preview, 'size' => (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), 'metadata' => is_array($metadata) ? $metadata : [], ]; } return $rows; } public function registraLetturaAcqua(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $this->ensureDefaultSelections(); if (blank($this->rilevatoreNome)) { $this->rilevatoreNome = trim((string) ($this->suggestedReaderName ?: $user->name)); } $validated = $this->validate([ 'stabileId' => ['required', 'integer'], 'servizioId' => ['required', 'integer'], 'unitaId' => ['required', 'integer'], 'letturaAttuale' => ['required', 'numeric', 'min:0'], 'dataLettura' => ['nullable', 'date'], 'rilevatoreNome' => ['required', 'string', 'max:255'], 'rilevatoreContatto' => ['nullable', 'string', 'max:255'], 'noteGenerali' => ['nullable', 'string', 'max:2000'], 'newWaterPhotos.*' => ['nullable', 'image', 'max:8192'], ], [ 'stabileId.required' => 'Seleziona lo stabile.', 'servizioId.required' => 'Seleziona il servizio acqua.', 'unitaId.required' => 'Seleziona o cerca l\'unità immobiliare.', 'letturaAttuale.required' => 'Inserisci la lettura attuale del contatore.', 'letturaAttuale.numeric' => 'La lettura attuale deve essere numerica.', 'rilevatoreNome.required' => 'Inserisci il nominativo del letturista.', 'newWaterPhotos.*.image' => 'Ogni allegato deve essere una foto valida.', 'newWaterPhotos.*.max' => 'Ogni foto può pesare al massimo 8 MB.', ], [ 'stabileId' => 'stabile', 'servizioId' => 'servizio acqua', 'unitaId' => 'unità immobiliare', 'letturaAttuale' => 'lettura attuale', 'rilevatoreNome' => 'nominativo letturista', ]); $stabileIds = $this->resolveAccessibleStabileIds($user); $servizio = StabileServizio::query() ->where('id', (int) $validated['servizioId']) ->where('stabile_id', (int) $validated['stabileId']) ->whereIn('stabile_id', $stabileIds) ->where('tipo', 'acqua') ->first(); $unita = UnitaImmobiliare::query() ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) ->where('id', (int) $validated['unitaId']) ->where('stabile_id', (int) $validated['stabileId']) ->first(); if (! $servizio || ! $unita) { Notification::make() ->title('Selezione acqua non valida') ->body('Controlla stabile, servizio e unità prima di inviare la lettura.') ->warning() ->send(); return; } $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $servizio->id) ->where('unita_immobiliare_id', (int) $unita->id) ->whereNotNull('lettura_fine') ->orderByDesc('periodo_al') ->orderByDesc('id') ->first(); $letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; $letturaFine = round((float) $validated['letturaAttuale'], 3); $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; $readerName = trim((string) ($validated['rilevatoreNome'] ?? '')); $consistency = $this->buildReadingConsistencyCheck((int) $servizio->id, (int) $unita->id, $letturaFine, $letturaInizio); $readingDate = filled($validated['dataLettura'] ?? null) ? Carbon::parse((string) $validated['dataLettura']) : now(); $storageDirectory = $this->buildWaterStorageDirectory($servizio, $readingDate); if ($consumo !== null && $consumo < 0) { $this->addError('letturaAttuale', 'La lettura attuale non può essere inferiore alla precedente.'); Notification::make() ->title('Lettura non coerente') ->body('Il valore inserito è inferiore alla lettura precedente memorizzata.') ->warning() ->send(); return; } $photoAssets = []; foreach ($this->newWaterPhotos as $index => $file) { if (! is_object($file) || ! method_exists($file, 'store')) { continue; } $protocol = $this->resolveDraftUploadCode($file, $index); $naming = $this->buildWaterPhotoNaming( $unita, $this->resolveUnitReaderLabel($unita), $protocol, (string) $file->getClientOriginalName(), $readingDate, ); $stored = app(TicketAttachmentUploadService::class)->store( $file, $storageDirectory, [ 'stored_basename' => $naming['stored_basename'], 'display_name' => $naming['display_name'], ] ); $storedMetadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], $this->resolveClientCaptureMetadata( (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ''), (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), ), ); $photoAssets[] = [ 'index' => $index, 'protocol' => $protocol, 'path' => $stored['path'], 'original_name' => $stored['original_name'] ?? null, 'source_original_name' => $stored['source_original_name'] ?? null, 'mime' => $stored['mime'] ?? null, 'size' => $stored['size'] ?? null, 'metadata' => $storedMetadata, 'description' => trim((string) ($this->waterPhotoDescriptions[$index] ?? '')), 'drive_relative_directory' => $naming['drive_relative_directory'], 'drive_display_name' => $naming['display_name'], 'stored_basename' => $naming['stored_basename'], ]; } $row = StabileServizioLettura::query()->create([ 'stabile_id' => (int) $servizio->stabile_id, 'stabile_servizio_id' => (int) $servizio->id, 'unita_immobiliare_id' => (int) $unita->id, 'fornitore_id' => $servizio->fornitore_id, 'periodo_dal' => $previous?->periodo_al, 'periodo_al' => $readingDate->toDateString(), 'tipologia_lettura' => 'ticket_acqua_mobile', 'canale_acquisizione' => 'filament_ticket_acqua', 'workflow_stato' => 'ricevuta', 'riferimento_acquisizione' => 'TICKET-ACQUA:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis'), 'rilevatore_tipo' => 'operatore_filament', 'rilevatore_nome' => $readerName, 'lettura_precedente_valore' => $letturaInizio, 'lettura_inizio' => $letturaInizio, 'lettura_fine' => $letturaFine, 'lettura_foto_path' => $photoAssets[0]['path'] ?? null, 'lettura_foto_original_name' => $photoAssets[0]['original_name'] ?? null, 'lettura_foto_metadata' => [ 'photos' => $photoAssets, 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), 'note' => trim((string) ($validated['noteGenerali'] ?? '')), 'drive_relative_directory' => $storageDirectory, ], 'consumo_valore' => $consumo, 'consumo_unita' => 'mc', 'raw' => [ 'source' => 'ticket_acqua_filament', 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), 'note' => trim((string) ($validated['noteGenerali'] ?? '')), 'photo_assets' => $photoAssets, 'drive_relative_directory' => $storageDirectory, 'reader_label' => $this->resolveUnitReaderLabel($unita), 'consistency_check' => $consistency, ], 'created_by' => (int) $user->id, ]); $this->letturaAttuale = null; $this->noteGenerali = null; $this->dataLettura = now()->toDateString(); $this->resetWaterDraftUploadState(); $this->loadPreviousReading(); $this->syncSuggestedReaderName(); $this->dispatch('ticket-acqua-draft-reset'); Notification::make() ->title('Ticket acqua registrato') ->body('Lettura salvata per unità ' . ((string) ($unita->codice_unita ?: ('#' . $unita->id))) . ' con riferimento #' . $row->id . '.') ->success() ->send(); if (($consistency['status'] ?? 'ok') !== 'ok') { Notification::make() ->title('Controllo coerenza lettura') ->body((string) ($consistency['summary'] ?? 'La lettura va verificata rispetto allo storico.')) ->warning() ->send(); } } private function loadStabileOptions(): void { $user = Auth::user(); if (! $user instanceof User) { $this->stabileOptions = []; return; } $this->stabileOptions = StabileContext::accessibleStabili($user) ->sortBy(fn($stabile) => mb_strtolower((string) ($stabile->denominazione ?? ''))) ->map(fn($stabile): array=> [ 'id' => (int) $stabile->id, 'label' => trim((string) (($stabile->codice_stabile ?? '') !== '' ? ($stabile->codice_stabile . ' - ' . $stabile->denominazione) : ($stabile->denominazione ?? ('Stabile #' . $stabile->id)))), ]) ->values() ->all(); } private function bootstrapDefaultSelection(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $activeId = StabileContext::resolveActiveStabileId($user); $optionIds = array_column($this->stabileOptions, 'id'); if ($activeId && in_array((int) $activeId, $optionIds, true)) { $this->stabileId = (int) $activeId; return; } $this->stabileId = $optionIds[0] ?? null; } private function loadServiceOptions(): void { if (! $this->stabileId) { $this->servizioOptions = []; $this->servizioId = null; return; } $this->ensureWaterServiceBucket(); $this->servizioOptions = StabileServizio::query() ->where('stabile_id', (int) $this->stabileId) ->where('tipo', 'acqua') ->where('attivo', true) ->orderBy('nome') ->get(['id', 'nome', 'codice_utenza', 'contatore_matricola']) ->map(function (StabileServizio $servizio): array { $bits = [trim((string) ($servizio->nome ?: 'Servizio acqua #' . $servizio->id))]; if (filled($servizio->codice_utenza)) { $bits[] = 'Utenza ' . trim((string) $servizio->codice_utenza); } if (filled($servizio->contatore_matricola)) { $bits[] = 'Contatore ' . trim((string) $servizio->contatore_matricola); } return [ 'id' => (int) $servizio->id, 'label' => implode(' - ', array_filter($bits)), ]; }) ->all(); $serviceIds = array_column($this->servizioOptions, 'id'); if (! in_array((int) $this->servizioId, $serviceIds, true)) { $this->servizioId = $serviceIds[0] ?? null; } } private function loadUnitOptions(): void { if (! $this->stabileId) { $this->unitaOptions = []; $this->unitaId = null; return; } $this->unitaOptions = UnitaImmobiliare::query() ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) ->where('stabile_id', (int) $this->stabileId) ->where(function ($query): void { $query->whereNull('attiva')->orWhere('attiva', true); }) ->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno']) ->map(function (UnitaImmobiliare $unita): array { $bits = [trim((string) ($unita->codice_unita ?: ('UI #' . $unita->id)))]; if (filled($unita->denominazione)) { $bits[] = trim((string) $unita->denominazione); } $location = array_filter([ filled($unita->scala) ? 'Scala ' . trim((string) $unita->scala) : null, filled($unita->piano) ? 'Piano ' . trim((string) $unita->piano) : null, filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, ]); if ($location !== []) { $bits[] = implode(' ', $location); } $readerLabel = $this->resolveUnitReaderLabel($unita); $searchNames = $this->resolveUnitSearchNames($unita); $searchText = mb_strtolower(implode(' ', array_filter([ implode(' ', array_filter($bits)), ...$searchNames, ]))); return [ 'id' => (int) $unita->id, 'label' => implode(' - ', array_filter($bits)), 'reader_label' => $readerLabel, 'folder_prefix' => $this->buildUnitFolderPrefix($unita), 'search_text' => $searchText, 'sort_key' => $this->buildUnitNaturalSortKey($unita, $readerLabel), ]; }) ->all(); usort($this->unitaOptions, static function (array $left, array $right): int { return strnatcasecmp((string) ($left['sort_key'] ?? ''), (string) ($right['sort_key'] ?? '')); }); $unitIds = array_column($this->unitaOptions, 'id'); if (! in_array((int) $this->unitaId, $unitIds, true)) { $this->unitaId = $unitIds[0] ?? null; } } /** * @return array */ public function getFilteredUnitaOptionsProperty(): array { $needle = mb_strtolower(trim($this->unitaSearch)); if ($needle === '') { return $this->unitaOptions; } return array_values(array_filter($this->unitaOptions, fn(array $option): bool => str_contains((string) ($option['search_text'] ?? ''), $needle))); } /** * @return array */ public function getLightUnitaSearchResultsProperty(): array { return array_slice($this->getFilteredUnitaOptionsProperty(), 0, 10); } private function loadPreviousReading(): void { $this->previousReading = null; if (! $this->servizioId || ! $this->unitaId) { return; } $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $this->servizioId) ->where('unita_immobiliare_id', (int) $this->unitaId) ->whereNotNull('lettura_fine') ->orderByDesc('periodo_al') ->orderByDesc('id') ->first(); if (! $previous) { return; } $this->previousReading = [ 'value' => $previous->lettura_fine, 'date' => $previous->periodo_al?->format('d/m/Y'), 'id' => (int) $previous->id, ]; } private function appendPendingWaterPhotos(): void { $pending = array_values(array_filter($this->pendingWaterPhotos, fn($file) => is_object($file))); if ($pending === []) { return; } $available = max(0, 4 - count($this->newWaterPhotos)); if ($available <= 0) { $this->pendingWaterPhotos = []; Notification::make() ->title('Limite foto raggiunto') ->body('Puoi tenere in coda al massimo 4 foto per una lettura acqua.') ->warning() ->send(); return; } $accepted = array_slice($pending, 0, $available); $currentTotal = count($this->newWaterPhotos); $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); $this->pendingWaterPhotos = []; $this->assignDraftUploadCodes($accepted, $currentTotal); $this->syncWaterDescriptionSlots(); } private function appendPendingWaterLibraryPhotos(): void { $pending = array_values(array_filter($this->pendingWaterLibraryPhotos, fn($file) => is_object($file))); if ($pending === []) { return; } $available = max(0, 4 - count($this->newWaterPhotos)); if ($available <= 0) { $this->pendingWaterLibraryPhotos = []; Notification::make() ->title('Limite foto raggiunto') ->body('Puoi tenere in coda al massimo 4 foto per una lettura acqua.') ->warning() ->send(); return; } $accepted = array_slice($pending, 0, $available); $currentTotal = count($this->newWaterPhotos); $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); $this->pendingWaterLibraryPhotos = []; $this->assignDraftUploadCodes($accepted, $currentTotal); $this->syncWaterDescriptionSlots(); } private function resetWaterDraftUploadState(): void { $this->newWaterPhotos = []; $this->pendingWaterPhotos = []; $this->pendingWaterLibraryPhotos = []; $this->waterPhotoDescriptions = []; $this->waterUploadCodes = []; $this->waterClientMetadata = []; $this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His'); $this->waterDraftSequence = 1; } private function syncWaterDescriptionSlots(): void { $next = []; foreach ($this->newWaterPhotos as $index => $_file) { $next[$index] = (string) ($this->waterPhotoDescriptions[$index] ?? ''); } $this->waterPhotoDescriptions = $next; } /** * @param array $files */ private function assignDraftUploadCodes(array $files, int $fallbackIndexStart): void { foreach ($files as $offset => $file) { if (! is_object($file)) { continue; } $key = $this->resolveUploadQueueKey($file, $fallbackIndexStart + $offset); if (isset($this->waterUploadCodes[$key])) { continue; } $this->waterUploadCodes[$key] = $this->buildDraftProtocolCode($this->waterDraftSequence); $this->waterDraftSequence++; } } private function resolveDraftUploadCode(object $file, int $fallbackIndex): string { $key = $this->resolveUploadQueueKey($file, $fallbackIndex); if (! isset($this->waterUploadCodes[$key])) { $this->waterUploadCodes[$key] = $this->buildDraftProtocolCode($this->waterDraftSequence); $this->waterDraftSequence++; } return $this->waterUploadCodes[$key]; } private function buildDraftProtocolCode(int $sequence): string { return $this->waterDraftProtocol . '-F' . str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); } private function buildDraftUploadDisplayName(string $protocol, string $originalName): string { $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); return $protocol . ($extension !== '' ? '.' . $extension : ''); } private function resolveUploadQueueKey(object $file, int $fallbackIndex): string { if (method_exists($file, 'getFilename')) { $filename = trim((string) $file->getFilename()); if ($filename !== '') { return $filename; } } return spl_object_hash($file) ?: 'water-upload-' . $fallbackIndex; } private function syncSuggestedReaderName(): void { $previous = $this->suggestedReaderName; $current = $this->resolveCurrentUnitOption(); $this->suggestedReaderName = trim((string) ($current['reader_label'] ?? '')) ?: null; if (blank($this->rilevatoreNome) || ($previous !== null && $this->rilevatoreNome === $previous)) { $this->rilevatoreNome = $this->suggestedReaderName; } } private function ensureWaterServiceBucket(): void { if (! $this->stabileId) { return; } $hasActiveWaterService = StabileServizio::query() ->where('stabile_id', (int) $this->stabileId) ->where('tipo', 'acqua') ->where('attivo', true) ->exists(); if ($hasActiveWaterService) { return; } StabileServizio::query()->firstOrCreate( [ 'stabile_id' => (int) $this->stabileId, 'tipo' => 'acqua', 'nome' => 'Archivio letture acqua', ], [ 'attivo' => true, 'meta' => [ 'auto_created_by' => 'ticket-acqua', 'purpose' => 'reading-bucket', ], ] ); } private function ensureDefaultSelections(): void { if (! $this->stabileId && $this->stabileOptions !== []) { $this->stabileId = (int) ($this->stabileOptions[0]['id'] ?? 0) ?: null; } if (! $this->servizioId && $this->servizioOptions !== []) { $this->servizioId = (int) ($this->servizioOptions[0]['id'] ?? 0) ?: null; } $filtered = $this->getFilteredUnitaOptionsProperty(); if (! $this->unitaId && $filtered !== []) { $this->unitaId = (int) ($filtered[0]['id'] ?? 0) ?: null; } } /** * @return array|null */ private function resolveCurrentUnitOption(): ?array { foreach ($this->unitaOptions as $option) { if ((int) ($option['id'] ?? 0) === (int) ($this->unitaId ?? 0)) { return $option; } } return null; } /** * @return array{status:string,summary:string,delta:?float,average:?float,rule:?string} */ private function buildReadingConsistencyCheck(int $servizioId, int $unitaId, float $letturaFine, ?float $letturaInizio): array { if ($letturaInizio === null) { return [ 'status' => 'ok', 'summary' => 'Prima lettura utile disponibile per questa unità.', 'delta' => null, 'average' => null, 'rule' => 'first_reading', ]; } $delta = round($letturaFine - $letturaInizio, 3); if ($delta < 0) { return [ 'status' => 'danger', 'summary' => 'La lettura inserita è inferiore alla precedente.', 'delta' => $delta, 'average' => null, 'rule' => 'lower_than_previous', ]; } if ($delta === 0.0) { return [ 'status' => 'warning', 'summary' => 'La lettura coincide con la precedente: verificare se il contatore è fermo o se serve un controllo.', 'delta' => $delta, 'average' => 0.0, 'rule' => 'same_as_previous', ]; } $history = StabileServizioLettura::query() ->where('stabile_servizio_id', $servizioId) ->where('unita_immobiliare_id', $unitaId) ->whereNotNull('consumo_valore') ->where('consumo_valore', '>', 0) ->orderByDesc('periodo_al') ->orderByDesc('id') ->limit(5) ->pluck('consumo_valore') ->map(fn($value): float => (float) $value) ->all(); if ($history === []) { return [ 'status' => 'ok', 'summary' => 'Lettura registrata: manca ancora uno storico sufficiente per confronti automatici.', 'delta' => $delta, 'average' => null, 'rule' => 'no_history', ]; } $average = round(array_sum($history) / count($history), 3); if ($average > 0 && $delta > max(30.0, $average * 3)) { return [ 'status' => 'warning', 'summary' => 'La lettura produce un consumo molto più alto della media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).', 'delta' => $delta, 'average' => $average, 'rule' => 'much_higher_than_average', ]; } if ($average >= 1 && $delta < max(0.5, $average * 0.2)) { return [ 'status' => 'warning', 'summary' => 'La lettura produce un consumo molto basso rispetto alla media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).', 'delta' => $delta, 'average' => $average, 'rule' => 'much_lower_than_average', ]; } return [ 'status' => 'ok', 'summary' => 'Lettura coerente con lo storico recente.', 'delta' => $delta, 'average' => $average, 'rule' => 'within_expected_range', ]; } private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string { $role = $unita->rubricaRuoliAttivi ?->sortByDesc(fn($item) => (bool) ($item->is_preferito ?? false)) ->first(); if ($role?->contatto) { $label = trim((string) ($role->contatto->nome_completo ?: $role->contatto->ragione_sociale)); if ($label !== '') { return $label; } } $historicLabel = $this->resolveRelevantHistoricNames($unita)[0] ?? ''; if ($historicLabel !== '') { return $historicLabel; } return trim((string) ($unita->denominazione ?? '')); } /** * @return array */ private function resolveUnitSearchNames(UnitaImmobiliare $unita): array { $names = []; foreach (($unita->rubricaRuoliAttivi ?? collect()) as $role) { $contact = $role->contatto; if (! $contact) { continue; } $label = trim((string) ($contact->nome_completo ?: $contact->ragione_sociale)); if ($label !== '') { $names[] = $label; } } $names = array_merge($names, $this->resolveRelevantHistoricNames($unita)); $fallback = trim((string) ($unita->denominazione ?? '')); if ($fallback !== '') { $names[] = $fallback; } return array_values(array_unique(array_filter($names))); } /** * @return array */ private function resolveRelevantHistoricNames(UnitaImmobiliare $unita): array { $today = now()->toDateString(); $historic = collect($unita->nominativiStorici ?? []); $current = $historic ->filter(function ($item) use ($today): bool { $start = trim((string) ($item->data_inizio ?? '')); $end = trim((string) ($item->data_fine ?? '')); return ($start === '' || $start <= $today) && ($end === '' || $end >= $today); }) ->map(fn($item): string => trim((string) ($item->nominativo ?? ''))) ->filter() ->unique() ->values() ->all(); if ($current !== []) { return $current; } return $historic ->sortByDesc(fn($item): string => sprintf( '%s|%s|%010d', (string) ($item->data_fine ?? '9999-12-31'), (string) ($item->data_inizio ?? ''), (int) ($item->id ?? 0) )) ->map(fn($item): string => trim((string) ($item->nominativo ?? ''))) ->filter() ->unique() ->take(1) ->values() ->all(); } private function applySelectedUnita(int $unitaId, bool $syncSearchLabel = false): void { if ($unitaId <= 0) { return; } foreach ($this->unitaOptions as $option) { if ((int) ($option['id'] ?? 0) !== $unitaId) { continue; } $this->unitaId = $unitaId; if ($syncSearchLabel) { $this->unitaSearch = (string) ($option['label'] ?? ''); } $this->loadPreviousReading(); $this->syncSuggestedReaderName(); return; } } private function buildUnitNaturalSortKey(UnitaImmobiliare $unita, string $readerLabel): string { $parts = array_filter([ trim((string) ($unita->scala ?? '')), trim((string) ($unita->interno ?? '')), trim((string) ($unita->codice_unita ?? '')), trim((string) ($unita->piano ?? '')), trim($readerLabel), (string) $unita->id, ]); return implode(' ', $parts); } private function buildUnitFolderPrefix(UnitaImmobiliare $unita): string { $parts = array_filter([ filled($unita->scala) ? Str::upper(trim((string) $unita->scala)) : null, filled($unita->interno) ? trim((string) $unita->interno) : null, ! filled($unita->interno) && filled($unita->codice_unita) ? trim((string) $unita->codice_unita) : null, ]); $raw = implode('_', $parts); $normalized = preg_replace('/[^A-Za-z0-9_\-]+/', '_', $raw) ?? ''; return trim($normalized, '_-') !== '' ? trim($normalized, '_-') : ('UI_' . (int) $unita->id); } private function buildWaterStorageDirectory(StabileServizio $servizio, Carbon $readingDate): string { $servizio->loadMissing('stabile:id,denominazione,codice_stabile'); $stabileLabel = trim((string) ($servizio->stabile?->denominazione ?: $servizio->stabile?->codice_stabile ?: ('stabile-' . $servizio->stabile_id))); $stabileSlug = $this->normalizeLinuxPathSegment($stabileLabel); return 'condomini/' . $stabileSlug . '/utenze/acqua/letture/' . $readingDate->format('Y'); } /** * @return array{stored_basename:string,display_name:string,drive_relative_directory:string} */ private function buildWaterPhotoNaming(UnitaImmobiliare $unita, string $readerLabel, string $protocol, string $originalName, Carbon $readingDate): array { $unita->loadMissing('stabile:id,denominazione,codice_stabile'); $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); $suffix = $extension !== '' ? '.' . $extension : ''; $readerSafe = $this->normalizeLinuxPathSegment($readerLabel !== '' ? $readerLabel : ('ui-' . $unita->id)); $folderPrefix = Str::lower($this->buildUnitFolderPrefix($unita)); $storedBase = trim($folderPrefix . '_' . $readerSafe . '_' . Str::lower(str_replace(['.', ' '], '-', $protocol)), '_'); $displayParts = array_filter([ filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, $readerLabel !== '' ? $readerLabel : null, ]); $displayName = implode(' ', $displayParts); if ($displayName === '') { $displayName = trim((string) ($unita->codice_unita ?: ('UI ' . $unita->id))); } return [ 'stored_basename' => trim($storedBase, '_-') !== '' ? trim($storedBase, '_-') : ('water-photo-' . $unita->id . '-' . $readingDate->format('YmdHis')), 'display_name' => $displayName . $suffix, 'drive_relative_directory' => 'condomini/' . $this->normalizeLinuxPathSegment((string) ($unita->stabile?->denominazione ?? 'stabile')) . '/utenze/acqua/letture/' . $readingDate->format('Y'), ]; } private function normalizeLinuxPathSegment(string $value): string { $ascii = (string) Str::of($value)->ascii()->lower(); $normalized = preg_replace('/[^a-z0-9]+/', '_', $ascii) ?? ''; return trim($normalized, '_') !== '' ? trim($normalized, '_') : 'n_d'; } /** * @return array */ private function resolveClientCaptureMetadata(string $originalName, int $size): array { foreach ($this->waterClientMetadata as $item) { if (($item['name'] ?? '') === $originalName && (int) ($item['size'] ?? -1) === $size) { return $item; } } foreach ($this->waterClientMetadata as $item) { if (($item['name'] ?? '') === $originalName) { return $item; } } return []; } /** * @return array */ private function resolveAccessibleStabileIds(User $user): array { return StabileContext::accessibleStabili($user) ->pluck('id') ->map(fn($value) => (int) $value) ->filter(fn(int $value) => $value > 0) ->values() ->all(); } }