From 7dd54f3a6f4904b4d7afaef3ee78ea78e4cd8299 Mon Sep 17 00:00:00 2001 From: michele Date: Mon, 13 Apr 2026 14:31:17 +0000 Subject: [PATCH] Finalize water workflows and shared asset management --- .../Commands/NetgesconNodeRefreshCommand.php | 3 +- .../Condomini/LettureServiziArchivio.php | 270 +++++++++++-- .../Pages/Condomini/NominativiStabile.php | 4 +- .../Condomini/ServiziStabileArchivio.php | 335 +++++++++++++--- .../Pages/Fornitore/ImpostazioniArchivio.php | 36 +- .../Pages/Fornitore/RubricaClienti.php | 4 +- .../Fornitore/TicketInterventoScheda.php | 22 +- .../Pages/Gescon/FornitoriArchivio.php | 2 +- app/Filament/Pages/Supporto/Modifiche.php | 2 +- app/Filament/Pages/Supporto/TicketAcqua.php | 258 ++++++++---- .../Pages/Supporto/TicketAcquaGenerale.php | 372 ++++++++++++------ .../Pages/Supporto/TicketAcquaLight.php | 2 +- .../Pages/Supporto/TicketGestione.php | 6 +- app/Filament/Pages/Supporto/TicketMobile.php | 8 +- .../Condomino/TicketController.php | 24 +- .../WaterReadingRequestController.php | 54 +-- ..._operational_config_to_fornitori_table.php | 2 +- ...d_metadata_to_ticket_attachments_table.php | 2 +- .../GERMANICO79-POSTA-ACQUA-2026-04.md | 2 +- .../servizi-stabile-archivio.blade.php | 4 +- .../supporto/ticket-acqua-generale.blade.php | 15 +- 21 files changed, 1047 insertions(+), 380 deletions(-) diff --git a/app/Console/Commands/NetgesconNodeRefreshCommand.php b/app/Console/Commands/NetgesconNodeRefreshCommand.php index 76eb95d..92395a8 100644 --- a/app/Console/Commands/NetgesconNodeRefreshCommand.php +++ b/app/Console/Commands/NetgesconNodeRefreshCommand.php @@ -1,5 +1,4 @@ $exitCode, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } -} \ No newline at end of file +} diff --git a/app/Filament/Pages/Condomini/LettureServiziArchivio.php b/app/Filament/Pages/Condomini/LettureServiziArchivio.php index cb0d664..f5ab3d2 100644 --- a/app/Filament/Pages/Condomini/LettureServiziArchivio.php +++ b/app/Filament/Pages/Condomini/LettureServiziArchivio.php @@ -27,7 +27,9 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use UnitEnum; class LettureServiziArchivio extends Page implements HasTable @@ -37,9 +39,9 @@ class LettureServiziArchivio extends Page implements HasTable public ?int $servizioFilter = null; public string $archivioScope = 'active'; - protected static ?string $navigationLabel = 'Letture servizi'; + protected static ?string $navigationLabel = 'Letture servizi / contatori'; - protected static ?string $title = 'Letture servizi'; + protected static ?string $title = 'Letture servizi / contatori'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list'; @@ -127,7 +129,7 @@ protected function getTableQuery(): Builder ->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter)) ->with([ 'stabile:id,codice_stabile,denominazione', - 'servizio:id,stabile_id,tipo,nome,contatore_matricola', + 'servizio:id,stabile_id,tipo,nome,contatore_matricola,meta', 'fornitore:id,ragione_sociale', 'voceSpesa:id,descrizione,tipo_gestione', 'unitaImmobiliare:id,codice_unita,denominazione,interno,scala,piano', @@ -168,6 +170,11 @@ public function table(Table $table): Table 'assicurazione' => 'Assicurazione', 'privacy' => 'Privacy', 'antenna' => 'Antenna', + 'antincendio' => 'Antincendio', + 'citofonia' => 'Citofonia', + 'locale_comune' => 'Locale comune', + 'spazio_comune' => 'Spazio / area comune', + 'impianto' => 'Impianto comune', 'altro' => 'Altro', ]) ->query(function (Builder $query, array $data): Builder { @@ -241,24 +248,19 @@ public function table(Table $table): Table TextColumn::make('servizio.contatore_matricola')->label('Matricola')->toggleable(isToggledHiddenByDefault: true), TextColumn::make('unitaImmobiliare.denominazione') - ->label('Unità immobiliare') + ->label('Attribuzione') ->formatStateUsing(function ($state, StabileServizioLettura $record): string { - $ui = $record->unitaImmobiliare; - if (! $ui) { - return '—'; - } - - $cod = trim((string) ($ui->codice_unita ?? '')); - $den = trim((string) ($ui->denominazione ?? '')); - if ($cod !== '' && $den !== '') { - return $cod . ' - ' . $den; - } - - return $cod !== '' ? $cod : ($den !== '' ? $den : ('UI #' . $ui->id)); + return $this->resolveReadingAssignmentLabel($record); }) ->wrap() ->toggleable(), + TextColumn::make('rilevatore_nome') + ->label('Letturista / nominativo') + ->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—') + ->wrap() + ->toggleable(), + TextColumn::make('consumo_valore') ->label('Consumo') ->formatStateUsing(function ($state, StabileServizioLettura $record): string { @@ -301,6 +303,35 @@ public function table(Table $table): Table ->badge() ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('raw.consistency_check.summary') + ->label('Check lettura') + ->formatStateUsing(function ($state, StabileServizioLettura $record): string { + $summary = trim((string) data_get($record->raw, 'consistency_check.summary', '')); + return $summary !== '' ? $summary : '—'; + }) + ->badge() + ->color(function ($state, StabileServizioLettura $record): string { + return match ((string) data_get($record->raw, 'consistency_check.status', '')) { + 'warning' => 'warning', + 'danger' => 'danger', + default => 'gray', + }; + }) + ->wrap() + ->toggleable(), + + TextColumn::make('lettura_foto_original_name') + ->label('Foto') + ->formatStateUsing(function ($state, StabileServizioLettura $record): string { + $photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : []; + if ($photos !== []) { + return count($photos) . ' foto'; + } + + return filled($record->lettura_foto_path) ? '1 foto' : '—'; + }) + ->toggleable(), + TextColumn::make('protocollo_numero') ->label('Protocollo') ->formatStateUsing(function ($state, StabileServizioLettura $record): string { @@ -528,6 +559,78 @@ public function table(Table $table): Table }), ]) ->actions([ + Action::make('apriFoto') + ->label('Foto') + ->icon('heroicon-o-photo') + ->url(fn(StabileServizioLettura $record): ?string => $this->resolveReadingPrimaryPhotoUrl($record)) + ->openUrlInNewTab() + ->visible(fn(StabileServizioLettura $record): bool => $this->resolveReadingPrimaryPhotoUrl($record) !== null), + + Action::make('mappaFoto') + ->label('Mappa') + ->icon('heroicon-o-map') + ->url(fn(StabileServizioLettura $record): ?string => $this->resolveReadingMapUrl($record)) + ->openUrlInNewTab() + ->visible(fn(StabileServizioLettura $record): bool => $this->resolveReadingMapUrl($record) !== null), + + Action::make('riattribuisci') + ->label('Riattribuisci') + ->icon('heroicon-o-user-circle') + ->form([ + Select::make('unita_immobiliare_id') + ->label('Unità / nominativo') + ->options(fn() => $this->getUnitaOptions()) + ->searchable() + ->placeholder('Contatore generale / bene comune dello stabile'), + Select::make('rilevatore_tipo') + ->label('Tipo rilevatore') + ->options($this->getReaderTypeOptions()), + TextInput::make('rilevatore_nome')->label('Nominativo rilevatore')->maxLength(191), + Textarea::make('motivo')->label('Nota riassegnazione')->rows(3)->maxLength(1000), + ]) + ->fillForm(fn(StabileServizioLettura $record): array => [ + 'unita_immobiliare_id' => $record->unita_immobiliare_id, + 'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? ''), + 'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''), + 'motivo' => '', + ]) + ->action(function (StabileServizioLettura $record, array $data): void { + $raw = is_array($record->raw ?? null) ? $record->raw : []; + $reassignments = is_array($raw['reassignments'] ?? null) ? $raw['reassignments'] : []; + $reassignments[] = [ + 'from_unita_immobiliare_id' => (int) ($record->getOriginal('unita_immobiliare_id') ?? 0) ?: null, + 'to_unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null, + 'from_reader_name' => (string) ($record->getOriginal('rilevatore_nome') ?? ''), + 'to_reader_name' => trim((string) ($data['rilevatore_nome'] ?? '')), + 'note' => trim((string) ($data['motivo'] ?? '')), + 'changed_by' => Auth::id(), + 'changed_at' => now()->toIso8601String(), + ]; + $raw['reassignments'] = $reassignments; + $raw['reader_assignment'] = [ + 'user_id' => null, + 'name' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, + 'type' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null, + ]; + + $record->fill([ + 'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null, + 'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null, + 'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, + 'lettura_precedente_valore' => null, + 'lettura_precedente_foto_path' => null, + 'raw' => $raw, + ]); + $record->save(); + $this->hydrateReadingWithPreviousData($record); + + Notification::make() + ->title('Attribuzione aggiornata') + ->body('La lettura è stata riallineata al nuovo nominativo/unità.') + ->success() + ->send(); + }), + Action::make('edit') ->label('Modifica') ->icon('heroicon-o-pencil-square') @@ -675,13 +778,17 @@ private function getServiziOptions(): array ->where('stabile_id', (int) $stabileId) ->orderBy('tipo') ->orderBy('nome') - ->get(['id', 'tipo', 'nome', 'contatore_matricola']) + ->get(['id', 'tipo', 'nome', 'contatore_matricola', 'meta']) ->mapWithKeys(function (StabileServizio $s): array { $tipo = strtoupper(trim((string) ($s->tipo ?? ''))); $nome = trim((string) ($s->nome ?? '')); $matr = trim((string) ($s->contatore_matricola ?? '')); + $asset = trim((string) data_get($s->meta, 'common_asset_label', '')); $label = $nome !== '' ? $nome : ($tipo !== '' ? $tipo : ('Servizio #' . $s->id)); + if ($asset !== '' && ! str_contains(mb_strtolower($label), mb_strtolower($asset))) { + $label .= ' - ' . $asset; + } if ($matr !== '') { $label .= ' (' . $matr . ')'; } @@ -782,16 +889,36 @@ private function getUnitaOptions(): array return []; } - return UnitaImmobiliare::query() + $units = UnitaImmobiliare::query() ->where('stabile_id', (int) $stabileId) ->orderBy('codice_unita') ->orderBy('interno') ->orderBy('id') ->get(['id', 'codice_unita', 'denominazione', 'interno']) - ->mapWithKeys(function (UnitaImmobiliare $u): array { + ->all(); + + $latestNominativi = []; + if ($units !== [] && Schema::hasTable('unita_immobiliare_nominativi')) { + $rows = DB::table('unita_immobiliare_nominativi') + ->whereIn('unita_immobiliare_id', array_map(fn(UnitaImmobiliare $unit): int => (int) $unit->id, $units)) + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->get(['unita_immobiliare_id', 'nominativo']); + + foreach ($rows as $row) { + $unitId = (int) ($row->unita_immobiliare_id ?? 0); + if ($unitId > 0 && ! isset($latestNominativi[$unitId])) { + $latestNominativi[$unitId] = trim((string) ($row->nominativo ?? '')); + } + } + } + + $options = collect($units) + ->mapWithKeys(function (UnitaImmobiliare $u) use ($latestNominativi): array { $cod = trim((string) ($u->codice_unita ?? '')); $den = trim((string) ($u->denominazione ?? '')); $int = trim((string) ($u->interno ?? '')); + $nom = trim((string) ($latestNominativi[(int) $u->id] ?? '')); $label = $cod !== '' ? $cod : ('UI #' . $u->id); if ($den !== '') { @@ -800,10 +927,33 @@ private function getUnitaOptions(): array if ($int !== '') { $label .= ' (Int. ' . $int . ')'; } + if ($nom !== '') { + $label .= ' · ' . $nom; + } return [(int) $u->id => $label]; }) ->all(); + + uasort($options, static fn(string $left, string $right): int => strnatcasecmp($left, $right)); + + return $options; + } + + /** + * @return array + */ + private function getReaderTypeOptions(): array + { + return [ + 'condomino' => 'Condomino', + 'inquilino' => 'Inquilino', + 'letturista' => 'Letturista', + 'collaboratore' => 'Collaboratore', + 'amministrazione' => 'Amministrazione', + 'operatore_filament' => 'Operatore Filament', + 'link_pubblico' => 'Link pubblico', + ]; } /** @@ -921,13 +1071,13 @@ private function markOverdueWaterReminders(): int private function hydrateReadingWithPreviousData(StabileServizioLettura $record): void { - if ($record->unita_immobiliare_id === null) { - return; - } - $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $record->stabile_servizio_id) - ->where('unita_immobiliare_id', (int) $record->unita_immobiliare_id) + ->when( + $record->unita_immobiliare_id === null, + fn(Builder $query) => $query->whereNull('unita_immobiliare_id'), + fn(Builder $query) => $query->where('unita_immobiliare_id', (int) $record->unita_immobiliare_id) + ) ->where('id', '!=', (int) $record->id) ->latest('periodo_al') ->latest('id') @@ -1045,4 +1195,76 @@ private function extractImageMetadata(string $path): array return $metadata; } + + private function resolveReadingAssignmentLabel(StabileServizioLettura $record): string + { + $ui = $record->unitaImmobiliare; + if ($ui instanceof UnitaImmobiliare) { + $cod = trim((string) ($ui->codice_unita ?? '')); + $den = trim((string) ($ui->denominazione ?? '')); + + return trim(($cod !== '' ? $cod : ('UI #' . $ui->id)) . ($den !== '' ? (' - ' . $den) : '')); + } + + $commonAsset = trim((string) data_get($record->servizio?->meta, 'common_asset_label', '')); + $servedArea = trim((string) data_get($record->servizio?->meta, 'served_area_label', '')); + $counterType = trim((string) data_get($record->servizio?->meta, 'counter_scope', '')); + + $parts = array_filter([ + $commonAsset, + $servedArea, + $counterType !== '' ? 'Contatore ' . $counterType : null, + ]); + + return $parts !== [] ? implode(' - ', $parts) : 'Contatore generale / bene comune'; + } + + private function resolveReadingPrimaryPhotoPath(StabileServizioLettura $record): ?string + { + if (filled($record->lettura_foto_path)) { + return (string) $record->lettura_foto_path; + } + + $photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : []; + foreach ($photos as $photo) { + $path = trim((string) ($photo['path'] ?? '')); + if ($path !== '') { + return $path; + } + } + + return null; + } + + private function resolveReadingPrimaryPhotoUrl(StabileServizioLettura $record): ?string + { + $path = $this->resolveReadingPrimaryPhotoPath($record); + if ($path === null || ! Storage::disk('public')->exists($path)) { + return null; + } + + return Storage::disk('public')->url($path); + } + + private function resolveReadingMapUrl(StabileServizioLettura $record): ?string + { + $lat = data_get($record->lettura_foto_metadata, 'gps_decimal.lat'); + $lng = data_get($record->lettura_foto_metadata, 'gps_decimal.lng'); + + if (is_numeric($lat) && is_numeric($lng)) { + return 'https://maps.google.com/?q=' . $lat . ',' . $lng; + } + + $photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : []; + foreach ($photos as $photo) { + $gpsLat = data_get($photo, 'metadata.gps_decimal.lat'); + $gpsLng = data_get($photo, 'metadata.gps_decimal.lng'); + + if (is_numeric($gpsLat) && is_numeric($gpsLng)) { + return 'https://maps.google.com/?q=' . $gpsLat . ',' . $gpsLng; + } + } + + return null; + } } diff --git a/app/Filament/Pages/Condomini/NominativiStabile.php b/app/Filament/Pages/Condomini/NominativiStabile.php index 04a50b1..dfa0564 100644 --- a/app/Filament/Pages/Condomini/NominativiStabile.php +++ b/app/Filament/Pages/Condomini/NominativiStabile.php @@ -883,7 +883,7 @@ public function table(Table $table): Table return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament'); }, shouldOpenInNewTab: true) - ->visible(fn($record): bool => (bool) $this->resolveRubricaIdForRow($record)), - ]); + ->visible(fn($record): bool => (bool) $this->resolveRubricaIdForRow($record)), + ]); } } diff --git a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php index 48d20f8..ec75afa 100644 --- a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php +++ b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php @@ -14,6 +14,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; @@ -35,9 +36,9 @@ class ServiziStabileArchivio extends Page implements HasTable { use InteractsWithTable; - protected static ?string $navigationLabel = 'Servizi / Utenze'; + protected static ?string $navigationLabel = 'Servizi / Beni comuni'; - protected static ?string $title = 'Servizi / Utenze'; + protected static ?string $title = 'Servizi / Beni comuni'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack'; @@ -94,7 +95,7 @@ public function prepareAcquaReadingCampaign(): void return; } - $year = $this->resolveActiveAnnoGestione(); + $year = $this->resolveActiveAnnoGestione(); $created = 0; $updated = 0; @@ -130,7 +131,7 @@ public function prepareAcquaReadingCampaign(): void ->first(); $requestReference = 'PUBREQ:' . (int) $servizio->id . ':' . (int) $unit->id . ':' . $year; - $requestPayload = [ + $requestPayload = [ 'stabile_id' => $stabileId, 'stabile_servizio_id' => (int) $servizio->id, 'unita_immobiliare_id' => (int) $unit->id, @@ -146,8 +147,8 @@ public function prepareAcquaReadingCampaign(): void 'lettura_precedente_valore' => $previous?->lettura_fine, 'lettura_inizio' => $previous?->lettura_fine, 'raw' => [ - 'source' => 'campagna_letture_acqua', - 'year' => $year, + 'source' => 'campagna_letture_acqua', + 'year' => $year, 'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')), ], ]; @@ -179,7 +180,7 @@ public function markAcquaReadingReminder(int $readingId): void $reading->fill([ 'sollecito_inviato_at' => now(), - 'workflow_stato' => 'sollecito_inviato', + 'workflow_stato' => 'sollecito_inviato', ]); $reading->save(); @@ -268,18 +269,11 @@ protected function getTableQuery(): Builder public function table(Table $table): Table { - $tipoOptions = [ - 'acqua' => 'Acqua', - 'energia' => 'Energia', - 'gas' => 'Gas', - 'riscaldamento' => 'Riscaldamento', - 'ascensore' => 'Ascensore', - 'pulizie' => 'Pulizie', - 'assicurazione' => 'Assicurazione', - 'privacy' => 'Privacy', - 'antenna' => 'Antenna', - 'altro' => 'Altro', - ]; + $tipoOptions = $this->getServiceTypeOptions(); + $commonScopeOptions = $this->getCommonAssetScopeOptions(); + $allocationOptions = $this->getAllocationScopeOptions(); + $fiscalScopeOptions = $this->getFiscalScopeOptions(); + $counterScopeOptions = $this->getCounterScopeOptions(); $gestioneOptions = [ 'ordinaria' => 'Ordinaria', @@ -294,6 +288,30 @@ public function table(Table $table): Table ->label('Tipo servizio') ->options($tipoOptions), + SelectFilter::make('common_asset_scope') + ->label('Ambito bene comune') + ->options($commonScopeOptions) + ->query(function (Builder $query, array $data): Builder { + $value = trim((string) ($data['value'] ?? '')); + if ($value === '') { + return $query; + } + + return $query->where('meta->common_asset_scope', $value); + }), + + SelectFilter::make('counter_scope') + ->label('Tipo contatore') + ->options($counterScopeOptions) + ->query(function (Builder $query, array $data): Builder { + $value = trim((string) ($data['value'] ?? '')); + if ($value === '') { + return $query; + } + + return $query->where('meta->counter_scope', $value); + }), + SelectFilter::make('tipo_gestione') ->label('Gestione (O/R/S)') ->options($gestioneOptions) @@ -309,11 +327,35 @@ public function table(Table $table): Table ->columns([ TextColumn::make('tipo') ->label('Tipo') - ->formatStateUsing(fn($state) => $tipoOptions[strtolower(trim((string) $state))] ?? (string) ($state ?? '—')) + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($tipoOptions, $state)) ->sortable(), TextColumn::make('nome')->label('Nome')->searchable()->wrap()->sortable(), + TextColumn::make('meta.common_asset_label') + ->label('Bene / locale') + ->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—') + ->searchable() + ->wrap() + ->toggleable(), + + TextColumn::make('meta.common_asset_scope') + ->label('Ambito') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($commonScopeOptions, $state)) + ->toggleable(), + + TextColumn::make('meta.allocation_scope') + ->label('Addebito') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($allocationOptions, $state)) + ->wrap() + ->toggleable(), + + TextColumn::make('meta.fiscal_scope') + ->label('Trattamento') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($fiscalScopeOptions, $state)) + ->wrap() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('fornitore.ragione_sociale')->label('Fornitore')->searchable()->wrap()->toggleable(), TextColumn::make('voceSpesa.descrizione')->label('Voce spesa')->searchable()->wrap()->toggleable(), @@ -350,6 +392,21 @@ public function table(Table $table): Table TextColumn::make('codice_contratto')->label('Contratto')->searchable()->toggleable(isToggledHiddenByDefault: true), TextColumn::make('contatore_matricola')->label('Matricola')->searchable()->toggleable(), + TextColumn::make('meta.counter_scope') + ->label('Contatore') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($counterScopeOptions, $state)) + ->toggleable(), + + IconColumn::make('meta.has_dedicated_meter') + ->label('Ded.') + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('meta.served_area_label') + ->label('Area servita') + ->wrap() + ->toggleable(isToggledHiddenByDefault: true), + IconColumn::make('attivo')->label('Attivo')->boolean()->sortable(), TextColumn::make('updated_at')->label('Aggiornato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true), ]) @@ -363,6 +420,16 @@ public function table(Table $table): Table ->options($tipoOptions) ->required(), TextInput::make('nome')->label('Nome')->maxLength(255), + TextInput::make('common_asset_label')->label('Bene comune / locale / servizio')->maxLength(255), + Select::make('common_asset_scope') + ->label('Ambito bene comune') + ->options($commonScopeOptions), + Select::make('allocation_scope') + ->label('Ripartizione / addebito') + ->options($allocationOptions), + Select::make('fiscal_scope') + ->label('Trattamento economico / fiscale') + ->options($fiscalScopeOptions), Select::make('fornitore_id') ->label('Fornitore') ->searchable() @@ -375,6 +442,12 @@ public function table(Table $table): Table TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255), TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255), TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255), + Toggle::make('has_dedicated_meter')->label('Contatore dedicato')->default(false), + Select::make('counter_scope') + ->label('Tipo contatore') + ->options($counterScopeOptions) + ->default('assente'), + TextInput::make('served_area_label')->label('Area servita / utilizzo')->maxLength(255), Select::make('canale_acquisizione') ->label('Canale acquisizione letture') ->options([ @@ -389,6 +462,7 @@ public function table(Table $table): Table TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(), TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(), Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false), + Textarea::make('operative_notes')->label('Note operative')->rows(3)->maxLength(2000), Toggle::make('attivo')->label('Attivo')->default(true), ]) ->action(function (array $data): void { @@ -405,7 +479,7 @@ public function table(Table $table): Table StabileServizio::query()->create([ 'stabile_id' => (int) $stabileId, 'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))), - 'nome' => trim((string) ($data['nome'] ?? '')), + 'nome' => $this->resolveServiceDisplayName($data), 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, 'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null, @@ -413,14 +487,7 @@ public function table(Table $table): Table 'codice_contratto' => trim((string) ($data['codice_contratto'] ?? '')) ?: null, 'contatore_matricola' => trim((string) ($data['contatore_matricola'] ?? '')) ?: null, 'attivo' => (bool) ($data['attivo'] ?? true), - 'meta' => [ - 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null, - 'email_raccolta_letture' => trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null, - 'whatsapp_raccolta_letture' => trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null, - 'costo_lettura_unitario' => is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null, - 'costo_ripartizione' => is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null, - 'ripartizione_esterna' => (bool) ($data['ripartizione_esterna'] ?? false), - ], + 'meta' => $this->buildServiceMetaPayload([], $data), ]); }), ]) @@ -444,6 +511,16 @@ public function table(Table $table): Table ->options($tipoOptions) ->required(), TextInput::make('nome')->label('Nome')->maxLength(255), + TextInput::make('common_asset_label')->label('Bene comune / locale / servizio')->maxLength(255), + Select::make('common_asset_scope') + ->label('Ambito bene comune') + ->options($commonScopeOptions), + Select::make('allocation_scope') + ->label('Ripartizione / addebito') + ->options($allocationOptions), + Select::make('fiscal_scope') + ->label('Trattamento economico / fiscale') + ->options($fiscalScopeOptions), Select::make('fornitore_id') ->label('Fornitore') ->searchable() @@ -456,6 +533,11 @@ public function table(Table $table): Table TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255), TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255), TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255), + Toggle::make('has_dedicated_meter')->label('Contatore dedicato')->default(false), + Select::make('counter_scope') + ->label('Tipo contatore') + ->options($counterScopeOptions), + TextInput::make('served_area_label')->label('Area servita / utilizzo')->maxLength(255), Select::make('canale_acquisizione') ->label('Canale acquisizione letture') ->options([ @@ -470,37 +552,40 @@ public function table(Table $table): Table TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(), TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(), Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false), + Textarea::make('operative_notes')->label('Note operative')->rows(3)->maxLength(2000), Toggle::make('attivo')->label('Attivo')->default(true), ]) ->fillForm(fn(StabileServizio $record): array=> [ 'tipo' => (string) ($record->tipo ?? ''), 'nome' => (string) ($record->nome ?? ''), + 'common_asset_label' => (string) (($record->meta['common_asset_label'] ?? '') ?: ''), + 'common_asset_scope' => (string) (($record->meta['common_asset_scope'] ?? '') ?: ''), + 'allocation_scope' => (string) (($record->meta['allocation_scope'] ?? '') ?: ''), + 'fiscal_scope' => (string) (($record->meta['fiscal_scope'] ?? '') ?: ''), 'fornitore_id' => $record->fornitore_id, 'voce_spesa_id' => $record->voce_spesa_id, 'codice_utenza' => (string) ($record->codice_utenza ?? ''), 'codice_cliente' => (string) ($record->codice_cliente ?? ''), 'codice_contratto' => (string) ($record->codice_contratto ?? ''), 'contatore_matricola' => (string) ($record->contatore_matricola ?? ''), + 'has_dedicated_meter' => (bool) ($record->meta['has_dedicated_meter'] ?? false), + 'counter_scope' => (string) (($record->meta['counter_scope'] ?? '') ?: ''), + 'served_area_label' => (string) (($record->meta['served_area_label'] ?? '') ?: ''), 'canale_acquisizione' => (string) (($record->meta['canale_acquisizione'] ?? '') ?: ''), 'email_raccolta_letture' => (string) (($record->meta['email_raccolta_letture'] ?? '') ?: ''), 'whatsapp_raccolta_letture' => (string) (($record->meta['whatsapp_raccolta_letture'] ?? '') ?: ''), 'costo_lettura_unitario' => $record->meta['costo_lettura_unitario'] ?? null, 'costo_ripartizione' => $record->meta['costo_ripartizione'] ?? null, 'ripartizione_esterna' => (bool) ($record->meta['ripartizione_esterna'] ?? false), + 'operative_notes' => (string) (($record->meta['operative_notes'] ?? '') ?: ''), 'attivo' => (bool) ($record->attivo ?? true), ]) ->action(function (StabileServizio $record, array $data): void { - $meta = is_array($record->meta ?? null) ? $record->meta : []; - $meta['canale_acquisizione'] = trim((string) ($data['canale_acquisizione'] ?? '')) ?: null; - $meta['email_raccolta_letture'] = trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null; - $meta['whatsapp_raccolta_letture'] = trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null; - $meta['costo_lettura_unitario'] = is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null; - $meta['costo_ripartizione'] = is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null; - $meta['ripartizione_esterna'] = (bool) ($data['ripartizione_esterna'] ?? false); + $meta = $this->buildServiceMetaPayload(is_array($record->meta ?? null) ? $record->meta : [], $data); $record->fill([ 'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))), - 'nome' => trim((string) ($data['nome'] ?? '')), + 'nome' => $this->resolveServiceDisplayName($data, (string) ($record->nome ?? '')), 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, 'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null, @@ -522,6 +607,142 @@ public function table(Table $table): Table ]); } + /** + * @return array + */ + private function getServiceTypeOptions(): array + { + return [ + 'acqua' => 'Acqua', + 'energia' => 'Energia', + 'gas' => 'Gas', + 'riscaldamento' => 'Riscaldamento', + 'ascensore' => 'Ascensore', + 'pulizie' => 'Pulizie', + 'assicurazione' => 'Assicurazione', + 'privacy' => 'Privacy', + 'antenna' => 'Antenna', + 'antincendio' => 'Antincendio', + 'citofonia' => 'Citofonia', + 'locale_comune' => 'Locale comune', + 'spazio_comune' => 'Spazio / area comune', + 'impianto' => 'Impianto comune', + 'altro' => 'Altro', + ]; + } + + /** + * @return array + */ + private function getCommonAssetScopeOptions(): array + { + return [ + 'locale' => 'Locale', + 'spazio' => 'Spazio comune', + 'impianto' => 'Impianto', + 'area_esterna' => 'Area esterna / giardino', + 'copertura' => 'Terrazzo / copertura', + 'accesso' => 'Portone / accesso', + 'servizio' => 'Servizio comune', + 'sicurezza' => 'Sicurezza / antincendio', + 'amministrativo'=> 'Privacy / assicurazione / studio', + 'altro' => 'Altro', + ]; + } + + /** + * @return array + */ + private function getAllocationScopeOptions(): array + { + return [ + 'tutti_millesimi' => 'Tutti per millesimi', + 'condomini_utilizzatori' => 'Condomini utilizzatori', + 'inquilini_utilizzatori' => 'Inquilini utilizzatori', + 'condomini_inquilini_utilizzatori'=> 'Condomini + inquilini utilizzatori', + 'rimborso_spese' => 'Rimborso spese uso bene comune', + 'gestione_studio' => 'Gestione studio / amministrazione', + 'altro' => 'Altro criterio', + ]; + } + + /** + * @return array + */ + private function getFiscalScopeOptions(): array + { + return [ + 'spesa_condominiale' => 'Spesa condominiale', + 'rimborso_non_fiscale' => 'Rimborso spese non fiscale', + 'da_fatturare' => 'Da fatturare', + 'polizza_o_servizio' => 'Polizza / servizio', + 'non_applicabile' => 'Non applicabile', + ]; + } + + /** + * @return array + */ + private function getCounterScopeOptions(): array + { + return [ + 'generale' => 'Generale', + 'particolare' => 'Particolare / dedicato', + 'assente' => 'Senza contatore', + ]; + } + + private function formatMetaOptionLabel(array $options, mixed $state): string + { + $value = strtolower(trim((string) $state)); + + return $options[$value] ?? ($value !== '' ? (string) $state : '—'); + } + + /** + * @param array $currentMeta + * @param array $data + * @return array + */ + private function buildServiceMetaPayload(array $currentMeta, array $data): array + { + $meta = $currentMeta; + $meta['common_asset_label'] = trim((string) ($data['common_asset_label'] ?? '')) ?: null; + $meta['common_asset_scope'] = trim((string) ($data['common_asset_scope'] ?? '')) ?: null; + $meta['allocation_scope'] = trim((string) ($data['allocation_scope'] ?? '')) ?: null; + $meta['fiscal_scope'] = trim((string) ($data['fiscal_scope'] ?? '')) ?: null; + $meta['has_dedicated_meter'] = (bool) ($data['has_dedicated_meter'] ?? false); + $meta['counter_scope'] = trim((string) ($data['counter_scope'] ?? '')) ?: (($meta['has_dedicated_meter'] ?? false) ? 'particolare' : 'assente'); + $meta['served_area_label'] = trim((string) ($data['served_area_label'] ?? '')) ?: null; + $meta['canale_acquisizione'] = trim((string) ($data['canale_acquisizione'] ?? '')) ?: null; + $meta['email_raccolta_letture'] = trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null; + $meta['whatsapp_raccolta_letture'] = trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null; + $meta['costo_lettura_unitario'] = is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null; + $meta['costo_ripartizione'] = is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null; + $meta['ripartizione_esterna'] = (bool) ($data['ripartizione_esterna'] ?? false); + $meta['operative_notes'] = trim((string) ($data['operative_notes'] ?? '')) ?: null; + + return $meta; + } + + /** + * @param array $data + */ + private function resolveServiceDisplayName(array $data, string $fallback = ''): string + { + $name = trim((string) ($data['nome'] ?? '')); + if ($name !== '') { + return $name; + } + + $commonLabel = trim((string) ($data['common_asset_label'] ?? '')); + if ($commonLabel !== '') { + return $commonLabel; + } + + return trim($fallback); + } + /** * @return array */ @@ -617,9 +838,9 @@ private function buildPublicAcquaReadingUrl(int $servizioId, int $unitaId, ?int now()->addDays(30), array_filter([ 'servizio' => $servizioId, - 'unita' => $unitaId, - 'request' => $requestId, - ], fn ($value) => $value !== null) + 'unita' => $unitaId, + 'request' => $requestId, + ], fn($value) => $value !== null) ); } @@ -1352,10 +1573,10 @@ public function getAcquaCampagnaStatsProperty(): array $rows = $this->acquaCampagnaRows; return [ - 'totale' => count($rows), - 'completate' => count(array_filter($rows, fn (array $row): bool => $row['status'] === 'fatta')), - 'pendenti' => count(array_filter($rows, fn (array $row): bool => in_array($row['status'], ['richiesta', 'non_richiesta'], true))), - 'sollecitate' => count(array_filter($rows, fn (array $row): bool => $row['status'] === 'sollecito')), + 'totale' => count($rows), + 'completate' => count(array_filter($rows, fn(array $row): bool => $row['status'] === 'fatta')), + 'pendenti' => count(array_filter($rows, fn(array $row): bool => in_array($row['status'], ['richiesta', 'non_richiesta'], true))), + 'sollecitate' => count(array_filter($rows, fn(array $row): bool => $row['status'] === 'sollecito')), ]; } @@ -1363,7 +1584,7 @@ public function getAcquaCampagnaStatsProperty(): array public function getAcquaCampagnaRowsProperty(): array { $stabileId = $this->resolveActiveStabileId(); - $servizio = $this->resolveActiveAcquaServizio(); + $servizio = $this->resolveActiveAcquaServizio(); if (! $stabileId || ! $servizio) { return []; } @@ -1401,7 +1622,7 @@ public function getAcquaCampagnaRowsProperty(): array ->get(); $currentYearByUnit = []; - $latestRealByUnit = []; + $latestRealByUnit = []; foreach ($readingRows as $row) { $unitId = (int) ($row->unita_immobiliare_id ?? 0); if ($unitId <= 0) { @@ -1419,7 +1640,7 @@ public function getAcquaCampagnaRowsProperty(): array } return $units->map(function (UnitaImmobiliare $unit) use ($currentYearByUnit, $latestRealByUnit, $nominativiByUnit, $servizio): array { - $current = $currentYearByUnit[(int) $unit->id] ?? null; + $current = $currentYearByUnit[(int) $unit->id] ?? null; $latestReal = $latestRealByUnit[(int) $unit->id] ?? null; $status = 'non_richiesta'; @@ -1432,17 +1653,17 @@ public function getAcquaCampagnaRowsProperty(): array } return [ - 'unit_id' => (int) $unit->id, - 'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')) ?: ('UI #' . (int) $unit->id), - 'nominativo' => $nominativiByUnit[(int) $unit->id] ?? (trim((string) ($unit->denominazione ?? '')) ?: 'Nominativo da completare'), + 'unit_id' => (int) $unit->id, + 'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')) ?: ('UI #' . (int) $unit->id), + 'nominativo' => $nominativiByUnit[(int) $unit->id] ?? (trim((string) ($unit->denominazione ?? '')) ?: 'Nominativo da completare'), 'previous_reading' => $latestReal?->lettura_fine, - 'request_id' => $current?->id, - 'status' => $status, - 'requested_at' => optional($current?->richiesta_lettura_inviata_at)->format('d/m/Y H:i'), - 'deadline_at' => optional($current?->deadline_lettura_at)->format('d/m/Y'), - 'completed_at' => optional($current?->created_at)->format('d/m/Y H:i'), - 'latest_value' => $current?->lettura_fine, - 'public_url' => $this->buildPublicAcquaReadingUrl((int) $servizio->id, (int) $unit->id, $current?->id ? (int) $current->id : null), + 'request_id' => $current?->id, + 'status' => $status, + 'requested_at' => optional($current?->richiesta_lettura_inviata_at)->format('d/m/Y H:i'), + 'deadline_at' => optional($current?->deadline_lettura_at)->format('d/m/Y'), + 'completed_at' => optional($current?->created_at)->format('d/m/Y H:i'), + 'latest_value' => $current?->lettura_fine, + 'public_url' => $this->buildPublicAcquaReadingUrl((int) $servizio->id, (int) $unit->id, $current?->id ? (int) $current->id : null), ]; })->all(); } diff --git a/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php b/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php index 97c286a..4a0b895 100644 --- a/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php +++ b/app/Filament/Pages/Fornitore/ImpostazioniArchivio.php @@ -185,7 +185,7 @@ public function getGoogleConnectUrl(): string return route('oauth.google.redirect', [ 'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0), 'label' => trim((string) ($this->fornitore?->ragione_sociale ?? '')) ?: ('Fornitore #' . (int) ($this->fornitore?->id ?? 0)), - 'return_to' => request()->getRequestUri(), + 'return_to' => request()->getRequestUri(), ]); } @@ -193,7 +193,7 @@ public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect', [ 'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0), - 'return_to' => request()->getRequestUri(), + 'return_to' => request()->getRequestUri(), ]); } @@ -261,7 +261,7 @@ public function saveOperationalWorkflow(): void } $normalizedAccessRows = collect($this->stabileAccessRows) - ->map(fn(array $row): ?array => $this->normalizeStabileAccessRow($row)) + ->map(fn(array $row): ?array=> $this->normalizeStabileAccessRow($row)) ->filter() ->values() ->all(); @@ -282,11 +282,11 @@ public function saveOperationalWorkflow(): void 'stabile_id' => (int) $row['stabile_id'], ]); - $meta = is_array($setting->meta ?? null) ? $setting->meta : []; - $meta['access_operativo'] = $row; - $setting->fornitore_id = (int) $this->fornitore->id; - $setting->stabile_id = (int) $row['stabile_id']; - $setting->meta = $meta; + $meta = is_array($setting->meta ?? null) ? $setting->meta : []; + $meta['access_operativo'] = $row; + $setting->fornitore_id = (int) $this->fornitore->id; + $setting->stabile_id = (int) $row['stabile_id']; + $setting->meta = $meta; $setting->save(); } @@ -322,7 +322,7 @@ public function saveOperationalWorkflow(): void } $config['preferred_materials'] = collect($this->preferredMaterialRows) - ->map(fn(array $row): ?array => $this->normalizePreferredMaterialRow($row)) + ->map(fn(array $row): ?array=> $this->normalizePreferredMaterialRow($row)) ->filter() ->values() ->all(); @@ -336,16 +336,16 @@ public function saveOperationalWorkflow(): void Notification::make() ->title('Workflow operativo aggiornato') ->body($this->operationalConfigAvailable - ? 'Chiavi, reperibilità e memoria materiali sono stati salvati.' - : 'Chiavi e reperibilità per stabile salvate. Esegui la migrazione per attivare anche i preferiti materiali.') + ? 'Chiavi, reperibilità e memoria materiali sono stati salvati.' + : 'Chiavi e reperibilità per stabile salvate. Esegui la migrazione per attivare anche i preferiti materiali.') ->success() ->send(); } private function refreshArchiveSummary(): void { - $relativeBase = $this->getArchiveRelativeBase(); - $absoluteBase = $relativeBase ? storage_path('app/' . $relativeBase) : null; + $relativeBase = $this->getArchiveRelativeBase(); + $absoluteBase = $relativeBase ? storage_path('app/' . $relativeBase) : null; $this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config'); if ($relativeBase !== null) { @@ -388,7 +388,7 @@ private function refreshArchiveSummary(): void $stats = $this->fornitore?->tecnorepair_stats ?? ['total' => 0, 'open' => 0, 'closed' => 0, 'serials' => 0]; $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); $this->loadOperationalWorkflowSettings(); - $this->moduleRows = [ + $this->moduleRows = [ ['module' => 'Rubrica fornitore', 'status' => 'pronto', 'note' => 'Vista fornitore filtrata ma agganciata all\'anagrafica unica.'], ['module' => 'Google Workspace', 'status' => data_get($this->googleWorkspaceStatus, 'connected', false) ? 'collegato' : 'da collegare', 'note' => 'Rubrica e sincronizzazioni smartphone riusano l\'account Google del contesto amministratore.'], ['module' => 'TecnoRepair MDB', 'status' => ((int) ($stats['total'] ?? 0) > 0) ? 'importato' : 'da importare', 'note' => 'Schede: ' . (int) ($stats['total'] ?? 0) . ' · aperte: ' . (int) ($stats['open'] ?? 0) . ' · chiuse: ' . (int) ($stats['closed'] ?? 0)], @@ -420,7 +420,7 @@ private function loadOperationalWorkflowSettings(): void ->get(['id', 'codice_stabile', 'denominazione']); $this->stabileOptions = $stabili - ->mapWithKeys(fn(Stabile $stabile): array => [ + ->mapWithKeys(fn(Stabile $stabile): array=> [ (int) $stabile->id => trim((string) ($stabile->codice_stabile ?: ('#' . $stabile->id)) . ' - ' . (string) ($stabile->denominazione ?: 'Stabile')), ]) ->all(); @@ -459,7 +459,7 @@ private function loadOperationalWorkflowSettings(): void $config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : []; - $this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? '')); + $this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? '')); $this->preferredMaterialRows = collect((array) ($config['preferred_materials'] ?? [])) ->map(function ($row): ?array { if (! is_array($row)) { @@ -512,8 +512,8 @@ private function normalizeStabileAccessRow(array $row): ?array private function normalizePreferredMaterialRow(array $row): ?array { - $label = trim((string) ($row['label'] ?? '')); - $brandModel = trim((string) ($row['brand_model'] ?? '')); + $label = trim((string) ($row['label'] ?? '')); + $brandModel = trim((string) ($row['brand_model'] ?? '')); $barcode = trim((string) ($row['barcode'] ?? '')); $internalCode = trim((string) ($row['internal_code'] ?? '')); diff --git a/app/Filament/Pages/Fornitore/RubricaClienti.php b/app/Filament/Pages/Fornitore/RubricaClienti.php index 8f61acb..dfbf240 100644 --- a/app/Filament/Pages/Fornitore/RubricaClienti.php +++ b/app/Filament/Pages/Fornitore/RubricaClienti.php @@ -149,7 +149,7 @@ public function getGoogleConnectUrl(): string return route('oauth.google.redirect', [ 'account_key' => 'fornitore-' . (int) ($this->fornitoreId ?? 0), 'label' => trim((string) ($this->fornitoreLabel ?? '')) ?: ('Fornitore #' . (int) ($this->fornitoreId ?? 0)), - 'return_to' => request()->getRequestUri(), + 'return_to' => request()->getRequestUri(), ]); } @@ -157,7 +157,7 @@ public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect', [ 'account_key' => 'fornitore-' . (int) ($this->fornitoreId ?? 0), - 'return_to' => request()->getRequestUri(), + 'return_to' => request()->getRequestUri(), ]); } diff --git a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php index ab213dd..31e416d 100644 --- a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php +++ b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php @@ -784,7 +784,7 @@ public function getGoogleConnectUrl(): string return route('oauth.google.redirect', [ 'account_key' => 'fornitore-' . (int) $this->fornitore->id, 'label' => trim((string) ($this->fornitore->ragione_sociale ?? '')) ?: ('Fornitore #' . (int) $this->fornitore->id), - 'return_to' => request()->getRequestUri(), + 'return_to' => request()->getRequestUri(), ]); } @@ -792,7 +792,7 @@ public function getGoogleDisconnectUrl(): string { return route('oauth.google.disconnect', [ 'account_key' => 'fornitore-' . (int) $this->fornitore->id, - 'return_to' => request()->getRequestUri(), + 'return_to' => request()->getRequestUri(), ]); } @@ -891,7 +891,7 @@ protected function buildStoredUploadDisplayName(int $index, bool $isImage, strin protected function reloadIntervento(): void { - $this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni'); + $this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni'); $this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config'); $relations = [ @@ -999,7 +999,7 @@ protected function reloadIntervento(): void $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); $this->loadAccessSummary(); $this->loadMaterialMemory(); - $this->qrToken = ''; + $this->qrToken = ''; } protected function getActiveSession(): ?TicketInterventoSessione @@ -1151,7 +1151,7 @@ protected function loadAccessSummary(): void return; } - $config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : []; + $config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : []; $this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? '')); $stabileId = (int) ($this->intervento->ticket?->stabile_id ?? 0); @@ -1199,7 +1199,7 @@ protected function loadMaterialMemory(): void : collect(); $this->preferredMaterialRows = $preferredRows - ->map(fn($row): ?array => is_array($row) ? $this->normalizePreferredMaterialConfig($row) : null) + ->map(fn($row): ?array=> is_array($row) ? $this->normalizePreferredMaterialConfig($row) : null) ->filter() ->filter(fn(array $row): bool => $this->matchesMaterialSearch($row, $term, $normalized)) ->values() @@ -1298,11 +1298,11 @@ protected function persistPreferredMaterial(array $row): void $config = $this->fornitore->operational_config_safe; $rows = collect((array) ($config['preferred_materials'] ?? [])) - ->map(fn($item): ?array => is_array($item) ? $this->normalizePreferredMaterialConfig($item) : null) + ->map(fn($item): ?array=> is_array($item) ? $this->normalizePreferredMaterialConfig($item) : null) ->filter() ->values(); - $key = $this->buildMaterialMemoryKey($row); + $key = $this->buildMaterialMemoryKey($row); $updatedRows = $rows ->reject(fn(array $item): bool => $this->buildMaterialMemoryKey($item) === $key) ->prepend($row) @@ -1310,7 +1310,7 @@ protected function persistPreferredMaterial(array $row): void ->values() ->all(); - $config['preferred_materials'] = $updatedRows; + $config['preferred_materials'] = $updatedRows; $this->fornitore->operational_config = $config; $this->fornitore->save(); $this->fornitore->refresh(); @@ -1318,8 +1318,8 @@ protected function persistPreferredMaterial(array $row): void protected function normalizePreferredMaterialConfig(array $row): ?array { - $label = trim((string) ($row['label'] ?? '')); - $brandModel = trim((string) ($row['brand_model'] ?? '')); + $label = trim((string) ($row['label'] ?? '')); + $brandModel = trim((string) ($row['brand_model'] ?? '')); $barcode = trim((string) ($row['barcode'] ?? '')); $internalCode = trim((string) ($row['internal_code'] ?? '')); diff --git a/app/Filament/Pages/Gescon/FornitoriArchivio.php b/app/Filament/Pages/Gescon/FornitoriArchivio.php index 15c036c..4d1a723 100644 --- a/app/Filament/Pages/Gescon/FornitoriArchivio.php +++ b/app/Filament/Pages/Gescon/FornitoriArchivio.php @@ -946,7 +946,7 @@ private function scoreFornitoreMatch(Fornitore $fornitore, string $raw, string $ } } - $rowTags = array_map(fn(string $tag): string => mb_strtolower($tag), $tags); + $rowTags = array_map(fn(string $tag): string => mb_strtolower($tag), $tags); foreach ($canonicalNeedles as $canonicalNeedle) { foreach ($rowTags as $rowTag) { if ($rowTag === $canonicalNeedle) { diff --git a/app/Filament/Pages/Supporto/Modifiche.php b/app/Filament/Pages/Supporto/Modifiche.php index 77d0b21..6fd5a87 100644 --- a/app/Filament/Pages/Supporto/Modifiche.php +++ b/app/Filament/Pages/Supporto/Modifiche.php @@ -1271,7 +1271,7 @@ private function loadAttachmentMetadataStatus(): void { try { $this->ticketAttachmentMetadataReady = Schema::hasTable('ticket_attachments') - && Schema::hasColumn('ticket_attachments', 'metadata'); + && Schema::hasColumn('ticket_attachments', 'metadata'); } catch (Throwable) { $this->ticketAttachmentMetadataReady = false; } diff --git a/app/Filament/Pages/Supporto/TicketAcqua.php b/app/Filament/Pages/Supporto/TicketAcqua.php index 8ab4d21..ecddf43 100644 --- a/app/Filament/Pages/Supporto/TicketAcqua.php +++ b/app/Filament/Pages/Supporto/TicketAcqua.php @@ -91,13 +91,13 @@ public static function canAccess(): bool $user = Auth::user(); return $user instanceof User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void { - $user = Auth::user(); - $this->dataLettura = now()->toDateString(); + $user = Auth::user(); + $this->dataLettura = now()->toDateString(); $this->rilevatoreNome = $user instanceof User ? trim((string) $user->name) : null; $this->resetWaterDraftUploadState(); $this->loadStabileOptions(); @@ -193,7 +193,7 @@ public function getSelectedWaterUploadsProperty(): array app(TicketAttachmentUploadService::class)->inspectUpload($file), $this->resolveClientCaptureMetadata($name, (int) (method_exists($file, 'getSize') ? $file->getSize() : 0)), ); - $preview = null; + $preview = null; if (method_exists($file, 'temporaryUrl')) { try { @@ -242,14 +242,14 @@ public function registraLetturaAcqua(): void '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.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', @@ -294,6 +294,7 @@ public function registraLetturaAcqua(): void $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(); @@ -325,7 +326,7 @@ public function registraLetturaAcqua(): void (string) $file->getClientOriginalName(), $readingDate, ); - $stored = app(TicketAttachmentUploadService::class)->store( + $stored = app(TicketAttachmentUploadService::class)->store( $file, $storageDirectory, [ @@ -342,56 +343,57 @@ public function registraLetturaAcqua(): void ); $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] ?? '')), + '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'], + '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'] ?? '')), + '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, + '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), + 'reader_label' => $this->resolveUnitReaderLabel($unita), + 'consistency_check' => $consistency, ], - 'created_by' => (int) $user->id, + 'created_by' => (int) $user->id, ]); $this->letturaAttuale = null; @@ -407,6 +409,14 @@ public function registraLetturaAcqua(): void ->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 @@ -419,11 +429,11 @@ private function loadStabileOptions(): void $this->stabileOptions = StabileContext::accessibleStabili($user) ->sortBy(fn($stabile) => mb_strtolower((string) ($stabile->denominazione ?? ''))) - ->map(fn($stabile): array => [ + ->map(fn($stabile): array=> [ 'id' => (int) $stabile->id, 'label' => trim((string) (($stabile->codice_stabile ?? '') !== '' - ? ($stabile->codice_stabile . ' - ' . $stabile->denominazione) - : ($stabile->denominazione ?? ('Stabile #' . $stabile->id)))), + ? ($stabile->codice_stabile . ' - ' . $stabile->denominazione) + : ($stabile->denominazione ?? ('Stabile #' . $stabile->id)))), ]) ->values() ->all(); @@ -436,7 +446,7 @@ private function bootstrapDefaultSelection(): void return; } - $activeId = StabileContext::resolveActiveStabileId($user); + $activeId = StabileContext::resolveActiveStabileId($user); $optionIds = array_column($this->stabileOptions, 'id'); if ($activeId && in_array((int) $activeId, $optionIds, true)) { @@ -451,7 +461,7 @@ private function loadServiceOptions(): void { if (! $this->stabileId) { $this->servizioOptions = []; - $this->servizioId = null; + $this->servizioId = null; return; } @@ -489,7 +499,7 @@ private function loadUnitOptions(): void { if (! $this->stabileId) { $this->unitaOptions = []; - $this->unitaId = null; + $this->unitaId = null; return; } @@ -604,9 +614,9 @@ private function appendPendingWaterPhotos(): void return; } - $accepted = array_slice($pending, 0, $available); - $currentTotal = count($this->newWaterPhotos); - $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); + $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(); @@ -614,13 +624,13 @@ private function appendPendingWaterPhotos(): void private function resetWaterDraftUploadState(): void { - $this->newWaterPhotos = []; - $this->pendingWaterPhotos = []; + $this->newWaterPhotos = []; + $this->pendingWaterPhotos = []; $this->waterPhotoDescriptions = []; - $this->waterUploadCodes = []; - $this->waterClientMetadata = []; - $this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His'); - $this->waterDraftSequence = 1; + $this->waterUploadCodes = []; + $this->waterClientMetadata = []; + $this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His'); + $this->waterDraftSequence = 1; } private function syncWaterDescriptionSlots(): void @@ -764,10 +774,98 @@ private function resolveCurrentUnitOption(): ?array 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)) + ?->sortByDesc(fn($item) => (bool) ($item->is_preferito ?? false)) ->first(); if ($role?->contatto) { @@ -778,7 +876,7 @@ private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string } $historic = $unita->nominativiStorici - ?->sortByDesc(fn($item) => (string) ($item->data_fine ?? '9999-12-31')) + ?->sortByDesc(fn($item) => (string) ($item->data_fine ?? '9999-12-31')) ->sortByDesc(fn($item) => (string) ($item->data_inizio ?? '')) ->first(); @@ -846,7 +944,7 @@ private function buildUnitFolderPrefix(UnitaImmobiliare $unita): string ! filled($unita->interno) && filled($unita->codice_unita) ? trim((string) $unita->codice_unita) : null, ]); - $raw = implode('_', $parts); + $raw = implode('_', $parts); $normalized = preg_replace('/[^A-Za-z0-9_\-]+/', '_', $raw) ?? ''; return trim($normalized, '_-') !== '' ? trim($normalized, '_-') : ('UI_' . (int) $unita->id); @@ -869,11 +967,11 @@ private function buildWaterPhotoNaming(UnitaImmobiliare $unita, string $readerLa { $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)); + $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)), '_'); + $storedBase = trim($folderPrefix . '_' . $readerSafe . '_' . Str::lower(str_replace(['.', ' '], '-', $protocol)), '_'); $displayParts = array_filter([ filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, @@ -885,15 +983,15 @@ private function buildWaterPhotoNaming(UnitaImmobiliare $unita, string $readerLa } return [ - 'stored_basename' => trim($storedBase, '_-') !== '' ? trim($storedBase, '_-') : ('water-photo-' . $unita->id . '-' . $readingDate->format('YmdHis')), - 'display_name' => $displayName . $suffix, + '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(); + $ascii = (string) Str::of($value)->ascii()->lower(); $normalized = preg_replace('/[^a-z0-9]+/', '_', $ascii) ?? ''; return trim($normalized, '_') !== '' ? trim($normalized, '_') : 'n_d'; @@ -931,4 +1029,4 @@ private function resolveAccessibleStabileIds(User $user): array ->values() ->all(); } -} \ No newline at end of file +} diff --git a/app/Filament/Pages/Supporto/TicketAcquaGenerale.php b/app/Filament/Pages/Supporto/TicketAcquaGenerale.php index 6bfe24c..628922b 100644 --- a/app/Filament/Pages/Supporto/TicketAcquaGenerale.php +++ b/app/Filament/Pages/Supporto/TicketAcquaGenerale.php @@ -1,5 +1,4 @@ hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void { - $user = Auth::user(); - $this->dataLettura = now()->toDateString(); + $user = Auth::user(); + $this->dataLettura = now()->toDateString(); $this->rilevatoreNome = $user instanceof User ? trim((string) $user->name) : null; $this->resetGeneralWaterDraftUploadState(); $this->loadStabileOptions(); @@ -201,9 +200,9 @@ public function getSelectedGeneralWaterUploadsProperty(): array 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); + $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), @@ -261,15 +260,18 @@ public function getSelectedServiceSnapshotProperty(): ?array ]); 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 ?? '')), - 'acea_sms_preview' => count($smsParts) === 3 ? implode('#', $smsParts) : '', - 'year_folder' => (string) Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), + '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'), ]; } @@ -289,14 +291,14 @@ public function getRecentGeneralReadingsProperty(): array ->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 ?? '')), + ->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(); } @@ -309,24 +311,24 @@ public function registraLetturaGenerale(): void } $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'], + '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 acqua 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.', + 'servizioId.required' => 'Seleziona il contratto/servizio acqua 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.', ]); @@ -348,8 +350,8 @@ public function registraLetturaGenerale(): void } $readingDate = Carbon::parse((string) $validated['dataLettura']); - $protocol = $this->generateGeneralReadingProtocol($readingDate); - $previous = StabileServizioLettura::query() + $protocol = $this->generateGeneralReadingProtocol($readingDate); + $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', (int) $servizio->id) ->whereNull('unita_immobiliare_id') ->whereNotNull('lettura_fine') @@ -358,8 +360,9 @@ public function registraLetturaGenerale(): void ->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; + $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.'); @@ -367,7 +370,7 @@ public function registraLetturaGenerale(): void } $storageDirectory = $this->buildGeneralWaterStorageDirectory($servizio, $readingDate); - $photoAssets = []; + $photoAssets = []; foreach ($this->newGeneralWaterPhotos as $index => $file) { if (! is_object($file) || ! method_exists($file, 'store')) { @@ -375,8 +378,8 @@ public function registraLetturaGenerale(): void } $draftCode = $this->resolveDraftUploadCode($file, $index); - $naming = $this->buildGeneralWaterPhotoNaming($servizio, $protocol, $draftCode, (string) $file->getClientOriginalName(), $readingDate); - $stored = app(TicketAttachmentUploadService::class)->store( + $naming = $this->buildGeneralWaterPhotoNaming($servizio, $protocol, $draftCode, (string) $file->getClientOriginalName(), $readingDate); + $stored = app(TicketAttachmentUploadService::class)->store( $file, $storageDirectory, [ @@ -393,20 +396,20 @@ public function registraLetturaGenerale(): void ); $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, + '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-ACQUA-GENERALE:' . (int) $servizio->id . ':' . now()->format('YmdHis'); + $reference = 'TICKET-ACQUA-GENERALE:' . (int) $servizio->id . ':' . now()->format('YmdHis'); $readerLabel = trim((string) $validated['rilevatoreNome']); - $snapshot = $this->getSelectedServiceSnapshotProperty(); - $row = StabileServizioLettura::query()->create([ + $snapshot = $this->getSelectedServiceSnapshotProperty(); + $row = StabileServizioLettura::query()->create([ 'stabile_id' => (int) $validated['stabileId'], 'stabile_servizio_id' => (int) $servizio->id, 'fornitore_id' => $servizio->fornitore_id, @@ -434,46 +437,55 @@ public function registraLetturaGenerale(): void 'consumo_unita' => 'mc', 'archivio_documentale_path' => $photoAssets[0]['drive_directory'] ?? null, 'raw' => [ - 'source' => 'ticket_acqua_generale', - 'service_snapshot' => $snapshot, - 'reader_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, - 'reader_assignment' => [ + 'source' => 'ticket_acqua_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_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', + 'acea_submission' => [ + 'sms_number' => '3399941808', + 'call_center' => '800 130 331', + 'sms_payload' => $snapshot['acea_sms_preview'] ?? null, + 'my_acea_url' => 'https://www.aceaato2.it', ], - 'notes' => trim((string) ($validated['noteGenerali'] ?? '')), - 'photos' => $photoAssets, + 'consistency_check' => $consistency, + 'notes' => trim((string) ($validated['noteGenerali'] ?? '')), + 'photos' => $photoAssets, ], - 'created_by' => (int) $user->id, + 'created_by' => (int) $user->id, ]); $this->syncGeneralWaterScadenze($row, $servizio, $readerLabel); $this->resetGeneralWaterDraftUploadState(); $this->letturaAttuale = null; - $this->noteGenerali = null; + $this->noteGenerali = null; $this->dispatch('ticket-acqua-generale-draft-reset'); $this->loadLastGeneralReading(); Notification::make() - ->title('Lettura generale registrata') + ->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 @@ -485,7 +497,7 @@ private function loadStabileOptions(): void } $this->stabileOptions = StabileContext::accessibleStabili($user) - ->map(fn(Stabile $stabile): array => [ + ->map(fn(Stabile $stabile): array=> [ 'id' => (int) $stabile->id, 'label' => trim((string) ($stabile->codice_stabile ?? '') . ' - ' . (string) ($stabile->denominazione ?? '')), ]) @@ -500,7 +512,7 @@ private function bootstrapDefaultSelection(): void return; } - $activeId = StabileContext::resolveActiveStabileId($user); + $activeId = StabileContext::resolveActiveStabileId($user); $optionIds = array_column($this->stabileOptions, 'id'); if ($activeId && in_array((int) $activeId, $optionIds, true)) { $this->stabileId = (int) $activeId; @@ -514,7 +526,7 @@ private function loadServiceOptions(): void { if (! $this->stabileId) { $this->servizioOptions = []; - $this->servizioId = null; + $this->servizioId = null; return; } @@ -524,9 +536,22 @@ private function loadServiceOptions(): void ->where('attivo', true) ->orderBy('nome') ->orderBy('codice_utenza') - ->get(['id', 'nome', 'codice_utenza', 'codice_cliente', 'codice_contratto', 'contatore_matricola']) + ->get(['id', 'nome', 'codice_utenza', 'codice_cliente', 'codice_contratto', 'contatore_matricola', 'meta']) ->map(function (StabileServizio $servizio): array { - $parts = [trim((string) ($servizio->nome ?: 'Contratto acqua #' . $servizio->id))]; + $parts = [trim((string) ($servizio->nome ?: 'Contratto acqua #' . $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); } @@ -539,7 +564,7 @@ private function loadServiceOptions(): void return [ 'id' => (int) $servizio->id, - 'label' => implode(' - ', array_filter($parts)), + 'label' => implode(' - ', array_unique(array_filter($parts))), ]; }) ->values() @@ -564,7 +589,7 @@ private function loadReaderOptions(): void ->find((int) $this->stabileId); $options = []; - $push = static function (array &$options, ?User $candidate, string $tipo): void { + $push = static function (array &$options, ?User $candidate, string $tipo): void { if (! $candidate instanceof User) { return; } @@ -637,9 +662,9 @@ private function appendPendingGeneralWaterPhotos(): void return; } - $accepted = array_slice($pending, 0, $available); - $currentTotal = count($this->newGeneralWaterPhotos); - $this->newGeneralWaterPhotos = array_values(array_merge($this->newGeneralWaterPhotos, $accepted)); + $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(); @@ -647,13 +672,13 @@ private function appendPendingGeneralWaterPhotos(): void private function resetGeneralWaterDraftUploadState(): void { - $this->newGeneralWaterPhotos = []; - $this->pendingGeneralWaterPhotos = []; + $this->newGeneralWaterPhotos = []; + $this->pendingGeneralWaterPhotos = []; $this->generalWaterPhotoDescriptions = []; - $this->generalWaterUploadCodes = []; - $this->generalWaterClientMetadata = []; - $this->generalWaterDraftProtocol = 'TAG-' . now()->format('Ymd-His'); - $this->generalWaterDraftSequence = 1; + $this->generalWaterUploadCodes = []; + $this->generalWaterClientMetadata = []; + $this->generalWaterDraftProtocol = 'TAG-' . now()->format('Ymd-His'); + $this->generalWaterDraftSequence = 1; } private function syncGeneralWaterDescriptionSlots(): void @@ -747,10 +772,10 @@ private function buildGeneralWaterStorageDirectory(StabileServizio $servizio, Ca */ private function buildGeneralWaterPhotoNaming(StabileServizio $servizio, string $protocol, string $draftCode, string $originalName, Carbon $readingDate): array { - $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); - $suffix = $extension !== '' ? '.' . $extension : ''; + $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([ + $base = trim(implode('_', array_filter([ 'contatore_generale', $serviceKey, Str::lower(str_replace(['.', ' '], '-', $protocol)), @@ -758,15 +783,15 @@ private function buildGeneralWaterPhotoNaming(StabileServizio $servizio, string ])), '_'); return [ - 'stored_basename' => $base !== '' ? $base : ('contatore-generale-' . $readingDate->format('YmdHis')), - 'display_name' => $protocol . '-' . $draftCode . $suffix, - 'drive_relative_directory'=> $this->buildGeneralWaterStorageDirectory($servizio, $readingDate), + '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(); + $ascii = (string) Str::of($value)->ascii()->lower(); $normalized = preg_replace('/[^a-z0-9]+/', '_', $ascii) ?? ''; return trim($normalized, '_') !== '' ? trim($normalized, '_') : 'n_d'; @@ -803,13 +828,112 @@ private function inferReaderType(): string 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))); + $serviceName = trim((string) ($servizio->nome ?: ('Contratto #' . $servizio->id))); $contractBits = array_filter([ trim((string) ($servizio->codice_contratto ?? '')), trim((string) ($servizio->codice_utenza ?? '')), @@ -831,15 +955,15 @@ private function syncGeneralWaterScadenze(StabileServizioLettura $reading, Stabi 'giorni_anticipo' => 2, 'popup_dal' => Carbon::parse((string) $this->aceaWindowStart)->copy()->subDays(2)->toDateString(), 'legacy_payload' => [ - 'source' => 'ticket_acqua_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'), + 'source' => 'ticket_acqua_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'), ], ]); } @@ -859,15 +983,15 @@ private function syncGeneralWaterScadenze(StabileServizioLettura $reading, Stabi 'giorni_anticipo' => 1, 'popup_dal' => Carbon::parse((string) $this->aceaVisitDate)->copy()->subDay()->toDateString(), 'legacy_payload' => [ - 'source' => 'ticket_acqua_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, + 'source' => 'ticket_acqua_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, ], ]); } @@ -880,4 +1004,4 @@ private function createScadenza(array $payload): void { Scadenza::query()->create($payload); } -} \ No newline at end of file +} diff --git a/app/Filament/Pages/Supporto/TicketAcquaLight.php b/app/Filament/Pages/Supporto/TicketAcquaLight.php index 26a2dba..73eeaa5 100644 --- a/app/Filament/Pages/Supporto/TicketAcquaLight.php +++ b/app/Filament/Pages/Supporto/TicketAcquaLight.php @@ -19,4 +19,4 @@ class TicketAcquaLight extends TicketAcqua protected static ?string $slug = 'supporto/ticket-acqua-light'; protected string $view = 'filament.pages.supporto.ticket-acqua-light'; -} \ No newline at end of file +} diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index bad642f..4c3d82a 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -448,8 +448,8 @@ private function shouldHydrateAttachmentMetadata(TicketAttachment $attachment, a } return empty($details['gps']) - || empty($details['exif_datetime']) - || empty($details['device']); + || empty($details['exif_datetime']) + || empty($details['device']); } /** @@ -695,7 +695,7 @@ public function caricaAllegati(): void return; } - $saved = 0; + $saved = 0; $supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata'); foreach ($files as $file) { if (! is_object($file) || ! method_exists($file, 'store')) { diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 24fca1a..fefa3e7 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -739,9 +739,9 @@ public function getSelectedUploadsProperty(): array : []; $metadata = $isImage ? app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( - is_array($metadata) ? $metadata : [], - $clientMetadata, - ) + is_array($metadata) ? $metadata : [], + $clientMetadata, + ) : []; $previewUrl = null; @@ -789,7 +789,7 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int return 0; } - $saved = 0; + $saved = 0; $supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata'); $files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments); diff --git a/app/Http/Controllers/Condomino/TicketController.php b/app/Http/Controllers/Condomino/TicketController.php index 2dc7ee0..a725e40 100755 --- a/app/Http/Controllers/Condomino/TicketController.php +++ b/app/Http/Controllers/Condomino/TicketController.php @@ -60,13 +60,13 @@ public function store(Request $request) } $request->validate([ - 'unita_immobiliare_id' => 'required|integer', - 'categoria_ticket_id' => 'nullable|exists:categorie_ticket,id', - 'titolo' => 'required|string|max:255', - 'descrizione' => 'required|string', - 'luogo_intervento' => 'nullable|string|max:255', - 'priorita' => 'required|in:Bassa,Media,Alta,Urgente', - 'allegati.*' => 'nullable|file|max:20480', // 20MB per file + 'unita_immobiliare_id' => 'required|integer', + 'categoria_ticket_id' => 'nullable|exists:categorie_ticket,id', + 'titolo' => 'required|string|max:255', + 'descrizione' => 'required|string', + 'luogo_intervento' => 'nullable|string|max:255', + 'priorita' => 'required|in:Bassa,Media,Alta,Urgente', + 'allegati.*' => 'nullable|file|max:20480', // 20MB per file 'attachment_descriptions' => 'nullable|array', 'attachment_descriptions.*' => 'nullable|string|max:255', ]); @@ -230,11 +230,11 @@ private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachmen return; } - $localPath = Storage::disk('public')->path($publicPath); - $contents = (string) Storage::disk('public')->get($publicPath); - $extension = strtolower((string) pathinfo($localPath, PATHINFO_EXTENSION)); - $archiveReference = 'ticket-' . (int) $ticket->id . '-attachment-' . (int) $attachment->id; - $attachmentMeta = is_array($attachment->metadata ?? null) ? $attachment->metadata : []; + $localPath = Storage::disk('public')->path($publicPath); + $contents = (string) Storage::disk('public')->get($publicPath); + $extension = strtolower((string) pathinfo($localPath, PATHINFO_EXTENSION)); + $archiveReference = 'ticket-' . (int) $ticket->id . '-attachment-' . (int) $attachment->id; + $attachmentMeta = is_array($attachment->metadata ?? null) ? $attachment->metadata : []; $payload = [ 'documentable_type' => Ticket::class, diff --git a/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php b/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php index f8231c5..41e71a8 100644 --- a/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php +++ b/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php @@ -150,38 +150,38 @@ public function store(Request $request): RedirectResponse ))); $payload = [ - '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' => isset($validated['data_lettura']) ? Carbon::parse((string) $validated['data_lettura'])->toDateString() : now()->toDateString(), - 'tipologia_lettura' => 'autolettura_link_pubblico', - 'canale_acquisizione' => 'portale_pubblico', - 'workflow_stato' => 'ricevuta', - 'riferimento_acquisizione' => $pendingRequest?->riferimento_acquisizione ?: ('PUBREAD:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis')), - 'rilevatore_tipo' => 'link_pubblico', - 'rilevatore_nome' => trim((string) $validated['rilevatore_nome']), - 'lettura_precedente_valore' => $letturaInizio, - 'lettura_inizio' => $letturaInizio, - 'lettura_fine' => $letturaFine, - 'lettura_foto_path' => $photoPaths[0] ?? null, - 'lettura_foto_original_name'=> $photoAssets[0]['original_name'] ?? null, - 'lettura_foto_metadata' => [ + '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' => isset($validated['data_lettura']) ? Carbon::parse((string) $validated['data_lettura'])->toDateString() : now()->toDateString(), + 'tipologia_lettura' => 'autolettura_link_pubblico', + 'canale_acquisizione' => 'portale_pubblico', + 'workflow_stato' => 'ricevuta', + 'riferimento_acquisizione' => $pendingRequest?->riferimento_acquisizione ?: ('PUBREAD:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis')), + 'rilevatore_tipo' => 'link_pubblico', + 'rilevatore_nome' => trim((string) $validated['rilevatore_nome']), + 'lettura_precedente_valore' => $letturaInizio, + 'lettura_inizio' => $letturaInizio, + 'lettura_fine' => $letturaFine, + 'lettura_foto_path' => $photoPaths[0] ?? null, + 'lettura_foto_original_name' => $photoAssets[0]['original_name'] ?? null, + 'lettura_foto_metadata' => [ 'photos' => $photoAssets, 'paths' => $photoPaths, 'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')), 'note' => trim((string) ($validated['note'] ?? '')), ], - 'consumo_valore' => $consumo, - 'consumo_unita' => 'mc', - 'raw' => [ - 'source' => 'public_water_reading_link', - 'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')), - 'note' => trim((string) ($validated['note'] ?? '')), - 'photo_assets'=> $photoAssets, - 'photo_paths' => $photoPaths, - 'request_id' => $pendingRequest?->id, + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'raw' => [ + 'source' => 'public_water_reading_link', + 'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')), + 'note' => trim((string) ($validated['note'] ?? '')), + 'photo_assets' => $photoAssets, + 'photo_paths' => $photoPaths, + 'request_id' => $pendingRequest?->id, ], ]; diff --git a/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php b/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php index ee7fea8..d9aa4f0 100644 --- a/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php +++ b/database/migrations/2026_04_12_090000_add_operational_config_to_fornitori_table.php @@ -23,4 +23,4 @@ public function down(): void } }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php b/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php index 0648956..b1f163a 100644 --- a/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php +++ b/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php @@ -27,4 +27,4 @@ public function down(): void $table->dropColumn('metadata'); }); } -}; \ No newline at end of file +}; diff --git a/docs/support/GERMANICO79-POSTA-ACQUA-2026-04.md b/docs/support/GERMANICO79-POSTA-ACQUA-2026-04.md index a103661..4c226af 100644 --- a/docs/support/GERMANICO79-POSTA-ACQUA-2026-04.md +++ b/docs/support/GERMANICO79-POSTA-ACQUA-2026-04.md @@ -107,4 +107,4 @@ ## Prossimo slice: ticket-lettura acqua semplificato 1. risolvere i `5` match mancanti tra `acqua_dett` e `unita_immobiliari` 2. creare token/link pubblico per singola unita e singola campagna lettura 3. aggiungere tab stabile con lista letture richieste, ricevute e mancanti -4. riusare il workflow ticket solo per stato, note e immagini, senza allegati generici \ No newline at end of file +4. riusare il workflow ticket solo per stato, note e immagini, senza allegati generici diff --git a/resources/views/filament/pages/condomini/servizi-stabile-archivio.blade.php b/resources/views/filament/pages/condomini/servizi-stabile-archivio.blade.php index 16557f0..6040e49 100644 --- a/resources/views/filament/pages/condomini/servizi-stabile-archivio.blade.php +++ b/resources/views/filament/pages/condomini/servizi-stabile-archivio.blade.php @@ -17,9 +17,9 @@
-

