Finalize water workflows and shared asset management

This commit is contained in:
michele 2026-04-13 14:31:17 +00:00
parent d95fba5740
commit 7dd54f3a6f
21 changed files with 1047 additions and 380 deletions

View File

@ -1,5 +1,4 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
@ -138,4 +137,4 @@ private function writeProgress(int $percent, string $message, string $status, ?i
'exit_code' => $exitCode,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
}

View File

@ -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<string, string>
*/
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;
}
}

View File

@ -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)),
]);
}
}

View File

@ -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<string, string>
*/
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<string, string>
*/
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<string, string>
*/
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<string, string>
*/
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<string, string>
*/
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<string, mixed> $currentMeta
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $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<int, string>
*/
@ -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();
}

View File

@ -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'] ?? ''));

View File

@ -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(),
]);
}

View File

@ -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'] ?? ''));

View File

@ -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) {

View File

@ -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;
}

View File

@ -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();
}
}
}

View File

@ -1,5 +1,4 @@
<?php
namespace App\Filament\Pages\Supporto;
use App\Models\Scadenza;
@ -24,9 +23,9 @@ class TicketAcquaGenerale extends Page
{
use WithFileUploads;
protected static ?string $navigationLabel = 'Acqua Generale';
protected static ?string $navigationLabel = 'Contatori Comuni Acqua';
protected static ?string $title = 'Acqua Generale';
protected static ?string $title = 'Contatori Comuni Acqua';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office-2';
@ -102,13 +101,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->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);
}
}
}

View File

@ -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';
}
}

View File

@ -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')) {

View File

@ -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);

View File

@ -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,

View File

@ -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,
],
];

View File

@ -23,4 +23,4 @@ public function down(): void
}
});
}
};
};

View File

@ -27,4 +27,4 @@ public function down(): void
$table->dropColumn('metadata');
});
}
};
};

View File

@ -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
4. riusare il workflow ticket solo per stato, note e immagini, senza allegati generici

View File

@ -17,9 +17,9 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Servizi / Utenze - ACQUA</h1>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Servizi / Beni comuni - ACQUA</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
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.
</p>
<div class="mt-2 inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200">
Anno gestione attivo: {{ $annoGestioneAttivo }}

View File

@ -14,8 +14,8 @@
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-900">Lettura contatore generale acqua</h2>
<div class="mt-1 text-sm text-slate-600">Pagina operativa per autoletture Acea del contatore generale, con foto geolocalizzate, storico per contratto e scadenze da calendario per amministrazione o collaboratori.</div>
<h2 class="text-lg font-semibold text-slate-900">Lettura contatori comuni acqua</h2>
<div class="mt-1 text-sm text-slate-600">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.</div>
</div>
<div class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-800">Bozza {{ $generalWaterDraftProtocol }}</div>
</div>
@ -43,7 +43,7 @@
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
<div class="mt-1 text-[11px] text-slate-500">Se lo stabile ha piu contratti, la lettura generale va agganciata a quello corretto.</div>
<div class="mt-1 text-[11px] text-slate-500">Se lo stabile ha piu contratti o contatori dedicati a giardino, locale caldaia o altri beni comuni, aggancia la lettura al servizio corretto.</div>
</label>
<label class="block text-sm">
@ -69,11 +69,14 @@
<div class="text-sm font-semibold text-emerald-900">Riferimenti contratto selezionato</div>
<div class="mt-3 grid gap-3 md:grid-cols-2 text-sm text-emerald-900">
<div><span class="font-medium">Servizio:</span> {{ $service['nome'] ?: 'n/d' }}</div>
<div><span class="font-medium">Bene comune:</span> {{ $service['common_asset'] ?: 'n/d' }}</div>
<div><span class="font-medium">Fornitore:</span> {{ $service['fornitore'] ?: 'n/d' }}</div>
<div><span class="font-medium">Tipo contatore:</span> {{ $service['counter_scope'] ?: 'n/d' }}</div>
<div><span class="font-medium">Codice utenza:</span> {{ $service['codice_utenza'] ?: 'n/d' }}</div>
<div><span class="font-medium">Codice cliente:</span> {{ $service['codice_cliente'] ?: 'n/d' }}</div>
<div><span class="font-medium">Codice contratto:</span> {{ $service['codice_contratto'] ?: 'n/d' }}</div>
<div><span class="font-medium">Matricola contatore:</span> {{ $service['matricola'] ?: 'n/d' }}</div>
<div><span class="font-medium">Area servita:</span> {{ $service['served_area'] ?: 'n/d' }}</div>
</div>
<div class="mt-3 rounded-xl border border-emerald-300 bg-white p-3 text-xs text-slate-700">
<div class="font-semibold text-slate-900">Formato rapido Acea</div>
@ -96,7 +99,7 @@
<input type="date" wire:model.defer="dataLettura" class="w-full rounded-lg border-sky-300 bg-white text-slate-900" />
</label>
</div>
<div class="mt-2 text-xs text-slate-600">La lettura viene salvata nello storico del contratto selezionato, separata dalle singole unita ma con lo stesso standard foto e metadati.</div>
<div class="mt-2 text-xs text-slate-600">La lettura viene salvata nello storico del contratto selezionato, separata dalle singole unita ma con lo stesso standard foto e metadati. Se il valore e fuori linea rispetto allo storico recente compare subito un avviso.</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
@ -287,12 +290,12 @@
}"
x-on:ticket-acqua-generale-draft-reset.window="clearAll()"
>
<span class="mb-2 block font-medium">Foto contatore generale</span>
<span class="mb-2 block font-medium">Foto contatore comune</span>
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta o aggiungi foto
<input type="file" wire:model="pendingGeneralWaterPhotos" multiple accept="image/*" capture="environment" class="hidden" x-on:change="addFiles($event)" />
</label>
<div class="mt-1 text-xs text-gray-500">Puoi accodare fino a 4 immagini del contatore generale e della targhetta matricola.</div>
<div class="mt-1 text-xs text-gray-500">Puoi accodare fino a 4 immagini del contatore generale o particolare e della targhetta matricola.</div>
<div wire:loading wire:target="pendingGeneralWaterPhotos" class="mt-2 text-xs font-medium text-amber-700">Caricamento foto in corso.</div>
@if(count($this->selectedGeneralWaterUploads) > 0)