Explorar el Código

alleagato fattura e s3

FabioFratini hace 7 meses
padre
commit
509169d20e

+ 143 - 62
app/Http/Livewire/RecordOUT.php

@@ -9,7 +9,7 @@ use SimpleXMLElement;
 use Livewire\WithFileUploads;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
-
+use App\Services\RecordFileService;
 
 class RecordOUT extends Component
 {
@@ -63,6 +63,7 @@ class RecordOUT extends Component
         $add = false,
         $is_paid = false;
     public $attachment;
+    public $attachment_old;
 
     public $filterSupplier = 0, $filterPaymentMethod = 0, $filterCausals = [], $filterFrom = '', $filterTo = '', $filterCommercial = 0;
 
@@ -105,6 +106,13 @@ class RecordOUT extends Component
         'rows.*.causal_id.required' => 'La causale è obbligatoria'
     ];
 
+    protected $recordFileService;
+
+    public function boot()
+    {
+        $this->recordFileService = app(RecordFileService::class);
+    }
+
     public function getSupplierProperty()
     {
         $ret = null;
@@ -130,7 +138,8 @@ class RecordOUT extends Component
         $this->payment_method_id = null;
         $this->date = date("Y-m-d");
         $this->data_pagamento = date("Y-m-d");
-        //$this->attachment = null;
+        $this->attachment = null;
+        $this->attachment_old = null;
         $this->numero_fattura = null;
         $this->type = 'OUT';
         $this->commercial = 1;
@@ -180,7 +189,6 @@ class RecordOUT extends Component
         }
 
         $this->vats = \App\Models\Vat::select('id', 'name', 'value')->orderBy('value')->get();
-
     }
 
     public function mount()
