diff --git a/app/Http/Controllers/Condomino/TicketController.php b/app/Http/Controllers/Condomino/TicketController.php index 39e0f7a..2dc7ee0 100755 --- a/app/Http/Controllers/Condomino/TicketController.php +++ b/app/Http/Controllers/Condomino/TicketController.php @@ -4,16 +4,26 @@ use App\Http\Controllers\Condomino\Concerns\ResolvesCondominoAccess; use App\Http\Controllers\Controller; use App\Models\CategoriaTicket; +use App\Models\Documento; use App\Models\Ticket; +use App\Models\TicketAttachment; use App\Models\TicketMessage; use App\Models\User; +use App\Services\Documenti\ImageTextExtractionService; +use App\Services\Documenti\PdfTextExtractionService; +use App\Services\Support\TicketAttachmentUploadService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; class TicketController extends Controller { use ResolvesCondominoAccess; + /** @var array|null */ + private ?array $documentiColumnsCache = null; + public function index() { $user = Auth::user(); @@ -57,6 +67,8 @@ public function store(Request $request) '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', ]); $unitaImmobiliare = $this->resolveUserUnita($user) @@ -84,22 +96,42 @@ public function store(Request $request) // Gestione allegati if ($request->hasFile('allegati')) { - foreach ($request->file('allegati') as $file) { + $supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata'); + $attachmentDescriptions = (array) $request->input('attachment_descriptions', []); + + foreach ($request->file('allegati') as $index => $file) { if (! $file) { continue; } - $path = $file->store('ticket-attachments', 'public'); + $displayName = $this->buildStoredAttachmentDisplayName($ticket, (int) $index, (string) $file->getClientOriginalName()); + $stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-attachments/' . $ticket->id, [ + 'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME), + 'display_name' => $displayName, + ]); - $ticket->attachments()->create([ + $attachmentPayload = [ 'ticket_update_id' => null, 'user_id' => $user->id, - 'file_path' => $path, - 'original_file_name' => $file->getClientOriginalName(), - 'mime_type' => (string) $file->getMimeType(), - 'size' => (int) $file->getSize(), - 'description' => 'Upload da area condomino', - ]); + 'file_path' => $stored['path'], + 'original_file_name' => $stored['original_name'], + 'mime_type' => $stored['mime'], + 'size' => $stored['size'], + 'description' => $this->resolveAttachmentDescription( + (string) ($attachmentDescriptions[$index] ?? ''), + $stored, + ), + ]; + + if ($supportsAttachmentMetadata) { + $attachmentPayload['metadata'] = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : []; + } + + $attachment = $ticket->attachments()->create($attachmentPayload); + + if ($attachment instanceof TicketAttachment) { + $this->archiveTicketAttachmentDocument($ticket, $attachment); + } } } @@ -128,4 +160,161 @@ public function show(Ticket $ticket) return view('condomino.tickets.show', compact('ticket')); } + private function buildStoredAttachmentDisplayName(Ticket $ticket, int $index, string $originalName): string + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + + return sprintf( + 'ticket-%06d-all-%02d%s', + (int) $ticket->id, + $index + 1, + $extension !== '' ? '.' . $extension : '' + ); + } + + /** + * @param array $stored + */ + private function resolveAttachmentDescription(string $specificDescription, array $stored): string + { + $specificDescription = trim($specificDescription); + if ($specificDescription !== '') { + return $specificDescription; + } + + $metadata = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : []; + $parts = ['Upload da area condomino']; + + $lat = $metadata['gps_decimal']['lat'] ?? null; + $lng = $metadata['gps_decimal']['lng'] ?? null; + if (is_numeric($lat) && is_numeric($lng)) { + $parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', ''); + } + + $mapsUrl = trim((string) ($metadata['google_maps_url'] ?? '')); + if ($mapsUrl !== '') { + $parts[] = 'Maps: ' . $mapsUrl; + } + + $exifDate = trim((string) ($metadata['exif_datetime'] ?? '')); + if ($exifDate !== '') { + $parts[] = 'EXIF: ' . $exifDate; + } + + $device = trim((string) ($metadata['device'] ?? '')); + if ($device !== '') { + $parts[] = 'Device: ' . $device; + } + + $displayName = trim((string) ($stored['original_name'] ?? '')); + if ($displayName !== '') { + $parts[] = 'File: ' . $displayName; + } + + $sourceName = trim((string) ($stored['source_original_name'] ?? '')); + if ($sourceName !== '' && $sourceName !== $displayName) { + $parts[] = 'Orig: ' . $sourceName; + } + + return mb_substr(implode(' | ', array_filter($parts)), 0, 255); + } + + private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void + { + if (! Schema::hasTable('documenti')) { + return; + } + + $publicPath = (string) ($attachment->file_path ?? ''); + if ($publicPath === '' || ! Storage::disk('public')->exists($publicPath)) { + 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 : []; + + $payload = [ + 'documentable_type' => Ticket::class, + 'documentable_id' => (int) $ticket->id, + 'stabile_id' => (int) $ticket->stabile_id, + 'utente_id' => Auth::id(), + 'nome' => (string) ($attachment->original_file_name ?: ('Allegato ticket #' . (int) $attachment->id)), + 'nome_file' => (string) ($attachment->original_file_name ?: basename($localPath)), + 'path_file' => $localPath, + 'percorso_file' => $localPath, + 'tipo_documento' => 'ticket_allegato', + 'tipologia' => $attachment->isImage() ? 'foto' : ($attachment->isPdf() ? 'comunicazione' : 'altro'), + 'mime_type' => $attachment->resolvedMimeType(), + 'descrizione' => (string) ($attachment->description ?: 'Allegato ticket #' . (int) $ticket->id), + 'note' => 'Ticket #' . (int) $ticket->id . ' · ' . (string) ($ticket->titolo ?: 'senza titolo'), + 'dimensione_file' => (int) ($attachment->size ?? strlen($contents)), + 'estensione' => $extension !== '' ? $extension : null, + 'hash_file' => hash('sha256', $contents), + 'xml_data' => [ + 'ticket_id' => (int) $ticket->id, + 'ticket_attachment_id' => (int) $attachment->id, + 'source_public_path' => $publicPath, + 'archive_reference' => $archiveReference, + 'archive_scope' => 'ticket', + 'visibility_scope' => 'interno', + 'attachment_metadata' => $attachmentMeta, + ], + 'metadati_ocr' => $attachment->isImage() + ? ['status' => 'pending_image_ocr', 'source' => 'ticket_attachment'] + : ['source' => 'ticket_attachment'], + 'data_upload' => now(), + 'approvato' => true, + 'archiviato' => true, + 'urgente' => false, + 'is_demo' => false, + 'archive_reference' => $archiveReference, + 'visibility_scope' => 'interno', + 'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id], + 'visibility_roles' => ['super-admin', 'admin', 'amministratore', 'collaboratore'], + ]; + + $documento = Documento::query()->updateOrCreate( + [ + 'documentable_type' => Ticket::class, + 'documentable_id' => (int) $ticket->id, + 'path_file' => $localPath, + ], + $this->filterDocumentoPayload($payload) + ); + + if ($attachment->isPdf()) { + app(PdfTextExtractionService::class)->extractAndStore($documento, false); + } elseif ($attachment->isImage()) { + app(ImageTextExtractionService::class)->extractAndStore($documento, false); + } + } + + /** + * @return array + */ + private function documentiColumns(): array + { + if ($this->documentiColumnsCache === null) { + $this->documentiColumnsCache = Schema::hasTable('documenti') + ? Schema::getColumnListing('documenti') + : []; + } + + return $this->documentiColumnsCache; + } + + /** + * @param array $payload + * @return array + */ + private function filterDocumentoPayload(array $payload): array + { + $allowed = array_flip($this->documentiColumns()); + + return array_intersect_key($payload, $allowed); + } + } diff --git a/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php b/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php index 3fa16f2..f8231c5 100644 --- a/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php +++ b/app/Http/Controllers/PublicAccess/WaterReadingRequestController.php @@ -1,11 +1,11 @@ integer('servizio'); - $unitaId = (int) $request->integer('unita'); - $requestId = (int) $request->integer('request'); + $unitaId = (int) $request->integer('unita'); + $requestId = (int) $request->integer('request'); $servizio = StabileServizio::query() ->where('id', $servizioId) @@ -33,41 +33,41 @@ public function show(Request $request): View $pendingRequest = $requestId > 0 ? StabileServizioLettura::query() - ->where('id', $requestId) - ->where('stabile_servizio_id', $servizioId) - ->where('unita_immobiliare_id', $unitaId) - ->first() + ->where('id', $requestId) + ->where('stabile_servizio_id', $servizioId) + ->where('unita_immobiliare_id', $unitaId) + ->first() : null; $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', $servizioId) ->where('unita_immobiliare_id', $unitaId) ->whereNotNull('lettura_fine') - ->when($pendingRequest, fn ($query) => $query->where('id', '!=', (int) $pendingRequest->id)) + ->when($pendingRequest, fn($query) => $query->where('id', '!=', (int) $pendingRequest->id)) ->orderByDesc('periodo_al') ->orderByDesc('id') ->first(); $submitUrl = URL::temporarySignedRoute('public.water-reading.store', now()->addDays(30), array_filter([ 'servizio' => $servizioId, - 'unita' => $unitaId, - 'request' => $pendingRequest?->id, + 'unita' => $unitaId, + 'request' => $pendingRequest?->id, ])); return view('public.water-reading.show', [ - 'servizio' => $servizio, - 'unita' => $unita, + 'servizio' => $servizio, + 'unita' => $unita, 'pendingRequest' => $pendingRequest, - 'previous' => $previous, - 'submitUrl' => $submitUrl, + 'previous' => $previous, + 'submitUrl' => $submitUrl, ]); } public function store(Request $request): RedirectResponse { $servizioId = (int) $request->integer('servizio'); - $unitaId = (int) $request->integer('unita'); - $requestId = (int) $request->integer('request'); + $unitaId = (int) $request->integer('unita'); + $requestId = (int) $request->integer('request'); $servizio = StabileServizio::query() ->where('id', $servizioId) @@ -80,35 +80,35 @@ public function store(Request $request): RedirectResponse ->firstOrFail(); $validated = $request->validate([ - 'lettura_attuale' => ['required', 'numeric', 'min:0'], - 'data_lettura' => ['nullable', 'date'], - 'rilevatore_nome' => ['required', 'string', 'max:255'], + 'lettura_attuale' => ['required', 'numeric', 'min:0'], + 'data_lettura' => ['nullable', 'date'], + 'rilevatore_nome' => ['required', 'string', 'max:255'], 'rilevatore_contatto' => ['nullable', 'string', 'max:255'], - 'note' => ['nullable', 'string', 'max:2000'], - 'foto_1' => ['nullable', 'image', 'max:8192'], - 'foto_2' => ['nullable', 'image', 'max:8192'], + 'note' => ['nullable', 'string', 'max:2000'], + 'foto_1' => ['nullable', 'image', 'max:8192'], + 'foto_2' => ['nullable', 'image', 'max:8192'], ]); $pendingRequest = $requestId > 0 ? StabileServizioLettura::query() - ->where('id', $requestId) - ->where('stabile_servizio_id', $servizioId) - ->where('unita_immobiliare_id', $unitaId) - ->first() + ->where('id', $requestId) + ->where('stabile_servizio_id', $servizioId) + ->where('unita_immobiliare_id', $unitaId) + ->first() : null; $previous = StabileServizioLettura::query() ->where('stabile_servizio_id', $servizioId) ->where('unita_immobiliare_id', $unitaId) ->whereNotNull('lettura_fine') - ->when($pendingRequest, fn ($query) => $query->where('id', '!=', (int) $pendingRequest->id)) + ->when($pendingRequest, fn($query) => $query->where('id', '!=', (int) $pendingRequest->id)) ->orderByDesc('periodo_al') ->orderByDesc('id') ->first(); $letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; - $letturaFine = (float) $validated['lettura_attuale']; - $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; + $letturaFine = (float) $validated['lettura_attuale']; + $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; if ($consumo !== null && $consumo < 0) { return back()->withErrors([ @@ -116,43 +116,72 @@ public function store(Request $request): RedirectResponse ])->withInput(); } - $photoPaths = []; + $photoAssets = []; foreach (['foto_1', 'foto_2'] as $photoField) { if ($request->hasFile($photoField)) { - $photoPaths[] = $request->file($photoField)?->store('letture-acqua-public', 'public'); + $file = $request->file($photoField); + if (! $file) { + continue; + } + + $stored = app(TicketAttachmentUploadService::class)->store( + $file, + 'letture-acqua-public/' . $servizio->id . '/' . $unita->id, + [ + 'stored_basename' => 'water-reading-' . $servizio->id . '-' . $unita->id . '-' . $photoField, + ] + ); + + $photoAssets[] = [ + 'field' => $photoField, + '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' => is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + ]; } } + $photoPaths = array_values(array_filter(array_map( + static fn(array $photo): ?string => is_string($photo['path'] ?? null) ? $photo['path'] : null, + $photoAssets + ))); + $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']), + '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_metadata' => [ - 'paths' => $photoPaths, + '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'] ?? '')), + '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'] ?? '')), + '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, + 'request_id' => $pendingRequest?->id, ], ]; @@ -165,4 +194,4 @@ public function store(Request $request): RedirectResponse return redirect()->back()->with('success', 'Lettura acqua inviata correttamente.'); } -} \ No newline at end of file +} diff --git a/app/Models/TicketAttachment.php b/app/Models/TicketAttachment.php index 1442433..e868ca4 100755 --- a/app/Models/TicketAttachment.php +++ b/app/Models/TicketAttachment.php @@ -11,7 +11,12 @@ class TicketAttachment extends Model use HasFactory; protected $table = 'ticket_attachments'; - protected $fillable = ['ticket_id', 'ticket_update_id', 'user_id', 'file_path', 'original_file_name', 'mime_type', 'size', 'description']; + protected $fillable = ['ticket_id', 'ticket_update_id', 'user_id', 'file_path', 'original_file_name', 'mime_type', 'size', 'description', 'metadata']; + + protected $casts = [ + 'size' => 'integer', + 'metadata' => 'array', + ]; public function ticket(): BelongsTo { diff --git a/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php b/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php new file mode 100644 index 0000000..0648956 --- /dev/null +++ b/database/migrations/2026_04_12_153000_add_metadata_to_ticket_attachments_table.php @@ -0,0 +1,30 @@ +json('metadata')->nullable()->after('description'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('ticket_attachments') || ! Schema::hasColumn('ticket_attachments', 'metadata')) { + return; + } + + Schema::table('ticket_attachments', function (Blueprint $table): void { + $table->dropColumn('metadata'); + }); + } +}; \ No newline at end of file diff --git a/resources/views/condomino/tickets/create.blade.php b/resources/views/condomino/tickets/create.blade.php index 80743cd..37090f8 100644 --- a/resources/views/condomino/tickets/create.blade.php +++ b/resources/views/condomino/tickets/create.blade.php @@ -74,16 +74,33 @@ -
- - -

