getDocumentoProtocolColumn(); $existing = Documento::query() ->where('documentable_type', FatturaElettronica::class) ->where('documentable_id', $fattura->id) ->first(); $stabile = Stabile::query() ->with(['amministratore:id,codice_amministratore,codice_univoco']) ->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco']) ->findOrFail((int) $fattura->stabile_id); $dataDoc = $fattura->data_fattura; $anno = $dataDoc ? (int) $dataDoc->format('Y') : (int) now()->format('Y'); $ym = $dataDoc ? $dataDoc->format('Y/m') : now()->format('Y/m'); $adminCode = $stabile->amministratore?->codice_amministratore; $stabileCode = $stabile->codice_stabile; $adminFolder = $adminCode ?: ('ID-' . (int) $stabile->amministratore_id); $stabileFolder = $stabileCode ?: ('ID-' . (int) $fattura->stabile_id); $stabileBase = ArchivioPaths::stabileBase($stabile, $stabile->amministratore); $gestione = $this->resolveGestioneForYear($stabile, $anno); $pdfBase = ArchivioPaths::fatturePdfGestionePath($stabile, $gestione, $anno, 'ordinaria', $stabile->amministratore) ?: $this->basePath($adminFolder, $stabileFolder, $anno); $xmlBase = ArchivioPaths::fattureXmlYearPath($stabile, $anno, $stabile->amministratore) ?: $this->basePath($adminFolder, $stabileFolder, $anno); $legacyBase = (is_string($stabileBase) && $stabileBase !== '') ? ($stabileBase . '/documenti/' . $anno . '/fatture-ricevute') : $this->basePath($adminFolder, $stabileFolder, $anno); // Se il Documento punta a un path valido (o viceversa), sincronizziamo prima. if ($existing) { $this->syncDocumentoAndPaths($fattura, $existing); } // Recupero soft dal base path (file già spostati manualmente). $this->recoverMissingPathsFromBase($fattura, $legacyBase); $this->recoverMissingPathsFromBase($fattura, $pdfBase); $this->recoverMissingPathsFromBase($fattura, $xmlBase); // Ripara PDF se salvato in vecchio schema con ID numerici. $newBase = (is_string($stabileBase) && $stabileBase !== '') ? ($stabileBase . '/fatture-ricevute/' . $ym . '/allegati') : null; $possibleAttachmentDirs = [ $newBase, 'amministratori/' . $adminFolder . '/stabili/' . $stabileFolder . '/fatture-ricevute/' . $ym . '/allegati', 'amministratori/' . (int) $stabile->amministratore_id . '/stabili/' . (int) $fattura->stabile_id . '/fatture-ricevute/' . $ym . '/allegati', ]; $possibleAttachmentDirs = array_values(array_filter($possibleAttachmentDirs, fn($v) => is_string($v) && $v !== '')); $pdfPath = $fattura->allegato_pdf_path; if ((! is_string($pdfPath) || $pdfPath === '' || ! Storage::disk('local')->exists($pdfPath)) && is_string($fattura->allegato_pdf_hash) && $fattura->allegato_pdf_hash !== '' ) { foreach ($possibleAttachmentDirs as $dir) { $found = $this->findFirstFileStartingWith($dir, $fattura->allegato_pdf_hash . '-'); if ($found) { $fattura->allegato_pdf_path = $found; $pdfPath = $found; break; } } } // Guardrail: se il PDF è presente ma il nome file sembra chiaramente di un'altra fattura/fornitore, // evitiamo di mostrarlo/ri-nominarlo e rigeneriamo da XML (se disponibile). // Applichiamo la regola solo a file già "protocollati" (FT-xxxx...) per non rompere naming legacy. try { $pdfPathNow = is_string($fattura->allegato_pdf_path) ? trim($fattura->allegato_pdf_path) : ''; $pdfNameNow = is_string($fattura->allegato_pdf_nome) && trim($fattura->allegato_pdf_nome) !== '' ? trim($fattura->allegato_pdf_nome) : ($pdfPathNow !== '' ? basename($pdfPathNow) : ''); $isProtocollato = ($pdfNameNow !== '' && preg_match('/^FT-\d{4}\b/i', $pdfNameNow) === 1); $supplierToken = $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? '')); $supplierToken = trim((string) $supplierToken); if ($isProtocollato && $supplierToken !== '' && mb_strlen($supplierToken) >= 4) { $nameUpper = mb_strtoupper($pdfNameNow); $tokenUpper = mb_strtoupper($supplierToken); if (! str_contains($nameUpper, $tokenUpper)) { $fattura->allegato_pdf_path = null; $fattura->allegato_pdf_nome = null; } } } catch (\Throwable) { // ignore } $protocollo = null; if ($existing) { $existingProtocol = null; try { $existingProtocol = is_string($existing->numero_protocollo ?? null) ? $existing->numero_protocollo : null; } catch (\Throwable) { $existingProtocol = null; } if (! $existingProtocol) { try { $existingProtocol = is_string($existing->protocollo ?? null) ? $existing->protocollo : null; } catch (\Throwable) { $existingProtocol = null; } } if (is_string($existingProtocol) && $existingProtocol !== '') { $protocollo = $existingProtocol; } } if (! $protocollo) { $protocollo = $this->detectProtocolloFromExistingFiles($fattura); } if (! $protocollo) { if ($protocolCol) { $next = $this->nextNumeroProtocollo((int) $fattura->stabile_id, $anno); $protocollo = 'FT-' . str_pad((string) $next, 4, '0', STR_PAD_LEFT); } else { // Se la tabella documenti non ha una colonna protocollo, assicuriamo comunque un nome unico per i file. $protocollo = 'FT-' . str_pad((string) ((int) $fattura->id), 6, '0', STR_PAD_LEFT); } } // Se il protocollo è già usato da un altro Documento (caso tipico: nome_file_xml "sporco" / recovery errato), // rigeneriamo un nuovo protocollo per evitare collisioni con l'unique. if (! $existing) { $tries = 0; while ($tries < 5) { $alreadyUsed = false; if ($protocolCol) { $alreadyUsed = Documento::query() ->where($protocolCol, $protocollo) ->exists(); } if (! $alreadyUsed) { break; } if ($protocolCol) { $next = $this->nextNumeroProtocollo((int) $fattura->stabile_id, $anno); $protocollo = 'FT-' . str_pad((string) $next, 4, '0', STR_PAD_LEFT); } else { $protocollo = 'FT-' . str_pad((string) ((int) $fattura->id), 6, '0', STR_PAD_LEFT); } $tries++; } } $sanFornitore = $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? 'FORNITORE')); $sanNumero = $this->sanitizeFilename((string) ($fattura->numero_fattura ?? ('ID-' . $fattura->id))); $dateForName = $dataDoc ? $dataDoc->format('Ymd') : now()->format('Ymd'); // Se manca il PDF (ma abbiamo almeno XML/metadata), generiamo un prospetto PDF archiviabile. $pdfPathNow = is_string($fattura->allegato_pdf_path) ? $fattura->allegato_pdf_path : ''; $hasPdfNow = is_string($pdfPathNow) && $pdfPathNow !== '' && Storage::disk('local')->exists($pdfPathNow); $hasXmlNow = (is_string($fattura->xml_content) && trim($fattura->xml_content) !== '') || (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)); if (! $hasPdfNow && $hasXmlNow) { $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf'; $targetPath = $pdfBase . '/' . $targetName; $this->ensureDir($pdfBase); // Evita overwrite: se esiste già, lo riusiamo. if (! Storage::disk('local')->exists($targetPath)) { // 1) Prova a generare un PDF "umano" usando XSL Assosoftware + dompdf. // 2) Se non disponibile (ext/librerie/errore), fallback al PDF minimale. $pdf = $this->tryGenerateHumanPdfFromXml($fattura, $protocollo) ?: $this->generateFallbackPdfForFattura($fattura, $protocollo); Storage::disk('local')->put($targetPath, $pdf); } $fattura->allegato_pdf_path = $targetPath; $fattura->allegato_pdf_nome = $targetName; } if (is_string($fattura->allegato_pdf_path) && $fattura->allegato_pdf_path !== '' && Storage::disk('local')->exists($fattura->allegato_pdf_path)) { $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf'; $targetPath = $pdfBase . '/' . $targetName; $this->ensureDir($pdfBase); if ($fattura->allegato_pdf_path !== $targetPath) { Storage::disk('local')->move($fattura->allegato_pdf_path, $targetPath); $fattura->allegato_pdf_path = $targetPath; } $fattura->allegato_pdf_nome = $targetName; } if (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)) { $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.xml'; $targetPath = $xmlBase . '/' . $targetName; $this->ensureDir($xmlBase); if ($fattura->xml_path !== $targetPath) { Storage::disk('local')->move($fattura->xml_path, $targetPath); $fattura->xml_path = $targetPath; } $fattura->nome_file_xml = $targetName; } $fattura->save(); $docData = [ 'utente_id' => $userId, 'nome' => $protocollo . ' - ' . $sanFornitore, 'tipologia' => 'fattura', 'tipo_documento' => 'fattura', 'fornitore' => $fattura->fornitore_denominazione, 'data_documento' => $fattura->data_fattura, 'data_scadenza' => $fattura->data_scadenza, 'importo_collegato' => $fattura->totale, 'numero_protocollo' => $protocollo, 'protocollo' => $protocollo, 'nome_file' => $fattura->allegato_pdf_nome ?: ($fattura->nome_file_xml ?: null), 'path_file' => $fattura->allegato_pdf_path ?: ($fattura->xml_path ?: null), 'percorso_file' => $fattura->allegato_pdf_path ?: ($fattura->xml_path ?: null), ]; // Compatibilità: alcune installazioni non hanno (più) certi campi su documenti. // Persistiamo solo colonne esistenti per evitare errori SQL. foreach (array_keys($docData) as $col) { try { if (! Schema::hasColumn('documenti', $col)) { unset($docData[$col]); } } catch (\Throwable) { // Se Schema non è disponibile, non rischiamo: rimuoviamo campi "non core". if (in_array($col, ['mime_type', 'hash_file'], true)) { unset($docData[$col]); } } } if ($existing) { $existing->fill(array_filter($docData, fn($v) => $v !== null && $v !== '')); $existing->save(); return $existing; } $createData = array_merge($docData, [ 'documentable_type' => FatturaElettronica::class, 'documentable_id' => $fattura->id, 'stabile_id' => (int) $fattura->stabile_id, 'data_upload' => now(), 'approvato' => false, 'archiviato' => false, 'is_demo' => false, ]); foreach (array_keys($createData) as $col) { try { if (! Schema::hasColumn('documenti', $col)) { unset($createData[$col]); } } catch (\Throwable) { // ignore } } return Documento::query()->create($createData); }); } private function resolveGestioneForYear(Stabile $stabile, int $year): ?GestioneContabile { if (! Schema::hasTable('gestioni_contabili')) { return null; } $query = GestioneContabile::query() ->where('stabile_id', (int) $stabile->id) ->where('anno_gestione', $year); $ordinaria = (clone $query) ->where('tipo_gestione', 'ordinaria') ->orderByDesc('gestione_attiva') ->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END") ->orderByDesc('id') ->first(); if ($ordinaria) { return $ordinaria; } return $query ->orderByRaw("CASE WHEN tipo_gestione = 'straordinaria' THEN 0 ELSE 1 END") ->orderByDesc('gestione_attiva') ->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END") ->orderByDesc('id') ->first(); } private function getDocumentoProtocolColumn(): ?string { static $cached = null; static $resolved = false; if ($resolved) { return $cached; } $resolved = true; try { if (Schema::hasColumn('documenti', 'numero_protocollo')) { $cached = 'numero_protocollo'; return $cached; } if (Schema::hasColumn('documenti', 'protocollo')) { $cached = 'protocollo'; return $cached; } } catch (\Throwable) { // ignore } $cached = null; return null; } private function generateFallbackPdfForFattura(FatturaElettronica $fattura, string $protocollo): string { $lines = []; $lines[] = 'NETGESCON - Prospetto fattura (generato automaticamente)'; $lines[] = 'Protocollo: ' . $protocollo; $lines[] = ''; $fornitore = trim((string) ($fattura->fornitore_denominazione ?? '')); $num = trim((string) ($fattura->numero_fattura ?? '')); $data = $fattura->data_fattura ? $fattura->data_fattura->format('d/m/Y') : ''; $tot = is_numeric($fattura->totale ?? null) ? number_format((float) $fattura->totale, 2, ',', '.') : ''; $piva = trim((string) ($fattura->fornitore_piva ?? '')); $cf = trim((string) ($fattura->fornitore_cf ?? '')); if ($fornitore !== '') { $lines[] = 'Fornitore: ' . $fornitore; } if ($piva !== '' || $cf !== '') { $lines[] = 'P.IVA/CF: ' . ($piva !== '' ? $piva : '—') . ' / ' . ($cf !== '' ? $cf : '—'); } if ($num !== '' || $data !== '') { $lines[] = 'Fattura: ' . ($num !== '' ? $num : '—') . ' - ' . ($data !== '' ? $data : '—'); } if ($tot !== '') { $lines[] = 'Totale: EUR ' . $tot; } $lines[] = ''; $lines[] = 'Nota: il PDF originale non era disponibile; questo documento serve solo per archiviazione e protocollazione.'; return $this->buildSimplePdf($lines); } private function tryGenerateHumanPdfFromXml(FatturaElettronica $fattura, string $protocollo): ?string { try { $xml = $this->getXmlContentForFattura($fattura); if (! is_string($xml) || trim($xml) === '') { return null; } $html = $this->transformFatturaPaXmlToHtmlWithAssosoftwareXsl($xml); if (! is_string($html) || trim($html) === '') { return null; } // Inietta una riga protocollo (utile anche in PDF/archivio). $html = $this->injectProtocolloIntoHtml($html, $protocollo); return $this->renderHtmlToPdfWithDompdf($html); } catch (\Throwable) { return null; } } private function getXmlContentForFattura(FatturaElettronica $fattura): ?string { $xml = is_string($fattura->xml_content) ? $fattura->xml_content : null; if (is_string($xml) && trim($xml) !== '') { return $xml; } $path = is_string($fattura->xml_path) ? $fattura->xml_path : ''; if ($path !== '' && Storage::disk('local')->exists($path)) { try { $data = Storage::disk('local')->get($path); return is_string($data) ? $data : null; } catch (\Throwable) { return null; } } return null; } private function transformFatturaPaXmlToHtmlWithAssosoftwareXsl(string $xml): ?string { if (! class_exists(\XSLTProcessor::class) || ! class_exists(\DOMDocument::class)) { return null; } $xslPath = config('contabilita.fatture_elettroniche.assosoftware_xsl_path'); if (! is_string($xslPath) || trim($xslPath) === '' || ! is_file($xslPath)) { $fallback = base_path('docs/Importazione/FoglioStileAssoSoftware.xsl'); if (is_file($fallback)) { $xslPath = $fallback; } else { return null; } } $xmlDoc = new \DOMDocument(); $xslDoc = new \DOMDocument(); $xmlLoaded = @$xmlDoc->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_COMPACT); if (! $xmlLoaded) { return null; } $xslLoaded = @$xslDoc->load($xslPath, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_COMPACT); if (! $xslLoaded) { return null; } $proc = new \XSLTProcessor(); @$proc->importStylesheet($xslDoc); $html = @$proc->transformToXML($xmlDoc); if (! is_string($html) || trim($html) === '') { return null; } // Assicura charset per dompdf. if (! str_contains($html, 'charset=')) { $html = preg_replace('/]*)>/', '', $html, 1) ?: $html; } return $html; } private function injectProtocolloIntoHtml(string $html, string $protocollo): string { $safe = htmlspecialchars($protocollo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $badge = '
Protocollo: ' . $safe . '
'; // Inserisce subito dopo l'apertura del body, se possibile. $out = preg_replace('/]*)>/', '' . $badge, $html, 1); return is_string($out) && $out !== '' ? $out : ($badge . $html); } private function renderHtmlToPdfWithDompdf(string $html): ?string { if (! class_exists(Dompdf::class)) { return null; } // Forza A4 anche via CSS (oltre a setPaper). // Alcuni fogli XSL includono @page size: Legal; in tal caso, rimuoviamo le regole @page e iniettiamo A4. try { if (str_contains($html, '@page')) { $html = preg_replace('/@page\s*\{[^}]*\}/s', '', $html) ?: $html; } } catch (\Throwable) { // ignore } $a4Css = ''; // Alcuni HTML prodotti da XSL hanno layout fisso (width in px) che può eccedere l'area stampabile, // causando "taglio" a destra. Forziamo un contenimento soft. $fitCss = ''; $html = preg_replace('/]*)>/', '' . $a4Css . $fitCss, $html, 1) ?: ($a4Css . $fitCss . $html); $options = new Options(); $options->setIsRemoteEnabled(false); $options->setIsHtml5ParserEnabled(true); $options->setDefaultFont('DejaVu Sans'); // IMPORTANT: molti XSL producono layout a larghezza fissa in px. // Aumentando la DPI, i px diventano più piccoli in punti => meno rischio di taglio a destra. $options->setDpi(120); $dompdf = new Dompdf($options); $dompdf->setPaper('A4', 'portrait'); $dompdf->loadHtml($html, 'UTF-8'); $dompdf->render(); $out = $dompdf->output(); return is_string($out) && $out !== '' ? $out : null; } private function buildSimplePdf(array $lines): string { $escape = function (string $text): string { $text = str_replace('\\', '\\\\', $text); $text = str_replace('(', '\\(', $text); $text = str_replace(')', '\\)', $text); $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text); return (string) $text; }; $content = "BT\n/F1 12 Tf\n14 TL\n50 800 Td\n"; foreach ($lines as $i => $line) { $line = $escape((string) $line); if ($i === 0) { $content .= '(' . $line . ") Tj\n"; } else { $content .= "T*\n(" . $line . ") Tj\n"; } } $content .= "\nET"; $objects = []; $objects[] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"; $objects[] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; $objects[] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n"; $objects[] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"; $objects[] = "5 0 obj\n<< /Length " . strlen($content) . " >>\nstream\n" . $content . "\nendstream\nendobj\n"; $pdf = "%PDF-1.4\n"; $offsets = [0]; foreach ($objects as $obj) { $offsets[] = strlen($pdf); $pdf .= $obj; } $xrefPos = strlen($pdf); $pdf .= "xref\n0 " . (count($objects) + 1) . "\n"; $pdf .= "0000000000 65535 f \n"; for ($i = 1; $i <= count($objects); $i++) { $pdf .= str_pad((string) $offsets[$i], 10, '0', STR_PAD_LEFT) . " 00000 n \n"; } $pdf .= "trailer\n<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\n"; $pdf .= "startxref\n" . $xrefPos . "\n%%EOF"; return $pdf; } private function syncDocumentoAndPaths(FatturaElettronica $fattura, Documento $doc): void { // Se il Documento punta a un file valido ma la FE no, aggiorniamo la FE. $docPath = (string) ($doc->percorso_file ?: ($doc->path_file ?: '')); $docName = (string) ($doc->nome_file ?: ''); $needsSaveFattura = false; if ($docPath !== '' && Storage::disk('local')->exists($docPath)) { if (! is_string($fattura->allegato_pdf_path) || $fattura->allegato_pdf_path === '' || ! Storage::disk('local')->exists((string) $fattura->allegato_pdf_path)) { if (str_ends_with(strtolower($docPath), '.pdf')) { $fattura->allegato_pdf_path = $docPath; if ($docName !== '') { $fattura->allegato_pdf_nome = $docName; } $needsSaveFattura = true; } } if (! is_string($fattura->xml_path) || $fattura->xml_path === '' || ! Storage::disk('local')->exists((string) $fattura->xml_path)) { if (str_ends_with(strtolower($docPath), '.xml')) { $fattura->xml_path = $docPath; if ($docName !== '') { $fattura->nome_file_xml = $docName; } $needsSaveFattura = true; } } } if ($needsSaveFattura) { $fattura->save(); } // Se la FE ha un file valido ma il Documento no, aggiorniamo Documento. $fatturaPath = null; $fatturaName = null; if (is_string($fattura->allegato_pdf_path) && $fattura->allegato_pdf_path !== '' && Storage::disk('local')->exists($fattura->allegato_pdf_path)) { $fatturaPath = $fattura->allegato_pdf_path; $fatturaName = $fattura->allegato_pdf_nome ?: null; } elseif (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)) { $fatturaPath = $fattura->xml_path; $fatturaName = $fattura->nome_file_xml ?: null; } if ($fatturaPath && (! is_string($docPath) || $docPath === '' || ! Storage::disk('local')->exists($docPath))) { $doc->forceFill([ 'nome_file' => $fatturaName, 'path_file' => $fatturaPath, 'percorso_file' => $fatturaPath, ])->save(); } } private function recoverMissingPathsFromBase(FatturaElettronica $fattura, string $base): void { $needsSave = false; $pdfOk = is_string($fattura->allegato_pdf_path) && $fattura->allegato_pdf_path !== '' && Storage::disk('local')->exists($fattura->allegato_pdf_path); $xmlOk = is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path); if ($pdfOk && $xmlOk) { return; } if (! Storage::disk('local')->exists($base)) { return; } $files = Storage::disk('local')->files($base); $needleNumero = trim((string) $this->sanitizeFilename((string) ($fattura->numero_fattura ?? ''))); $needleFornitore = trim((string) $this->sanitizeFilename((string) ($fattura->fornitore_denominazione ?? ''))); $needlePiva = trim((string) ($fattura->fornitore_piva ?? '')); $needleCf = trim((string) ($fattura->fornitore_cf ?? '')); // Evita agganci sbagliati: se il numero è vuoto o troppo corto (es. "1", "2"), non proviamo recovery. // In quei casi è più sicuro rigenerare da XML. if ($needleNumero === '' || mb_strlen($needleNumero) < 4) { return; } // Se manca anche il fornitore, il rischio di collisione è alto. if ($needleFornitore === '' || mb_strlen($needleFornitore) < 4) { return; } $pdfCandidates = []; $xmlCandidates = []; foreach ($files as $file) { $name = basename($file); $nameUpper = mb_strtoupper($name); $numUpper = mb_strtoupper($needleNumero); $fornUpper = mb_strtoupper($needleFornitore); $matchNumero = str_contains($nameUpper, $numUpper); $matchFornitore = str_contains($nameUpper, $fornUpper); $matchPiva = ($needlePiva !== '' && str_contains($nameUpper, $needlePiva)); $matchCf = ($needleCf !== '' && str_contains($nameUpper, $needleCf)); $strongMatch = $matchNumero && ($matchFornitore || $matchPiva || $matchCf); if (! $pdfOk && str_ends_with(strtolower($name), '.pdf')) { if ($strongMatch) { $pdfCandidates[] = ['path' => (string) $file, 'name' => (string) $name]; } } if (! $xmlOk && str_ends_with(strtolower($name), '.xml')) { if ($strongMatch) { $xmlCandidates[] = ['path' => (string) $file, 'name' => (string) $name]; } } } if (! $pdfOk && count($pdfCandidates) === 1) { $fattura->allegato_pdf_path = $pdfCandidates[0]['path']; $fattura->allegato_pdf_nome = $pdfCandidates[0]['name']; $pdfOk = true; $needsSave = true; } if (! $xmlOk && count($xmlCandidates) === 1) { $fattura->xml_path = $xmlCandidates[0]['path']; $fattura->nome_file_xml = $xmlCandidates[0]['name']; $xmlOk = true; $needsSave = true; } if ($needsSave) { $fattura->save(); } } private function detectProtocolloFromExistingFiles(FatturaElettronica $fattura): ?string { $candidates = []; if (is_string($fattura->allegato_pdf_nome) && $fattura->allegato_pdf_nome !== '') { $candidates[] = $fattura->allegato_pdf_nome; } if (is_string($fattura->nome_file_xml) && $fattura->nome_file_xml !== '') { $candidates[] = $fattura->nome_file_xml; } foreach ($candidates as $name) { if (preg_match('/^(FT-\d{4})\b/', $name, $m)) { return (string) $m[1]; } } // fallback: prova dal path foreach ([(string) ($fattura->allegato_pdf_path ?? ''), (string) ($fattura->xml_path ?? '')] as $path) { if ($path !== '' && preg_match('/\/(FT-\d{4})\b/', $path, $m)) { return (string) $m[1]; } } return null; } private function nextNumeroProtocollo(int $stabileId, int $anno): int { $protocolCol = $this->getDocumentoProtocolColumn(); if (! $protocolCol) { return 1; } // Nota: la colonna protocollo è unique a livello tabella. // Quindi il protocollo FT-* non può "resettare" per anno o per stabile senza includere l'anno nel prefisso. // Per evitare collisioni, calcoliamo il prossimo numero globalmente. $ultimo = Documento::query() ->whereNotNull($protocolCol) ->where($protocolCol, 'like', 'FT-%') ->lockForUpdate() ->orderByDesc($protocolCol) ->first(); $value = null; if ($ultimo) { try { $value = is_string($ultimo->{$protocolCol} ?? null) ? (string) $ultimo->{$protocolCol} : null; } catch (\Throwable) { $value = null; } } if (! is_string($value) || $value === '') { return 1; } if (preg_match('/^FT-(\d{1,})$/', $value, $m)) { return ((int) $m[1]) + 1; } return 1; } private function basePath(string $adminFolder, string $stabileFolder, int $anno): string { return 'amministratori/' . $adminFolder . '/stabili/' . $stabileFolder . '/documenti/' . $anno . '/fatture-ricevute'; } private function findFirstFileStartingWith(string $dir, string $prefix): ?string { try { if (! Storage::disk('local')->exists($dir)) { return null; } $files = Storage::disk('local')->files($dir); foreach ($files as $file) { if (str_starts_with(basename((string) $file), $prefix)) { return (string) $file; } } } catch (\Throwable) { return null; } return null; } private function sanitizeFilename(string $value): string { $value = trim($value); $value = preg_replace('/\s+/', ' ', $value); $value = str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '-', (string) $value); $value = preg_replace('/[^\pL\pN \-_.]/u', '', (string) $value); $value = trim((string) $value); if ($value === '') { $value = (string) Str::uuid(); } return $value; } private function ensureDir(string $dir): void { if (! Storage::disk('local')->exists($dir)) { Storage::disk('local')->makeDirectory($dir); } } }