@@ -286,7 +294,7 @@ class RecordOUT extends Component
         if (false) {
             if ($this->hasFilter) {
 
-                $datas = \App\Models\Record::where('type', 'OUT')->with('supplier', 'payment_method','is_paid');
+                $datas = \App\Models\Record::where('type', 'OUT')->with('supplier', 'payment_method', 'is_paid');
                 if ($this->filterSupplier > 0) {
                     $datas = $datas->where('supplier_id', $this->filterSupplier);
                 }
@@ -366,14 +374,8 @@ class RecordOUT extends Component
     public function store()
     {
         $this->emit('refresh');
-        /*
-        if ($this->attachment) {
-            Log::info("Attachment: " . json_encode($this->attachment));
-            $name = md5($this->attachment . microtime()) . '.' . $this->attachment->extension();
-            $this->attachment->storeAs('public', $name);
-        } */
-
-        if($this->numero_fattura == null || $this->numero_fattura == '') {
+
+        if ($this->numero_fattura == null || $this->numero_fattura == '') {
             $this->numero_fattura = 'USC-' . date('Ymd');
         }
 
@@ -394,8 +396,8 @@ class RecordOUT extends Component
                 'supplier_id' => $this->supplier_id,
                 'payment_method_id' => $this->payment_method_id,
                 'date' => $this->date,
-                'data_pagamento' =>$this->data_pagamento,
-                //'attachment' => $this->attachment,
+                'data_pagamento' => $this->data_pagamento,
+                'attachment' => '',
                 'type' => $this->type,
                 'amount' => $this->currencyToDouble($this->amount),
                 'commercial' => $this->commercial,
@@ -405,6 +407,19 @@ class RecordOUT extends Component
             Log::info("Record data being inserted: " . json_encode($record));
 
             $this->dataId = $record->id;
+            $this->recordFileService->createRecordFolders($record->id, 'OUT');
+            if ($this->attachment) {
+                try {
+                    $attachmentPath = $this->recordFileService->uploadAttachment($this->attachment, $record->id, 'OUT');
+
+                    $record->update(['attachment' => $attachmentPath]);
+
+                    Log::info("Attachment uploaded and record updated: " . $attachmentPath);
+                } catch (\Exception $ex) {
+                    Log::error("Error uploading attachment: " . $ex->getMessage());
+                }
+            }
+
             $tot = 0;
             foreach ($this->rows as $row) {
                 foreach ($row["when"] as $x => $y) {
@@ -474,13 +489,14 @@ class RecordOUT extends Component
                 $this->data_pagamento =  $record->data_pagamento;
                 $this->type = $record->type;
                 $this->numero_fattura = $record->numero_fattura;
-                //$attachment = $record->attachment;
+                $this->attachment_old = $record->attachment;
                 $this->commercial = $record->commercial;
                 $this->dataId = $record->id;
                 $this->update = true;
                 $this->add = false;
 
                 $this->rows = [];
+                $this->recordFileService->createRecordFolders($record->id, 'OUT');
 
                 $recordRows = \App\Models\RecordRow::where('record_id', $this->dataId)->get();
 
@@ -549,6 +565,21 @@ class RecordOUT extends Component
         }
 
         try {
+
+            $attachmentPath = $this->attachment_old;
+
+            if ($this->attachment) {
+                try {
+                    if ($this->attachment_old) {
+                        $this->recordFileService->deleteAttachment($this->attachment_old);
+                    }
+
+                    $attachmentPath = $this->recordFileService->uploadAttachment($this->attachment, $this->dataId, 'OUT');
+                } catch (\Exception $ex) {
+                    session()->flash('error', 'Errore caricamento allegato: ' . $ex->getMessage());
+                    return;
+                }
+            }
             \App\Models\Record::whereId($this->dataId)->update([
                 'member_id' => $this->member_id,
                 'supplier_id' => $this->supplier_id,
@@ -559,7 +590,7 @@ class RecordOUT extends Component
                 'commercial' => $this->commercial,
                 'is_paid' => $is_paid,
                 'numero_fattura' => $this->numero_fattura,
-                //'attachment' => $this->attachment,
+                'attachment' => $attachmentPath,
             ]);
 
             $tot = 0;
@@ -645,7 +676,12 @@ class RecordOUT extends Component
     public function delete($id)
     {
         try {
-            \App\Models\Record::find($id)->delete();
+            $record = \App\Models\Record::find($id);
+            if ($record->attachment) {
+                $this->recordFileService->deleteAttachment($record->attachment);
+            }
+
+            $record->delete();
             session()->flash('success', "Movimento eliminato");
         } catch (\Exception $e) {
             $this->emit('flash-error', 'Errore (' . $e->getMessage() . ')');
@@ -656,7 +692,13 @@ class RecordOUT extends Component
     {
         try {
             foreach ($this->multipleIds as $id) {
-                \App\Models\Record::find($id)->delete();
+                $record = \App\Models\Record::find($id);
+
+                if ($record->attachment) {
+                    $this->recordFileService->deleteAttachment($record->attachment);
+                }
+
+                $record->delete();
             }
         } catch (\Exception $e) {
             $this->emit('flash-error', 'Errore (' . $e->getMessage() . ')');
@@ -664,6 +706,25 @@ class RecordOUT extends Component
         $this->multipleAction = '';
     }
 
+    public function getAttachmentUrl($filePath)
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        return $this->recordFileService->getAttachmentUrl($filePath);
+    }
+
+    /**
+     * Remove attachment file
+     */
+    public function removeAttachment()
+    {
+        if ($this->attachment_old) {
+            $this->recordFileService->deleteAttachment($this->attachment_old);
+        }
+        $this->attachment_old = '';
+    }
     function currencyToDouble($val)
     {
         $x = str_replace("€", "", $val);
@@ -749,10 +810,10 @@ class RecordOUT extends Component
     public function importReceipts()
     {
         $this->validate([
-            //'receiptFiles.*' => 'required|mimes:xml|max:2048',
             'selectedCausal' => 'required|exists:causals,id',
         ]);
         Log::info("Importazione ricevute: " . json_encode($this->receiptFiles));
+
         try {
             $importCount = 0;
             $updateCount = 0;
@@ -771,6 +832,7 @@ class RecordOUT extends Component
                 try {
                     $fileName = $receiptFile->getClientOriginalName();
                     Log::info("Elaborazione file: " . $fileName);
+
                     // Carica e analizza il file XML
                     $xmlString = file_get_contents($receiptFile->getRealPath());
                     $xml = simplexml_load_string($xmlString);
@@ -792,7 +854,7 @@ class RecordOUT extends Component
                     // Estrai i dati dalla fattura elettronica
                     $fatturaData = $this->extractFatturaData($xml);
 
-                    // Trova o crea il fornitorez
+                    // Trova o crea il fornitore
                     $supplier = $this->findOrCreateSupplier($fatturaData);
 
                     // Trova il metodo di pagamento
@@ -808,16 +870,33 @@ class RecordOUT extends Component
                         $record = $this->updateRecord($existingRecord, $paymentMethodId, $fatturaData);
                         $isUpdate = true;
                         $updateCount++;
-                        $updatedFiles[] = $fileName; // Aggiungiamo il nome del file alla lista degli aggiornati
+                        $updatedFiles[] = $fileName;
                         Log::info("Fattura aggiornata con successo: {$fatturaData['numeroFattura']}, Fornitore: {$supplier->name}");
                     } else {
                         // Crea un nuovo record
                         $record = $this->createRecord($supplier->id, $paymentMethodId, $fatturaData);
                         $importCount++;
-                        $importedFiles[] = $fileName; // Aggiungiamo il nome del file alla lista degli importati
+                        $importedFiles[] = $fileName;
                         Log::info("Fattura importata con successo: {$fatturaData['numeroFattura']}, Fornitore: {$supplier->name}");
                     }
 
+                    // ← NOW $record exists! Create folder structure for the record
+                    $this->recordFileService->createRecordFolders($record->id, 'OUT');
+
+                    // ← Store the XML file as an attachment to the record
+                    try {
+                        $xmlAttachmentPath = $this->recordFileService->uploadXmlReceipt($receiptFile, $record->id, 'OUT');
+
+                        // ← Update the record with the XML attachment path (if you have a field for it)
+                        // If you want to store XML separately from regular attachments, you could add a xml_attachment field
+                        // Or if you want to overwrite the attachment field:
+                        $record->update(['attachment' => $xmlAttachmentPath]);
+
+                        Log::info("XML receipt stored as attachment: " . $xmlAttachmentPath);
+                    } catch (\Exception $ex) {
+                        Log::warning("Could not store XML as attachment: " . $ex->getMessage());
+                    }
+
                     // Crea il record row
                     $this->createRecordRow($record->id, $fatturaData);
 
@@ -1104,7 +1183,7 @@ class RecordOUT extends Component
 
         if ($record->data_pagamento != null) {
             $record->is_paid = true;
-        }else {
+        } else {
             $record->is_paid = false;
         }
 
@@ -1439,7 +1518,8 @@ class RecordOUT extends Component
         $this->reset(['receiptFiles']);
     }
 
-    private function getFriendlyErrorMessage($errorMessage) {
+    private function getFriendlyErrorMessage($errorMessage)
+    {
         // Errore di parsing XML iniziale
         if (strpos($errorMessage, "simplexml_load_string(): Entity: line 1: parser error : Start tag expected, '<' not found") !== false) {
             return "Il file non è in formato valido. Potrebbe essere danneggiato o in un formato diverso.";
@@ -1456,8 +1536,10 @@ class RecordOUT extends Component
         }
 
         // Errori di struttura interna
-        if (strpos($errorMessage, "Undefined index") !== false ||
-            strpos($errorMessage, "Trying to get property") !== false) {
+        if (
+            strpos($errorMessage, "Undefined index") !== false ||
+            strpos($errorMessage, "Trying to get property") !== false
+        ) {
             return "La fattura è incompleta o non contiene tutti i dati necessari.";
         }
 
@@ -1468,14 +1550,11 @@ class RecordOUT extends Component
     public function getVats()
     {
         $vats = array();
-        foreach($this->rows as $r)
-        {
-            if ($r["amount"] != null && $r["amount"] != "" && $r["vat_id"] > 0)
-            {
+        foreach ($this->rows as $r) {
+            if ($r["amount"] != null && $r["amount"] != "" && $r["vat_id"] > 0) {
                 $vat = getVatValue($this->currencyToDouble($r["amount"]), $r["vat_id"]);
                 $vatName = "";
-                foreach($this->vats as $v)
-                {
+                foreach ($this->vats as $v) {
                     if ($v->id == $r["vat_id"])
                         $vatName = $v->name;
                 }
@@ -1519,39 +1598,41 @@ class RecordOUT extends Component
         return $newVat->id;
     }
 
-public function viewData($id)
-{
-    Log::info("Visualizzazione dati per ID: " . $id);
-    try {
-        $record = \App\Models\Record::with(['supplier', 'payment_method', 'rows.causal'])->findOrFail($id);
+    public function viewData($id)
+    {
+        Log::info("Visualizzazione dati per ID: " . $id);
+        try {
+            $record = \App\Models\Record::with(['supplier', 'payment_method', 'rows.causal'])->findOrFail($id);
 
-        if (!$record) {
-            $this->emit('flash-error', 'Movimento non trovato');
-            return;
-        }
+            if (!$record) {
+                $this->emit('flash-error', 'Movimento non trovato');
+                return;
+            }
 
-        $record->formatted_date = date("d/m/Y", strtotime($record->date));
-        $record->formatted_data_pagamento = $record->data_pagamento ? date("d/m/Y", strtotime($record->data_pagamento)) : 'Non impostata';
-        $record->supplier_name = $record->supplier ? $record->supplier->name : 'N/A';
-        $record->payment_method_name = $record->payment_method ? $record->payment_method->name : 'N/A';
-        $record->formatted_amount = formatPrice($record->amount);
-        $record->payment_status = $record->is_paid ? 'Pagato' : 'Da Pagare';
-
-        foreach ($record->rows as $row) {
-            $row->causal_name = $row->causal ? $row->causal->getTree() : 'N/A';
-            $row->formatted_imponibile = $row->imponibile ? formatPrice($row->imponibile) : 'N/A';
-            $row->iva = $row->aliquota_iva ? formatPrice($row->aliquota_iva) : 'N/A';
-            $row->formatted_imposta = $row->imposta ? formatPrice($row->imposta) : 'N/A';
-            $row->formatted_amount = formatPrice($row->amount);
-        }
+            $record->formatted_date = date("d/m/Y", strtotime($record->date));
+            $record->formatted_data_pagamento = $record->data_pagamento ? date("d/m/Y", strtotime($record->data_pagamento)) : 'Non impostata';
+            $record->supplier_name = $record->supplier ? $record->supplier->name : 'N/A';
+            $record->payment_method_name = $record->payment_method ? $record->payment_method->name : 'N/A';
+            $record->formatted_amount = formatPrice($record->amount);
+            $record->payment_status = $record->is_paid ? 'Pagato' : 'Da Pagare';
+
+            foreach ($record->rows as $row) {
+                $row->causal_name = $row->causal ? $row->causal->getTree() : 'N/A';
+                $row->formatted_imponibile = $row->imponibile ? formatPrice($row->imponibile) : 'N/A';
+                $row->iva = $row->aliquota_iva ? formatPrice($row->aliquota_iva) : 'N/A';
+                $row->formatted_imposta = $row->imposta ? formatPrice($row->imposta) : 'N/A';
+                $row->formatted_amount = formatPrice($row->amount);
+            }
 
-        Log::info("Emitting show-record-details event");
-        $this->dispatchBrowserEvent('show-record-details', ['record' => $record]);
+            if ($record->attachment) {
+                $record->attachment_url = $this->getAttachmentUrl($record->attachment);
+            }
 
-    } catch (\Exception $e) {
-        Log::error("Errore nel caricamento dei dettagli: " . $e->getMessage());
-        $this->emit('flash-error', 'Errore nel caricamento dei dettagli: ' . $e->getMessage());
+            Log::info("Emitting show-record-details event");
+            $this->dispatchBrowserEvent('show-record-details', ['record' => $record]);
+        } catch (\Exception $e) {
+            Log::error("Errore nel caricamento dei dettagli: " . $e->getMessage());
+            $this->emit('flash-error', 'Errore nel caricamento dei dettagli: ' . $e->getMessage());
+        }
     }
 }
-
-}

+ 474 - 0
app/Services/RecordFileService.php

@@ -0,0 +1,474 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\UploadedFile;
+use Exception;
+
+class RecordFileService
+{
+    /**
+     * The storage disk to use for file operations
+     */
+    private $disk;
+
+    /**
+     * Allowed file extensions for record attachments
+     */
+    private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx', 'xml', 'txt', 'csv', 'xls', 'xlsx'];
+
+    /**
+     * Maximum file size in KB
+     */
+    private const MAX_FILE_SIZE = 10240; // 10MB
+
+    /**
+     * Constructor
+     */
+    public function __construct()
+    {
+        $this->disk = Storage::disk('s3');
+    }
+
+    /**
+     * Upload record attachment file
+     *
+     * @param UploadedFile $attachmentFile
+     * @param int $recordId
+     * @param string $recordType ('IN' or 'OUT')
+     * @return string
+     * @throws Exception
+     */
+    public function uploadAttachment(UploadedFile $attachmentFile, int $recordId, string $recordType = 'OUT'): string
+    {
+        try {
+            $currentClient = session('currentClient', 'iao');
+
+            // Validate file
+            $this->validateAttachmentFile($attachmentFile);
+
+            // Create filename
+            $originalName = pathinfo($attachmentFile->getClientOriginalName(), PATHINFO_FILENAME);
+            $extension = strtolower($attachmentFile->getClientOriginalExtension());
+            $timestamp = time();
+            $filename = 'attachment_' . $recordId . '_' . $timestamp . '_' . substr(md5($originalName), 0, 8) . '.' . $extension;
+
+            // Upload to S3
+            $s3Path = $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments/' . $filename;
+
+            $uploaded = $this->disk->putFileAs(
+                $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments',
+                $attachmentFile,
+                $filename,
+                'private'
+            );
+
+            if (!$uploaded) {
+                throw new Exception('Failed to upload attachment to S3: ' . $originalName);
+            }
+
+            Log::info("Record attachment uploaded", [
+                'record_id' => $recordId,
+                'record_type' => $recordType,
+                'path' => $s3Path,
+                'original_name' => $attachmentFile->getClientOriginalName(),
+                'size' => $attachmentFile->getSize()
+            ]);
+
+            return $s3Path;
+
+        } catch (Exception $e) {
+            Log::error("Error uploading record attachment", [
+                'record_id' => $recordId,
+                'record_type' => $recordType,
+                'error' => $e->getMessage()
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Upload XML receipt file (for electronic invoice imports)
+     *
+     * @param UploadedFile $xmlFile
+     * @param int $recordId
+     * @param string $recordType
+     * @return string
+     * @throws Exception
+     */
+    public function uploadXmlReceipt(UploadedFile $xmlFile, int $recordId, string $recordType = 'OUT'): string
+    {
+        try {
+            $currentClient = session('currentClient', 'iao');
+
+            // Validate XML file
+            $this->validateXmlFile($xmlFile);
+
+            // Create filename
+            $originalName = pathinfo($xmlFile->getClientOriginalName(), PATHINFO_FILENAME);
+            $extension = strtolower($xmlFile->getClientOriginalExtension());
+            $timestamp = time();
+            $filename = 'xml_receipt_' . $recordId . '_' . $timestamp . '_' . substr(md5($originalName), 0, 8) . '.' . $extension;
+
+            // Upload to S3
+            $s3Path = $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/xml/' . $filename;
+
+            $uploaded = $this->disk->putFileAs(
+                $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/xml',
+                $xmlFile,
+                $filename,
+                'private'
+            );
+
+            if (!$uploaded) {
+                throw new Exception('Failed to upload XML receipt to S3: ' . $originalName);
+            }
+
+            Log::info("XML receipt uploaded", [
+                'record_id' => $recordId,
+                'record_type' => $recordType,
+                'path' => $s3Path,
+                'original_name' => $xmlFile->getClientOriginalName(),
+                'size' => $xmlFile->getSize()
+            ]);
+
+            return $s3Path;
+
+        } catch (Exception $e) {
+            Log::error("Error uploading XML receipt", [
+                'record_id' => $recordId,
+                'record_type' => $recordType,
+                'error' => $e->getMessage()
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Get attachment file URL for display/download
+     *
+     * @param string $filePath
+     * @param string $expiresIn
+     * @return string|null
+     */
+    public function getAttachmentUrl(string $filePath, string $expiresIn = '+1 hour'): ?string
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        // Handle legacy local paths - return asset URL
+        if (!$this->isS3Path($filePath)) {
+            return asset('storage/' . $filePath);
+        }
+
+        try {
+            if (!$this->disk->exists($filePath)) {
+                Log::warning("Attachment file not found", ['path' => $filePath]);
+                return null;
+            }
+
+            return $this->disk->temporaryUrl($filePath, now()->add($expiresIn));
+
+        } catch (Exception $e) {
+            Log::error("Error generating attachment URL", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * Delete attachment file from S3
+     *
+     * @param string $filePath
+     * @return bool
+     */
+    public function deleteAttachment(string $filePath): bool
+    {
+        // Don't try to delete local files
+        if (!$this->isS3Path($filePath)) {
+            return false;
+        }
+
+        try {
+            if ($this->disk->exists($filePath)) {
+                $this->disk->delete($filePath);
+                Log::info("Attachment file deleted", ['path' => $filePath]);
+                return true;
+            }
+            return false;
+
+        } catch (Exception $e) {
+            Log::error("Error deleting attachment file", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return false;
+        }
+    }
+
+    /**
+     * Check if path is S3 path
+     */
+    private function isS3Path(string $path): bool
+    {
+        return strpos($path, '/records/') !== false ||
+               strpos($path, session('currentClient', 'iao')) === 0;
+    }
+
+    /**
+     * Validate the uploaded attachment file
+     *
+     * @param UploadedFile $attachmentFile
+     * @throws Exception
+     */
+    private function validateAttachmentFile(UploadedFile $attachmentFile): void
+    {
+        // Check if file is valid
+        if (!$attachmentFile->isValid()) {
+            throw new Exception('Invalid file upload');
+        }
+
+        // Check file size
+        if ($attachmentFile->getSize() > (self::MAX_FILE_SIZE * 1024)) {
+            throw new Exception('File size exceeds maximum allowed size of ' . self::MAX_FILE_SIZE . 'KB');
+        }
+
+        // Check file extension
+        $extension = strtolower($attachmentFile->getClientOriginalExtension());
+        if (!in_array($extension, self::ALLOWED_EXTENSIONS)) {
+            throw new Exception('File type not allowed. Allowed types: ' . implode(', ', self::ALLOWED_EXTENSIONS));
+        }
+
+        // Check mime type for additional security
+        $mimeType = $attachmentFile->getMimeType();
+        $allowedMimeTypes = [
+            'image/jpeg', 'image/png',
+            'application/pdf',
+            'application/msword',
+            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+            'application/xml', 'text/xml',
+            'text/plain',
+            'text/csv',
+            'application/vnd.ms-excel',
+            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+        ];
+
+        if (!in_array($mimeType, $allowedMimeTypes)) {
+            throw new Exception('Invalid file type');
+        }
+    }
+
+    /**
+     * Validate XML file specifically
+     *
+     * @param UploadedFile $xmlFile
+     * @throws Exception
+     */
+    private function validateXmlFile(UploadedFile $xmlFile): void
+    {
+        // Check if file is valid
+        if (!$xmlFile->isValid()) {
+            throw new Exception('Invalid XML file upload');
+        }
+
+        // Check file size
+        if ($xmlFile->getSize() > (self::MAX_FILE_SIZE * 1024)) {
+            throw new Exception('XML file size exceeds maximum allowed size of ' . self::MAX_FILE_SIZE . 'KB');
+        }
+
+        // Check file extension
+        $extension = strtolower($xmlFile->getClientOriginalExtension());
+        if ($extension !== 'xml') {
+            throw new Exception('Only XML files are allowed for receipt uploads');
+        }
+
+        // Check mime type
+        $mimeType = $xmlFile->getMimeType();
+        $allowedMimeTypes = ['application/xml', 'text/xml'];
+
+        if (!in_array($mimeType, $allowedMimeTypes)) {
+            throw new Exception('Invalid XML file type');
+        }
+    }
+
+    /**
+     * Create record folder structure in S3
+     *
+     * @param int $recordId
+     * @param string $recordType
+     */
+    public function createRecordFolders(int $recordId, string $recordType = 'OUT'): void
+    {
+        try {
+            $currentClient = session('currentClient', 'iao');
+
+            $folders = [
+                $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments/.gitkeep',
+                $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/xml/.gitkeep'
+            ];
+
+            foreach ($folders as $folder) {
+                if (!$this->disk->exists($folder)) {
+                    $this->disk->put($folder, '');
+                }
+            }
+
+            Log::info("Created record folder structure", [
+                'record_id' => $recordId,
+                'record_type' => $recordType
+            ]);
+
+        } catch (Exception $e) {
+            Log::error("Error creating record folders", [
+                'record_id' => $recordId,
+                'record_type' => $recordType,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * Get attachment file info
+     *
+     * @param string $filePath
+     * @return array|null
+     */
+    public function getAttachmentInfo(string $filePath): ?array
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        // Handle legacy local paths
+        if (!$this->isS3Path($filePath)) {
+            $localPath = storage_path('app/public/' . $filePath);
+            if (file_exists($localPath)) {
+                return [
+                    'path' => $filePath,
+                    'name' => basename($filePath),
+                    'size' => filesize($localPath),
+                    'last_modified' => filemtime($localPath),
+                    'url' => $this->getAttachmentUrl($filePath),
+                    'exists' => true,
+                    'storage_type' => 'local'
+                ];
+            }
+            return null;
+        }
+
+        if (!$this->disk->exists($filePath)) {
+            return null;
+        }
+
+        try {
+            return [
+                'path' => $filePath,
+                'name' => basename($filePath),
+                'size' => $this->disk->size($filePath),
+                'last_modified' => $this->disk->lastModified($filePath),
+                'url' => $this->getAttachmentUrl($filePath),
+                'exists' => true,
+                'storage_type' => 's3'
+            ];
+
+        } catch (Exception $e) {
+            Log::error("Error getting attachment info", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * List all attachments for a record
+     *
+     * @param int $recordId
+     * @param string $recordType
+     * @return array
+     */
+    public function listRecordAttachments(int $recordId, string $recordType = 'OUT'): array
+    {
+        try {
+            $currentClient = session('currentClient', 'iao');
+            $attachmentPath = $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments';
+
+            $files = $this->disk->files($attachmentPath);
+
+            return array_filter($files, function($file) {
+                $filename = basename($file);
+                return strpos($filename, 'attachment_') === 0;
+            });
+
+        } catch (Exception $e) {
+            Log::error("Error listing record attachments", [
+                'record_id' => $recordId,
+                'record_type' => $recordType,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    /**
+     * Store XML receipt files for batch import
+     *
+     * @param array $xmlFiles
+     * @param string $batchId
+     * @return array
+     */
+    public function storeXmlBatch(array $xmlFiles, string $batchId): array
+    {
+        $storedFiles = [];
+        $currentClient = session('currentClient', 'iao');
+
+        foreach ($xmlFiles as $index => $xmlFile) {
+            try {
+                $this->validateXmlFile($xmlFile);
+
+                $originalName = pathinfo($xmlFile->getClientOriginalName(), PATHINFO_FILENAME);
+                $extension = strtolower($xmlFile->getClientOriginalExtension());
+                $timestamp = time();
+                $filename = 'batch_' . $batchId . '_' . $index . '_' . $timestamp . '_' . substr(md5($originalName), 0, 8) . '.' . $extension;
+
+                $s3Path = $currentClient . '/imports/xml_batch/' . $batchId . '/' . $filename;
+
+                $uploaded = $this->disk->putFileAs(
+                    $currentClient . '/imports/xml_batch/' . $batchId,
+                    $xmlFile,
+                    $filename,
+                    'private'
+                );
+
+                if ($uploaded) {
+                    $storedFiles[] = [
+                        'original_name' => $xmlFile->getClientOriginalName(),
+                        's3_path' => $s3Path,
+                        'local_path' => $xmlFile->getRealPath(),
+                        'index' => $index
+                    ];
+
+                    Log::info("XML file stored for batch processing", [
+                        'batch_id' => $batchId,
+                        'original_name' => $xmlFile->getClientOriginalName(),
+                        's3_path' => $s3Path
+                    ]);
+                }
+
+            } catch (Exception $e) {
+                Log::error("Error storing XML file for batch", [
+                    'batch_id' => $batchId,
+                    'file_name' => $xmlFile->getClientOriginalName(),
+                    'error' => $e->getMessage()
+                ]);
+            }
+        }
+
+        return $storedFiles;
+    }
+}

+ 64 - 0
resources/views/livewire/records_out.blade.php

@@ -278,6 +278,70 @@
                                 @enderror
                             </div>
                         </div>
+                        <div class="row gx-2 mt-5">
+                            <span class="title-form d-block w-100">Allegato</span>
+                            <div class="col-md-12">
+                                @if ($attachment_old != '')
+                                    <div class="alert alert-info d-flex align-items-center justify-content-between mb-3">
+                                        <div class="d-flex align-items-center">
+                                            <i class="fa-solid fa-paperclip me-2"></i>
+                                            <div>
+                                                <strong>Allegato presente:</strong>
+                                                <a href="{{ $this->getAttachmentUrl($attachment_old) }}" target="_blank" class="ms-2 btn btn-sm btn-outline-primary">
+                                                    <i class="fa-regular fa-eye me-1"></i>Visualizza
+                                                </a>
+                                            </div>
+                                        </div>
+                                        <button type="button"
+                                                wire:click.prevent="removeAttachment()"
+                                                class="btn btn-sm btn-outline-danger"
+                                                data-bs-toggle="popover"
+                                                data-bs-trigger="hover focus"
+                                                data-bs-placement="bottom"
+                                                data-bs-content="Elimina allegato corrente">
+                                            <i class="fa-regular fa-trash-can me-1"></i>Rimuovi
+                                        </button>
+                                    </div>
+                                @endif
+
+                                <div class="mb-3">
+                                    <label for="attachment" class="form-label">
+                                        @if ($attachment_old != '')
+                                            Sostituisci allegato esistente
+                                        @else
+                                            Carica nuovo allegato
+                                        @endif
+                                    </label>
+                                    <input class="form-control"
+                                        type="file"
+                                        id="attachment"
+                                        wire:model="attachment"
+                                        accept="image/*,.pdf,.doc,.docx,.xml,.txt,.csv,.xls,.xlsx">
+                                </div>
+
+                                @if($attachment)
+                                    <div class="alert alert-success d-flex align-items-center mb-3">
+                                        <i class="fa-solid fa-check-circle me-2"></i>
+                                        <div>
+                                            <strong>Nuovo allegato selezionato:</strong>
+                                            <span class="text-muted">{{ $attachment->getClientOriginalName() }}</span>
+                                        </div>
+                                    </div>
+                                @endif
+
+                                @error('attachment')
+                                    <div class="alert alert-danger">
+                                        <i class="fa-solid fa-exclamation-triangle me-2"></i>{{ $message }}
+                                    </div>
+                                @enderror
+
+                                <small class="form-text text-muted">
+                                    <i class="fa-solid fa-info-circle me-1"></i>
+                                    <strong>Formati supportati:</strong> Immagini, PDF, documenti Word, XML, TXT, CSV, Excel.
+                                    <strong>Dimensione massima:</strong> 10MB.
+                                </small>
+                            </div>
+                        </div>
 
                         @foreach($rows as $idx => $row)