, * } */ public function parse(string $xml): array { $xml = $this->sanitizeXml($xml); $dom = new DOMDocument(); $dom->preserveWhiteSpace = false; $dom->formatOutput = false; // Se l'XML è sporco, proviamo comunque a caricarlo $loaded = @$dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); if (! $loaded) { throw new \RuntimeException('XML non valido'); } $xpath = new DOMXPath($dom); // IdentificativoSdI non è sempre presente negli XML (dipende dal flusso/origine). // Se presente, lo salviamo per matching deterministico con la “Lista AdE”. $sdiFile = $this->firstString($xpath, "//*[local-name()='IdentificativoSdI']"); $tipoDocumento = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='TipoDocumento']"); $numero = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='Numero']"); $data = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='Data']"); $scadenza = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='DataScadenzaPagamento']"); $modalitaPagamento = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='ModalitaPagamento']"); $iban = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='IBAN']"); $bic = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='BIC']"); $totaleDocumento = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='ImportoTotaleDocumento']"); $fornitorePiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']"); $fornitoreCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']"); $denominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']"); $nome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']"); $cognome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Cognome']"); $regimeFiscale = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']/*[local-name()='RegimeFiscale']"); $fornitoreIndirizzo = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Indirizzo']"); $fornitoreCivico = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='NumeroCivico']"); $fornitoreCap = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='CAP']"); $fornitoreCitta = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Comune']"); $fornitoreProvincia = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Provincia']"); $fornitoreNazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Nazione']"); $fornitoreTelefono = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Contatti']/*[local-name()='Telefono']"); $fornitoreFax = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Contatti']/*[local-name()='Fax']"); $fornitoreEmail = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Contatti']/*[local-name()='Email']"); if (! $denominazione) { $denominazione = trim(implode(' ', array_filter([$nome, $cognome]))); } $codiceDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='CodiceDestinatario']"); $pecDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='PECDestinatario']"); $destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']"); $destinatarioPiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']"); $destDenominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']"); $destNome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']"); $destCognome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Cognome']"); if (! $destDenominazione) { $destDenominazione = trim(implode(' ', array_filter([$destNome, $destCognome]))); } [$imponibile, $iva] = $this->sumRiepilogo($xpath); $consumi = $this->extractConsumi($xpath); $ritenuta = $this->extractRitenuta($xpath); $righe = $this->extractRighe($xpath); // Alcuni XML (soprattutto vecchi) esprimono componenti economiche fuori da DettaglioLinee, // ad esempio Cassa Previdenziale e Bollo. Se non li trasformiamo in righe, la quadratura // tra totale documento e somma righe risulta errata (tipico: delta = ImportoContributoCassa). $righe = $this->appendExtraRigheFromGenerali($xpath, $righe); $trasmissione = [ 'id_paese' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']//*[local-name()='IdTrasmittente']/*[local-name()='IdPaese']"), 'id_codice' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']//*[local-name()='IdTrasmittente']/*[local-name()='IdCodice']"), 'progressivo_invio' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='ProgressivoInvio']"), 'formato_trasmissione' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='FormatoTrasmissione']"), ]; $totale = $this->toFloat($totaleDocumento); if ($totale === null) { $totale = null; if ($imponibile !== null || $iva !== null) { $totale = round((float) ($imponibile ?? 0) + (float) ($iva ?? 0), 2); } } return [ 'tipo_documento' => $tipoDocumento ?: null, 'sdi_file' => $sdiFile ?: null, 'numero_fattura' => $numero ?: null, 'data_fattura' => $this->toDate($data), 'data_scadenza' => $this->toDate($scadenza), 'fornitore_piva' => $this->normalizeId($fornitorePiva), 'fornitore_cf' => $this->normalizeId($fornitoreCf), 'fornitore_denominazione' => $denominazione ?: null, 'fornitore_nome' => $nome ?: null, 'fornitore_cognome' => $cognome ?: null, 'fornitore_indirizzo' => $fornitoreIndirizzo ?: null, 'fornitore_civico' => $fornitoreCivico ?: null, 'fornitore_cap' => $fornitoreCap ?: null, 'fornitore_citta' => $fornitoreCitta ?: null, 'fornitore_provincia' => $fornitoreProvincia ?: null, 'fornitore_nazione' => $fornitoreNazione ?: null, 'fornitore_telefono' => $fornitoreTelefono ?: null, 'fornitore_email' => $fornitoreEmail ?: null, 'fornitore_fax' => $fornitoreFax ?: null, 'fornitore_regime_fiscale' => $regimeFiscale ?: null, 'imponibile' => $imponibile, 'iva' => $iva, 'totale' => $totale, 'codice_destinatario' => $codiceDest ?: null, 'pec_destinatario' => $pecDest ?: null, 'destinatario_cf' => $this->normalizeId($destinatarioCf), 'destinatario_piva' => $this->normalizeId($destinatarioPiva), 'destinatario_denominazione' => $destDenominazione ?: null, 'pagamento_modalita' => $modalitaPagamento ?: null, 'pagamento_iban' => $this->normalizeIban($iban), 'pagamento_bic' => $this->normalizeId($bic), 'consumo_valore' => $consumi['valore'], 'consumo_unita' => $consumi['unita'], 'consumo_riferimento' => $consumi['riferimento'], 'consumo_raw' => $consumi['raw'], 'trasmissione' => $trasmissione, 'ritenuta' => $ritenuta, 'righe' => $righe, ]; } private function sanitizeXml(string $xml): string { $xml = preg_replace('/^\xEF\xBB\xBF/', '', $xml) ?? $xml; if (function_exists('mb_check_encoding') && ! mb_check_encoding($xml, 'UTF-8')) { if (function_exists('mb_convert_encoding')) { $xml = mb_convert_encoding($xml, 'UTF-8', 'UTF-8,ISO-8859-1,Windows-1252'); } } $fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $xml); if (is_string($fixed) && $fixed !== '') { $xml = $fixed; } $xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml) ?? $xml; return $xml; } /** * @param array $righe * @return array */ private function appendExtraRigheFromGenerali(DOMXPath $xpath, array $righe): array { $nextNumero = 1; foreach ($righe as $r) { if (isset($r['numero_linea']) && is_int($r['numero_linea']) && $r['numero_linea'] >= $nextNumero) { $nextNumero = $r['numero_linea'] + 1; } } // DatiCassaPrevidenziale può comparire 1..N volte. $nodes = $xpath->query("//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiCassaPrevidenziale']"); if ($nodes && $nodes->length > 0) { foreach ($nodes as $node) { $tipoCassa = $this->firstStringNode($xpath, "./*[local-name()='TipoCassa']", $node); $aliquotaIva = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='AliquotaIVA']", $node)); $importo = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='ImportoContributoCassa']", $node)); if ($importo === null || abs($importo) < 0.00001) { continue; } $label = 'Cassa previdenziale'; if (is_string($tipoCassa) && trim($tipoCassa) !== '') { $label .= ' ' . trim($tipoCassa); } $righe[] = [ 'numero_linea' => $nextNumero++, 'descrizione' => $label, 'quantita' => null, 'prezzo_unitario' => null, 'prezzo_totale' => $importo, 'aliquota_iva' => $aliquotaIva, 'ritenuta' => null, ]; } } // DatiBollo: in alcuni tracciati può essere conteggiato nel totale documento. $bollo = $this->toFloat($this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiBollo']/*[local-name()='ImportoBollo']")); if ($bollo !== null && abs($bollo) >= 0.00001) { $righe[] = [ 'numero_linea' => $nextNumero++, 'descrizione' => 'Bollo', 'quantita' => null, 'prezzo_unitario' => null, 'prezzo_totale' => $bollo, 'aliquota_iva' => 0.0, 'ritenuta' => null, ]; } return $righe; } /** * @return array{tipo: string|null, importo: float|null, aliquota: float|null, causale: string|null} */ private function extractRitenuta(DOMXPath $xpath): array { $tipo = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='TipoRitenuta']"); $importo = $this->toFloat($this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='ImportoRitenuta']")); $aliquota = $this->toFloat($this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='AliquotaRitenuta']")); $causale = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='CausalePagamento']"); return [ 'tipo' => $tipo ?: null, 'importo' => $importo, 'aliquota' => $aliquota, 'causale' => $causale ?: null, ]; } /** * @return array */ private function extractRighe(DOMXPath $xpath): array { $out = []; $nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DettaglioLinee']"); if (! $nodes || $nodes->length < 1) { return $out; } foreach ($nodes as $node) { $numeroLinea = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='NumeroLinea']", $node)); $descrizione = $this->firstStringNode($xpath, "./*[local-name()='Descrizione']", $node); $quantita = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='Quantita']", $node)); $prezzoUnitario = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='PrezzoUnitario']", $node)); $prezzoTotale = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='PrezzoTotale']", $node)); $aliquotaIva = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='AliquotaIVA']", $node)); $ritenuta = $this->firstStringNode($xpath, "./*[local-name()='Ritenuta']", $node); $ritenutaBool = null; if (is_string($ritenuta) && $ritenuta !== '') { $ritenutaBool = strtoupper(trim($ritenuta)) === 'SI'; } $out[] = [ 'numero_linea' => $numeroLinea !== null ? (int) $numeroLinea : null, 'descrizione' => $descrizione ?: null, 'quantita' => $quantita, 'prezzo_unitario' => $prezzoUnitario, 'prezzo_totale' => $prezzoTotale, 'aliquota_iva' => $aliquotaIva, 'ritenuta' => $ritenutaBool, ]; } return $out; } private function firstStringNode(DOMXPath $xpath, string $expr, \DOMNode $contextNode): ?string { $nodes = $xpath->query($expr, $contextNode); if (! $nodes || $nodes->length < 1) { return null; } $value = trim((string) $nodes->item(0)?->textContent); return $value !== '' ? $value : null; } /** * @return array{valore: float|null, unita: string|null, riferimento: string|null, raw: string|null} */ private function extractConsumi(DOMXPath $xpath): array { $texts = []; $nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DettaglioLinee']/*[local-name()='Descrizione']"); if ($nodes) { foreach ($nodes as $n) { $v = trim((string) $n->textContent); if ($v !== '') { $texts[] = $v; } } } // AltriDatiGestionali spesso contiene codici (utenza/POD/PDR/letture) in forma testuale. $nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DettaglioLinee']//*[local-name()='AltriDatiGestionali']//*[local-name()='RiferimentoTesto']"); if ($nodes) { foreach ($nodes as $n) { $v = trim((string) $n->textContent); if ($v !== '') { $texts[] = $v; } } } $raw = trim(implode("\n", array_values(array_unique($texts)))); if ($raw === '') { return ['valore' => null, 'unita' => null, 'riferimento' => null, 'raw' => null]; } $riferimento = null; if (preg_match('/\butenza\s*([0-9]{3,})\b/i', $raw, $m)) { $riferimento = 'utenza ' . $m[1]; } elseif (preg_match('/\bPOD\s*([A-Z0-9]{10,})\b/i', $raw, $m)) { $riferimento = 'POD ' . strtoupper($m[1]); } elseif (preg_match('/\bPDR\s*([0-9]{8,})\b/i', $raw, $m)) { $riferimento = 'PDR ' . $m[1]; } // Priorità: MC (acqua), kWh (energia), Smc (gas) $valore = null; $unita = null; if (preg_match('/\bMC\s*([0-9]+(?:[\.,][0-9]+)?)\b/i', $raw, $m)) { $valore = $this->toFloat($m[1]); $unita = 'MC'; } elseif (preg_match('/\bkWh\s*([0-9]+(?:[\.,][0-9]+)?)\b/i', $raw, $m)) { $valore = $this->toFloat($m[1]); $unita = 'kWh'; } elseif (preg_match('/\bSmc\s*([0-9]+(?:[\.,][0-9]+)?)\b/i', $raw, $m)) { $valore = $this->toFloat($m[1]); $unita = 'Smc'; } return [ 'valore' => $valore, 'unita' => $unita, 'riferimento' => $riferimento, 'raw' => $raw, ]; } private function firstString(DOMXPath $xpath, string $expr): ?string { $nodes = $xpath->query($expr); if (! $nodes || $nodes->length < 1) { return null; } $value = trim((string) $nodes->item(0)?->textContent); return $value !== '' ? $value : null; } /** * @return array{0: float|null, 1: float|null} */ private function sumRiepilogo(DOMXPath $xpath): array { $nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DatiRiepilogo']"); if (! $nodes || $nodes->length < 1) { return [null, null]; } $imponibile = 0.0; $iva = 0.0; $hasAny = false; foreach ($nodes as $node) { $childXpath = new DOMXPath($node->ownerDocument); $imp = $childXpath->query(".//*[local-name()='ImponibileImporto']", $node); $tax = $childXpath->query(".//*[local-name()='Imposta']", $node); $impVal = $imp && $imp->length ? $this->toFloat($imp->item(0)?->textContent) : null; $taxVal = $tax && $tax->length ? $this->toFloat($tax->item(0)?->textContent) : null; if ($impVal !== null) { $imponibile += $impVal; $hasAny = true; } if ($taxVal !== null) { $iva += $taxVal; $hasAny = true; } } if (! $hasAny) { return [null, null]; } return [round($imponibile, 2), round($iva, 2)]; } private function toDate(?string $value): ?Carbon { $value = $value ? trim($value) : ''; if ($value === '') { return null; } try { return Carbon::parse($value); } catch (\Throwable) { return null; } } private function toFloat(?string $value): ?float { $value = $value ? trim($value) : ''; if ($value === '') { return null; } $value = str_replace(['.', ','], ['.', '.'], $value); if (! is_numeric($value)) { return null; } return (float) $value; } private function normalizeId(?string $value): ?string { $value = $value ? trim($value) : ''; if ($value === '') { return null; } $value = strtoupper(preg_replace('/\s+/', '', $value)); return $value !== '' ? $value : null; } private function normalizeIban(?string $value): ?string { $value = $this->normalizeId($value); if (! $value) { return null; } // IBAN tipico: 15-34 caratteri alfanumerici if (! preg_match('/^[A-Z0-9]{15,34}$/', $value)) { return null; } return $value; } }