Servizi / Utenze - ACQUA

+

Servizi / Beni comuni - ACQUA

- Cruscotto operativo acqua con controllo fatture, versamenti/letture condomini e tariffe storicizzate. I dati mostrati seguono lo stabile attivo e l'anno gestione del topbar. + Cruscotto operativo per utenze, beni comuni, locali condivisi e contatori acqua dello stabile. Qui convivono contratti, servizi ad uso comune, rimborsi spesa, contatori generali e contatori particolari con storico e tariffe.

Anno gestione attivo: {{ $annoGestioneAttivo }} diff --git a/resources/views/filament/pages/supporto/ticket-acqua-generale.blade.php b/resources/views/filament/pages/supporto/ticket-acqua-generale.blade.php index a01b019..d9653db 100644 --- a/resources/views/filament/pages/supporto/ticket-acqua-generale.blade.php +++ b/resources/views/filament/pages/supporto/ticket-acqua-generale.blade.php @@ -14,8 +14,8 @@
-

Lettura contatore generale acqua

-
Pagina operativa per autoletture Acea del contatore generale, con foto geolocalizzate, storico per contratto e scadenze da calendario per amministrazione o collaboratori.
+

Lettura contatori comuni acqua

+
Pagina operativa per contatore generale e contatori particolari dei beni comuni dello stabile, con foto geolocalizzate, storico per contratto e scadenze da calendario per amministrazione o collaboratori.
Bozza {{ $generalWaterDraftProtocol }}
@@ -43,7 +43,7 @@ @endforeach -
Se lo stabile ha piu contratti, la lettura generale va agganciata a quello corretto.
+
Se lo stabile ha piu contratti o contatori dedicati a giardino, locale caldaia o altri beni comuni, aggancia la lettura al servizio corretto.
@@ -287,12 +290,12 @@ }" x-on:ticket-acqua-generale-draft-reset.window="clearAll()" > - Foto contatore generale + Foto contatore comune -
Puoi accodare fino a 4 immagini del contatore generale e della targhetta matricola.
+
Puoi accodare fino a 4 immagini del contatore generale o particolare e della targhetta matricola.
Caricamento foto in corso.
@if(count($this->selectedGeneralWaterUploads) > 0)