netgescon-day0/app/Filament/Pages/Supporto/TicketAcqua.php

1176 lines
43 KiB
PHP
Executable File

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