netgescon-day0/app/Services/FattureElettroniche/FatturaElettronicaProtocolloService.php

806 lines
33 KiB
PHP

<?php
namespace App\Services\FattureElettroniche;
use App\Models\Documento;
use App\Models\FatturaElettronica;
use App\Models\GestioneContabile;
use App\Models\Stabile;
use App\Support\ArchivioPaths;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FatturaElettronicaProtocolloService
{
public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $userId = null): Documento
{
return DB::transaction(function () use ($fattura, $userId) {
$protocolCol = $this->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('/<head(\b[^>]*)>/', '<head$1><meta charset="utf-8" />', $html, 1) ?: $html;
}
return $html;
}
private function injectProtocolloIntoHtml(string $html, string $protocollo): string
{
$safe = htmlspecialchars($protocollo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$badge = '<div style="font-size:12px;margin:10px 0 0 0;color:#333;"><strong>Protocollo:</strong> ' . $safe . '</div>';
// Inserisce subito dopo l'apertura del body, se possibile.
$out = preg_replace('/<body(\b[^>]*)>/', '<body$1>' . $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 = '<style>@page { size: A4 portrait !important; margin: 12mm !important; }</style>';
// 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 = '<style>'
. 'html,body{max-width:100% !important;}'
. '*{box-sizing:border-box;}'
. 'table{max-width:100% !important; width:100% !important; table-layout:fixed !important;}'
. 'th,td{word-wrap:break-word; overflow-wrap:break-word;}'
. 'img{max-width:100% !important; height:auto !important;}'
. '</style>';
$html = preg_replace('/<head(\b[^>]*)>/', '<head$1>' . $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);
}
}
}