Scatta foto direttamente da smartphone.

-
+
+
+
+
Allegati con anteprima e descrizione
+

Ogni file compare qui sotto in un box dedicato, così puoi descriverlo subito prima dell'invio.

+
+
Max 20MB per file
+
-
- - -

Puoi caricare immagini, PDF e documenti; max 20MB per file.

+
+
+ + +

Scatta foto direttamente da smartphone.

+
+ +
+ + +

Immagini, PDF e documenti. I metadati foto vengono letti in fase di salvataggio.

+
+
+ +
+ Nessun allegato selezionato. +
+
@@ -92,5 +109,87 @@
+ + diff --git a/resources/views/public/water-reading/show.blade.php b/resources/views/public/water-reading/show.blade.php index c0f1757..e3686e3 100644 --- a/resources/views/public/water-reading/show.blade.php +++ b/resources/views/public/water-reading/show.blade.php @@ -11,7 +11,7 @@

Invio lettura acqua

-

Compila la lettura attuale del contatore per l'unità indicata. Puoi allegare massimo due foto.

+

Compila la lettura attuale del contatore per l'unità indicata. La lettura resta il campo principale; le foto servono a fissare subito il dato del display.

@if (session('success')) @@ -42,6 +42,14 @@
@csrf +
+ +
Inserisci solo il numero letto sul contatore. Se alleghi foto, il sistema conserva anche i metadati dello scatto.
+
+
-
-
- - +
+
Foto del contatore
+
+ + +
+
+
Nessuna foto 1 selezionata
+
Nessuna foto 2 selezionata
+
+ + \ No newline at end of file