*/ public array $newGeneralWaterPhotos = []; /** @var array */ public array $pendingGeneralWaterPhotos = []; /** @var array */ public array $generalWaterPhotoDescriptions = []; /** @var array */ public array $generalWaterUploadCodes = []; public string $generalWaterDraftProtocol = ''; public int $generalWaterDraftSequence = 1; /** @var array */ public array $stabileOptions = []; /** @var array */ public array $servizioOptions = []; /** @var array */ public array $readerOptions = []; /** @var array|null */ public ?array $lastGeneralReading = null; /** @var array> */ public array $generalWaterClientMetadata = []; 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->resetGeneralWaterDraftUploadState(); $this->loadStabileOptions(); $this->bootstrapDefaultSelection(); $this->loadServiceOptions(); $this->applyRequestSelection(); $this->loadReaderOptions(); $this->loadLastGeneralReading(); } public function updatedStabileId(): void { $this->loadServiceOptions(); $this->loadReaderOptions(); $this->loadLastGeneralReading(); } public function updatedServizioId(): void { $this->loadLastGeneralReading(); } public function updatedRilevatoreUserId(): void { foreach ($this->readerOptions as $option) { if ((int) ($option['id'] ?? 0) !== (int) ($this->rilevatoreUserId ?? 0)) { continue; } $this->rilevatoreNome = (string) ($option['label'] ?? $this->rilevatoreNome); $this->rilevatoreTipo = (string) ($option['tipo'] ?? $this->rilevatoreTipo); break; } } public function updatedPendingGeneralWaterPhotos(): void { $this->appendPendingGeneralWaterPhotos(); } public function removeGeneralWaterPhoto(int $index): void { $file = $this->newGeneralWaterPhotos[$index] ?? null; unset($this->newGeneralWaterPhotos[$index]); $this->newGeneralWaterPhotos = array_values($this->newGeneralWaterPhotos); if (is_object($file)) { unset($this->generalWaterUploadCodes[$this->resolveUploadQueueKey($file, $index)]); } $this->syncGeneralWaterDescriptionSlots(); } /** * @param array> $items */ public function updateGeneralWaterClientMetadata(array $items): void { $this->generalWaterClientMetadata = 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 getSelectedGeneralWaterUploadsProperty(): array { $rows = []; foreach ($this->newGeneralWaterPhotos 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'); $size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0); $protocol = $this->resolveDraftUploadCode($file, $index); $metadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( app(TicketAttachmentUploadService::class)->inspectUpload($file), $this->resolveClientCaptureMetadata($name, $size), ); $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' => $size, 'metadata' => is_array($metadata) ? $metadata : [], ]; } return $rows; } /** * @return array|null */ public function getSelectedServiceSnapshotProperty(): ?array { if (! $this->servizioId) { return null; } $servizio = StabileServizio::query() ->with(['fornitore:id,ragione_sociale']) ->where('id', (int) $this->servizioId) ->where('stabile_id', (int) ($this->stabileId ?? 0)) ->first(); if (! $servizio instanceof StabileServizio) { return null; } $smsParts = array_filter([ trim((string) ($servizio->codice_utenza ?? '')), trim((string) ($servizio->codice_cliente ?? '')), trim((string) ($this->letturaAttuale ?? '')), ]); return [ 'id' => (int) $servizio->id, 'nome' => trim((string) ($servizio->nome ?? '')), 'fornitore' => trim((string) ($servizio->fornitore?->ragione_sociale ?? '')), 'codice_utenza' => trim((string) ($servizio->codice_utenza ?? '')), 'codice_cliente' => trim((string) ($servizio->codice_cliente ?? '')), 'codice_contratto' => trim((string) ($servizio->codice_contratto ?? '')), 'matricola' => trim((string) ($servizio->contatore_matricola ?? '')), 'common_asset' => trim((string) data_get($servizio->meta, 'common_asset_label', '')), 'counter_scope' => trim((string) data_get($servizio->meta, 'counter_scope', '')), 'served_area' => trim((string) data_get($servizio->meta, 'served_area_label', '')), 'acea_sms_preview' => count($smsParts) === 3 ? implode('#', $smsParts) : '', 'year_folder' => (string) Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), ]; } /** * @return array> */ public function getRecentGeneralReadingsProperty(): array { if (! $this->servizioId) { return []; } return StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $this->servizioId) ->whereNull('unita_immobiliare_id') ->orderByDesc('periodo_al') ->orderByDesc('id') ->limit(6) ->get(['id', 'periodo_al', 'lettura_fine', 'consumo_valore', 'rilevatore_nome', 'workflow_stato', 'protocollo_numero']) ->map(fn(StabileServizioLettura $row): array=> [ 'id' => (int) $row->id, 'date' => $row->periodo_al?->format('d/m/Y') ?: optional($row->created_at)->format('d/m/Y'), 'value' => $row->lettura_fine, 'consumo' => $row->consumo_valore, 'reader' => trim((string) ($row->rilevatore_nome ?? '')), 'workflow' => trim((string) ($row->workflow_stato ?? '')), 'protocollo' => trim((string) ($row->protocollo_numero ?? '')), ]) ->all(); } /** * @return array|null */ public function getLatestInvoiceReadingWindowProperty(): ?array { $rows = $this->recentGeneralInvoiceExtractions; foreach ($rows as $row) { if (! empty($row['window_label']) || ! empty($row['window_message'])) { return $row; } } return null; } /** * @return array> */ public function getRecentGeneralInvoiceExtractionsProperty(): array { if (! $this->servizioId || ! $this->stabileId) { return []; } $servizio = StabileServizio::query() ->where('id', (int) $this->servizioId) ->where('stabile_id', (int) $this->stabileId) ->where('tipo', 'riscaldamento') ->first(['id', 'stabile_id', 'codice_utenza', 'codice_cliente', 'codice_contratto', 'contatore_matricola']); if (! $servizio instanceof StabileServizio) { return []; } $identifiers = array_values(array_filter(array_unique([ $this->normalizeRiscaldamentoIdentifier((string) ($servizio->codice_utenza ?? '')), $this->normalizeRiscaldamentoIdentifier((string) ($servizio->codice_cliente ?? '')), $this->normalizeRiscaldamentoIdentifier((string) ($servizio->codice_contratto ?? '')), $this->normalizeRiscaldamentoIdentifier((string) ($servizio->contatore_matricola ?? '')), ]))); $fallbackSingleService = $identifiers === [] && StabileServizio::query() ->where('stabile_id', (int) $this->stabileId) ->where('tipo', 'riscaldamento') ->where('attivo', true) ->count() === 1; if ($identifiers === [] && ! $fallbackSingleService) { return []; } return FatturaElettronica::query() ->where('stabile_id', (int) $this->stabileId) ->whereNotNull('consumo_raw') ->orderByDesc('data_fattura') ->orderByDesc('id') ->limit(40) ->get(['id', 'numero_fattura', 'data_fattura', 'consumo_raw', 'consumo_riferimento', 'consumo_valore']) ->map(function (FatturaElettronica $fattura) use ($identifiers, $fallbackSingleService): ?array { $raw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : ''; if ($raw === '') { return null; } $payload = json_decode($raw, true); if (! is_array($payload)) { return null; } $payloadIdentifiers = array_values(array_filter(array_unique([ $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'codici.utenza', '')), $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'codici.cliente', '')), $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'codici.contratto', '')), $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'contatore.matricola', '')), ]))); $looksLikeRiscaldamentoPayload = $this->looksLikeRiscaldamentoPayload($payload); if ($payloadIdentifiers === [] && ! $fallbackSingleService) { return null; } if (! $fallbackSingleService && array_intersect($identifiers, $payloadIdentifiers) === []) { return null; } if ($fallbackSingleService && ! $looksLikeRiscaldamentoPayload) { return null; } $windowStart = $this->formatIsoDate((string) data_get($payload, 'finestra_autolettura.dal', '')); $windowEnd = $this->formatIsoDate((string) data_get($payload, 'finestra_autolettura.al', '')); $windowLabel = trim(implode(' - ', array_filter([$windowStart, $windowEnd]))); $readings = collect(is_array($payload['riepilogo_letture'] ?? null) ? $payload['riepilogo_letture'] : []) ->take(3) ->map(function (array $row): string { $tipo = trim((string) ($row['tipologia'] ?? $row['tipo'] ?? '')); $letturaValue = is_numeric($row['valore_a'] ?? null) ? (float) $row['valore_a'] : (is_numeric($row['valore_da'] ?? null) ? (float) $row['valore_da'] : null); $lettura = $letturaValue !== null ? number_format($letturaValue, 3, ',', '.') : 'n/d'; $data = $this->formatIsoDate((string) ($row['data_a'] ?? $row['data_da'] ?? $row['data'] ?? '')); $consumo = is_numeric($row['consumo'] ?? null) ? number_format((float) $row['consumo'], 3, ',', '.') . ' mc' : null; return trim(implode(' ยท ', array_filter([$tipo !== '' ? ucfirst($tipo) : 'Lettura', $lettura . ' mc', $data, $consumo]))); }) ->filter(fn(string $value): bool => $value !== '') ->values() ->all(); return [ 'id' => (int) $fattura->id, 'date' => optional($fattura->data_fattura)->format('d/m/Y'), 'numero_fattura' => trim((string) ($fattura->numero_fattura ?? '')), 'periodo_label' => trim((string) ($fattura->consumo_riferimento ?? '')), 'consumo_mc' => is_numeric($fattura->consumo_valore) ? (float) $fattura->consumo_valore : null, 'window_label' => $windowLabel !== '' ? $windowLabel : null, 'window_message' => trim((string) data_get($payload, 'finestra_autolettura.messaggio', '')), 'readings' => $readings, 'fe_url' => FatturaElettronicaScheda::getUrl(['record' => (int) $fattura->id], panel: 'admin-filament'), ]; }) ->filter() ->take(6) ->values() ->all(); } public function getGeneralReadingArchiveUrlProperty(): string { return ServiziStabileArchivio::getUrl([ 'tab' => 'generale', ], panel: 'admin-filament'); } public function getServiceReadingArchiveUrlProperty(): string { return LettureServiziArchivio::getUrl([ 'servizio' => (int) ($this->servizioId ?? 0), ], panel: 'admin-filament'); } public function registraLetturaGenerale(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $validated = $this->validate([ 'stabileId' => ['required', 'integer'], 'servizioId' => ['required', 'integer'], 'letturaAttuale' => ['required', 'numeric', 'min:0'], 'dataLettura' => ['required', 'date'], 'rilevatoreNome' => ['required', 'string', 'max:255'], 'rilevatoreTipo' => ['nullable', 'string', 'max:80'], 'aceaWindowStart' => ['nullable', 'date'], 'aceaWindowEnd' => ['nullable', 'date', 'after_or_equal:aceaWindowStart'], 'aceaVisitDate' => ['nullable', 'date'], 'aceaVisitTimeFrom' => ['nullable', 'date_format:H:i'], 'aceaVisitTimeTo' => ['nullable', 'date_format:H:i'], 'noteGenerali' => ['nullable', 'string', 'max:4000'], 'newGeneralWaterPhotos.*' => ['nullable', 'image', 'max:8192'], ], [ 'servizioId.required' => 'Seleziona il contratto/servizio riscaldamento da usare per la lettura generale.', 'letturaAttuale.required' => 'Inserisci la lettura attuale del contatore generale.', 'dataLettura.required' => 'Indica la data effettiva della lettura.', 'rilevatoreNome.required' => 'Seleziona o inserisci il nominativo del letturista.', 'aceaWindowEnd.after_or_equal' => 'La fine finestra autolettura non puo essere precedente all\'inizio.', ]); $servizio = StabileServizio::query() ->where('id', (int) $validated['servizioId']) ->where('stabile_id', (int) $validated['stabileId']) ->where('tipo', 'riscaldamento') ->where('attivo', true) ->first(); if (! $servizio instanceof StabileServizio) { Notification::make() ->title('Contratto riscaldamento non valido') ->body('Controlla lo stabile e il contratto Acea selezionato prima di registrare la lettura generale.') ->warning() ->send(); return; } $readingDate = Carbon::parse((string) $validated['dataLettura']); $protocol = $this->generateGeneralReadingProtocol($readingDate); $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $servizio->id) ->whereNull('unita_immobiliare_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; $consistency = $this->buildReadingConsistencyCheck((int) $servizio->id, null, $letturaFine, $letturaInizio); if ($consumo !== null && $consumo < 0) { $this->addError('letturaAttuale', 'La lettura attuale non puo essere inferiore alla precedente del contatore generale.'); return; } $storageDirectory = $this->buildGeneralWaterStorageDirectory($servizio, $readingDate); $photoAssets = []; foreach ($this->newGeneralWaterPhotos as $index => $file) { if (! is_object($file) || ! method_exists($file, 'store')) { continue; } $draftCode = $this->resolveDraftUploadCode($file, $index); $naming = $this->buildGeneralWaterPhotoNaming($servizio, $protocol, $draftCode, (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[] = [ 'path' => (string) ($stored['path'] ?? ''), 'original_name' => (string) ($stored['original_name'] ?? ''), 'metadata' => $storedMetadata, 'description' => trim((string) ($this->generalWaterPhotoDescriptions[$index] ?? '')), 'drive_directory' => (string) ($naming['drive_relative_directory'] ?? ''), 'display_name' => (string) ($naming['display_name'] ?? ''), 'draft_protocol' => $draftCode, ]; } $reference = 'TICKET-RISCALDAMENTO-GENERALE:' . (int) $servizio->id . ':' . now()->format('YmdHis'); $readerLabel = trim((string) $validated['rilevatoreNome']); $snapshot = $this->getSelectedServiceSnapshotProperty(); $row = StabileServizioLettura::query()->create([ 'stabile_id' => (int) $validated['stabileId'], 'stabile_servizio_id' => (int) $servizio->id, 'fornitore_id' => $servizio->fornitore_id, 'periodo_dal' => $previous?->periodo_al, 'periodo_al' => $readingDate, 'tipologia_lettura' => 'autolettura_contatore_generale', 'canale_acquisizione' => 'filament_ticket_riscaldamento_generale', 'riferimento_acquisizione' => $reference, 'workflow_stato' => 'ricevuta', 'protocollo_categoria' => 'ACQ-GEN', 'protocollo_numero' => $protocol, 'prossima_lettura_scadenza_at' => filled($validated['aceaVisitDate'] ?? null) ? Carbon::parse((string) $validated['aceaVisitDate']) : null, 'deadline_lettura_at' => filled($validated['aceaWindowEnd'] ?? null) ? Carbon::parse((string) $validated['aceaWindowEnd']) : (filled($validated['aceaVisitDate'] ?? null) ? Carbon::parse((string) $validated['aceaVisitDate']) : null), 'rilevatore_tipo' => trim((string) ($validated['rilevatoreTipo'] ?? '')) ?: $this->inferReaderType(), 'rilevatore_nome' => $readerLabel, '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' => $photoAssets[0]['metadata'] ?? null, 'consumo_valore' => $consumo, 'consumo_unita' => 'mc', 'archivio_documentale_path' => $photoAssets[0]['drive_directory'] ?? null, 'raw' => [ 'source' => 'ticket_riscaldamento_generale', 'service_snapshot' => $snapshot, 'reader_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, 'reader_assignment' => [ 'user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, 'name' => $readerLabel, 'type' => $this->inferReaderType(), ], 'acea_schedule' => [ 'window_start' => $validated['aceaWindowStart'] ?? null, 'window_end' => $validated['aceaWindowEnd'] ?? null, 'visit_date' => $validated['aceaVisitDate'] ?? null, 'visit_time_from' => $validated['aceaVisitTimeFrom'] ?? null, 'visit_time_to' => $validated['aceaVisitTimeTo'] ?? null, 'channel' => $this->aceaAutoSendChannel, ], 'acea_submission' => [ 'sms_number' => '3399941808', 'call_center' => '800 130 331', 'sms_payload' => $snapshot['acea_sms_preview'] ?? null, 'my_acea_url' => 'https://www.aceaato2.it', ], 'consistency_check' => $consistency, 'notes' => trim((string) ($validated['noteGenerali'] ?? '')), 'photos' => $photoAssets, ], 'created_by' => (int) $user->id, ]); $this->syncGeneralWaterScadenze($row, $servizio, $readerLabel); $this->resetGeneralWaterDraftUploadState(); $this->letturaAttuale = null; $this->noteGenerali = null; $this->dispatch('ticket-riscaldamento-generale-draft-reset'); $this->loadLastGeneralReading(); Notification::make() ->title('Lettura contatore comune registrata') ->body('Protocollo ' . $protocol . ' salvato per il contratto selezionato.') ->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) ->map(fn(Stabile $stabile): array=> [ 'id' => (int) $stabile->id, 'label' => trim((string) ($stabile->codice_stabile ?? '') . ' - ' . (string) ($stabile->denominazione ?? '')), ]) ->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 applyRequestSelection(): void { $requestedStabile = request()->integer('stabile'); $requestedServizio = request()->integer('servizio'); $stabileIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->stabileOptions); if ($requestedStabile > 0 && in_array($requestedStabile, $stabileIds, true) && $requestedStabile !== (int) $this->stabileId) { $this->stabileId = $requestedStabile; $this->loadServiceOptions(); } $serviceIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->servizioOptions); if ($requestedServizio > 0 && in_array($requestedServizio, $serviceIds, true)) { $this->servizioId = $requestedServizio; } } private function loadServiceOptions(): void { if (! $this->stabileId) { $this->servizioOptions = []; $this->servizioId = null; return; } $this->servizioOptions = StabileServizio::query() ->where('stabile_id', (int) $this->stabileId) ->where('tipo', 'riscaldamento') ->where('attivo', true) ->orderBy('nome') ->orderBy('codice_utenza') ->get(['id', 'nome', 'codice_utenza', 'codice_cliente', 'codice_contratto', 'contatore_matricola', 'meta']) ->map(function (StabileServizio $servizio): array { $parts = [trim((string) ($servizio->nome ?: 'Contratto riscaldamento #' . $servizio->id))]; $commonAsset = trim((string) data_get($servizio->meta, 'common_asset_label', '')); $servedArea = trim((string) data_get($servizio->meta, 'served_area_label', '')); $counterType = trim((string) data_get($servizio->meta, 'counter_scope', '')); if ($commonAsset !== '') { $parts[] = $commonAsset; } if ($servedArea !== '') { $parts[] = $servedArea; } if ($counterType !== '') { $parts[] = 'Contatore ' . $this->formatCounterScopeLabel($counterType); } if (filled($servizio->codice_contratto)) { $parts[] = 'Contratto ' . trim((string) $servizio->codice_contratto); } if (filled($servizio->codice_utenza)) { $parts[] = 'Utenza ' . trim((string) $servizio->codice_utenza); } if (filled($servizio->contatore_matricola)) { $parts[] = 'Contatore ' . trim((string) $servizio->contatore_matricola); } return [ 'id' => (int) $servizio->id, 'label' => implode(' - ', array_unique(array_filter($parts))), ]; }) ->values() ->all(); $serviceIds = array_column($this->servizioOptions, 'id'); if (! in_array((int) ($this->servizioId ?? 0), $serviceIds, true)) { $this->servizioId = $serviceIds[0] ?? null; } } private function loadReaderOptions(): void { $user = Auth::user(); if (! $user instanceof User || ! $this->stabileId) { $this->readerOptions = []; return; } $stabile = Stabile::query() ->with(['amministratore.user:id,name', 'collaboratori:id,name']) ->find((int) $this->stabileId); $options = []; $push = static function (array &$options, ?User $candidate, string $tipo): void { if (! $candidate instanceof User) { return; } $options[(int) $candidate->id] = [ 'id' => (int) $candidate->id, 'label' => trim((string) $candidate->name), 'tipo' => $tipo, ]; }; $push($options, $user, $user->hasRole('collaboratore') ? 'collaboratore' : 'amministrazione'); $push($options, $stabile?->amministratore?->user, 'amministrazione'); foreach (($stabile?->collaboratori ?? collect()) as $collaboratore) { $push($options, $collaboratore, 'collaboratore'); } uasort($options, static fn(array $left, array $right): int => strcasecmp((string) $left['label'], (string) $right['label'])); $this->readerOptions = array_values($options); $readerIds = array_column($this->readerOptions, 'id'); if (! in_array((int) ($this->rilevatoreUserId ?? 0), $readerIds, true)) { $this->rilevatoreUserId = (int) $user->id; $this->updatedRilevatoreUserId(); } } private function loadLastGeneralReading(): void { $this->lastGeneralReading = null; if (! $this->servizioId) { return; } $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $this->servizioId) ->whereNull('unita_immobiliare_id') ->whereNotNull('lettura_fine') ->orderByDesc('periodo_al') ->orderByDesc('id') ->first(); if (! $previous instanceof StabileServizioLettura) { return; } $this->lastGeneralReading = [ 'id' => (int) $previous->id, 'date' => $previous->periodo_al?->format('d/m/Y'), 'value' => $previous->lettura_fine, 'reader' => trim((string) ($previous->rilevatore_nome ?? '')), 'workflow' => trim((string) ($previous->workflow_stato ?? '')), 'protocol' => trim((string) ($previous->protocollo_numero ?? '')), ]; } private function appendPendingGeneralWaterPhotos(): void { $pending = array_values(array_filter($this->pendingGeneralWaterPhotos, fn($file) => is_object($file))); if ($pending === []) { return; } $available = max(0, 4 - count($this->newGeneralWaterPhotos)); if ($available <= 0) { $this->pendingGeneralWaterPhotos = []; return; } $accepted = array_slice($pending, 0, $available); $currentTotal = count($this->newGeneralWaterPhotos); $this->newGeneralWaterPhotos = array_values(array_merge($this->newGeneralWaterPhotos, $accepted)); $this->pendingGeneralWaterPhotos = []; $this->assignDraftUploadCodes($accepted, $currentTotal); $this->syncGeneralWaterDescriptionSlots(); } private function resetGeneralWaterDraftUploadState(): void { $this->newGeneralWaterPhotos = []; $this->pendingGeneralWaterPhotos = []; $this->generalWaterPhotoDescriptions = []; $this->generalWaterUploadCodes = []; $this->generalWaterClientMetadata = []; $this->generalWaterDraftProtocol = 'TAG-' . now()->format('Ymd-His'); $this->generalWaterDraftSequence = 1; } private function syncGeneralWaterDescriptionSlots(): void { $next = []; foreach ($this->newGeneralWaterPhotos as $index => $_file) { $next[$index] = (string) ($this->generalWaterPhotoDescriptions[$index] ?? ''); } $this->generalWaterPhotoDescriptions = $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->generalWaterUploadCodes[$key])) { continue; } $this->generalWaterUploadCodes[$key] = $this->buildDraftProtocolCode($this->generalWaterDraftSequence); $this->generalWaterDraftSequence++; } } private function resolveDraftUploadCode(object $file, int $fallbackIndex): string { $key = $this->resolveUploadQueueKey($file, $fallbackIndex); if (! isset($this->generalWaterUploadCodes[$key])) { $this->generalWaterUploadCodes[$key] = $this->buildDraftProtocolCode($this->generalWaterDraftSequence); $this->generalWaterDraftSequence++; } return $this->generalWaterUploadCodes[$key]; } private function buildDraftProtocolCode(int $sequence): string { return $this->generalWaterDraftProtocol . '-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) ?: 'general-water-upload-' . $fallbackIndex; } private function generateGeneralReadingProtocol(Carbon $readingDate): string { $year = $readingDate->format('Y'); $next = StabileServizioLettura::query() ->where('tipologia_lettura', 'autolettura_contatore_generale') ->whereYear('created_at', (int) $year) ->count() + 1; return 'ACQ-GEN-' . $year . '-' . str_pad((string) $next, 4, '0', STR_PAD_LEFT); } private function buildGeneralWaterStorageDirectory(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))); return 'condomini/' . $this->normalizeLinuxPathSegment($stabileLabel) . '/utenze/riscaldamento/generale/' . $readingDate->format('Y'); } /** * @return array{stored_basename:string,display_name:string,drive_relative_directory:string} */ private function buildGeneralWaterPhotoNaming(StabileServizio $servizio, string $protocol, string $draftCode, string $originalName, Carbon $readingDate): array { $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); $suffix = $extension !== '' ? '.' . $extension : ''; $serviceKey = $this->normalizeLinuxPathSegment((string) ($servizio->codice_contratto ?: $servizio->codice_utenza ?: $servizio->id)); $base = trim(implode('_', array_filter([ 'contatore_generale', $serviceKey, Str::lower(str_replace(['.', ' '], '-', $protocol)), Str::lower(str_replace(['.', ' '], '-', $draftCode)), ])), '_'); return [ 'stored_basename' => $base !== '' ? $base : ('contatore-generale-' . $readingDate->format('YmdHis')), 'display_name' => $protocol . '-' . $draftCode . $suffix, 'drive_relative_directory' => $this->buildGeneralWaterStorageDirectory($servizio, $readingDate), ]; } 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'; } private function normalizeRiscaldamentoIdentifier(string $value): string { $normalized = strtoupper(trim($value)); return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? ''; } private function formatIsoDate(string $value): ?string { $value = trim($value); if ($value === '') { return null; } try { return Carbon::parse($value)->format('d/m/Y'); } catch (Throwable) { return $value; } } /** @param array $payload */ private function looksLikeRiscaldamentoPayload(array $payload): bool { if (strtolower(trim((string) ($payload['type'] ?? ''))) === 'riscaldamento') { return true; } return is_array($payload['consumi'] ?? null) || is_array($payload['riepilogo_letture'] ?? null) || is_array($payload['finestra_autolettura'] ?? null) || is_array($payload['codici'] ?? null); } /** * @return array */ private function resolveClientCaptureMetadata(string $originalName, int $size): array { foreach ($this->generalWaterClientMetadata as $item) { if (($item['name'] ?? '') === $originalName && (int) ($item['size'] ?? -1) === $size) { return $item; } } foreach ($this->generalWaterClientMetadata as $item) { if (($item['name'] ?? '') === $originalName) { return $item; } } return []; } private function inferReaderType(): string { foreach ($this->readerOptions as $option) { if ((int) ($option['id'] ?? 0) === (int) ($this->rilevatoreUserId ?? 0)) { return (string) ($option['tipo'] ?? 'amministrazione'); } } return $this->rilevatoreTipo ?: 'amministrazione'; } /** * @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 questo contatore.', 'delta' => null, 'average' => null, 'rule' => 'first_reading', ]; } $delta = round($letturaFine - $letturaInizio, 3); if ($delta < 0) { return [ 'status' => 'danger', 'summary' => 'La lettura inserita e 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 e fermo o se serve un controllo.', 'delta' => $delta, 'average' => 0.0, 'rule' => 'same_as_previous', ]; } $history = StabileServizioLettura::query() ->where('stabile_servizio_id', $servizioId) ->when($unitaId === null, fn($query) => $query->whereNull('unita_immobiliare_id')) ->when($unitaId !== null, fn($query) => $query->where('unita_immobiliare_id', (int) $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 piu 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 formatCounterScopeLabel(string $value): string { return match (strtolower(trim($value))) { 'generale' => 'generale', 'particolare' => 'particolare', 'assente' => 'senza contatore', default => trim($value) !== '' ? trim($value) : 'n/d', }; } private function syncGeneralWaterScadenze(StabileServizioLettura $reading, StabileServizio $servizio, string $readerLabel): void { if (! Schema::hasTable('scadenze')) { return; } $serviceName = trim((string) ($servizio->nome ?: ('Contratto #' . $servizio->id))); $contractBits = array_filter([ trim((string) ($servizio->codice_contratto ?? '')), trim((string) ($servizio->codice_utenza ?? '')), ]); $contractLabel = $contractBits !== [] ? ' [' . implode(' / ', $contractBits) . ']' : ''; if (filled($this->aceaWindowStart)) { $this->createScadenza([ 'stabile_id' => (int) $reading->stabile_id, 'codice_stabile_legacy' => $reading->stabile?->codice_stabile, 'descrizione_sintetica' => 'Finestra autolettura Acea ' . $serviceName . $contractLabel . (filled($this->aceaWindowEnd) ? ' fino al ' . Carbon::parse((string) $this->aceaWindowEnd)->format('d/m/Y') : ''), 'data_scadenza' => Carbon::parse((string) $this->aceaWindowStart)->toDateString(), 'ora_scadenza' => null, 'natura' => 'riscaldamento', 'natura_label' => 'Autolettura riscaldamento generale', 'gestione_tipo' => 'operativa', 'anno' => Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), 'richiede_pop_up' => true, 'giorni_anticipo' => 2, 'popup_dal' => Carbon::parse((string) $this->aceaWindowStart)->copy()->subDays(2)->toDateString(), 'legacy_payload' => [ 'source' => 'ticket_riscaldamento_generale', 'schedule_type' => 'window', 'stabile_servizio_id' => (int) $servizio->id, 'reading_id' => (int) $reading->id, 'reading_reference' => $reading->riferimento_acquisizione, 'window_end' => $this->aceaWindowEnd, 'assigned_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, 'assigned_user_name' => $readerLabel, 'sms_payload' => data_get($reading->raw, 'acea_submission.sms_payload'), ], ]); } if (filled($this->aceaVisitDate)) { $this->createScadenza([ 'stabile_id' => (int) $reading->stabile_id, 'codice_stabile_legacy' => $reading->stabile?->codice_stabile, 'descrizione_sintetica' => 'Passaggio Acea contatore generale ' . $serviceName . $contractLabel . ' fascia ' . trim((string) $this->aceaVisitTimeFrom) . '-' . trim((string) $this->aceaVisitTimeTo), 'data_scadenza' => Carbon::parse((string) $this->aceaVisitDate)->toDateString(), 'ora_scadenza' => $this->aceaVisitTimeFrom, 'natura' => 'riscaldamento', 'natura_label' => 'Passaggio Acea contatore generale', 'gestione_tipo' => 'operativa', 'anno' => Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), 'richiede_pop_up' => true, 'giorni_anticipo' => 1, 'popup_dal' => Carbon::parse((string) $this->aceaVisitDate)->copy()->subDay()->toDateString(), 'legacy_payload' => [ 'source' => 'ticket_riscaldamento_generale', 'schedule_type' => 'visit', 'stabile_servizio_id' => (int) $servizio->id, 'reading_id' => (int) $reading->id, 'reading_reference' => $reading->riferimento_acquisizione, 'visit_time_from' => $this->aceaVisitTimeFrom, 'visit_time_to' => $this->aceaVisitTimeTo, 'assigned_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, 'assigned_user_name' => $readerLabel, ], ]); } } /** * @param array $payload */ private function createScadenza(array $payload): void { Scadenza::query()->create($payload); } }