netgescon-day0/app/Services/FatturaParserService.php

294 lines
11 KiB
PHP

<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Servizio deterministico di parsing PDF per fatture utenze (Luce, Gas, Acqua, Ascensori, ecc.) via pdftotext.
* Include l'auto-classificazione basata su logica ARERA/anagrafica operatori locale.
*/
class FatturaParserService
{
/**
* Dizionario potenziato per fornitori nazionali/ARERA
*/
protected array $fornitoriDizionario = [
'enel' => ['categoria' => 'energia_elettrica', 'conto_mastro' => 'SPESE_ENERGIA', 'nome' => 'Enel Energia'],
'servizio elettrico' => ['categoria' => 'energia_elettrica', 'conto_mastro' => 'SPESE_ENERGIA', 'nome' => 'Servizio Elettrico Nazionale'],
'plenitude' => ['categoria' => 'riscaldamento', 'conto_mastro' => 'SPESE_RISCALDAMENTO', 'nome' => 'Eni Plenitude'],
'eni' => ['categoria' => 'riscaldamento', 'conto_mastro' => 'SPESE_RISCALDAMENTO', 'nome' => 'Eni'],
'italgas' => ['categoria' => 'riscaldamento', 'conto_mastro' => 'SPESE_RISCALDAMENTO', 'nome' => 'Italgas'],
'publiacqua' => ['categoria' => 'acqua', 'conto_mastro' => 'SPESE_ACQUA', 'nome' => 'Publiacqua'],
'acea' => ['categoria' => 'acqua', 'conto_mastro' => 'SPESE_ACQUA', 'nome' => 'Acea'],
'sorgenia' => ['categoria' => 'energia_elettrica', 'conto_mastro' => 'SPESE_ENERGIA', 'nome' => 'Sorgenia'],
'otis' => ['categoria' => 'ascensori', 'conto_mastro' => 'SPESE_ASCENSORI', 'nome' => 'Otis Servizi'],
'schindler' => ['categoria' => 'ascensori', 'conto_mastro' => 'SPESE_ASCENSORI', 'nome' => 'Schindler'],
];
/**
* Esegue il parsing del file PDF specificato
*
* @param string $filePath
* @return array
*/
public function parsePdf(string $filePath): array
{
if (!file_exists($filePath)) {
Log::warning("[FatturaParser] File non trovato: $filePath");
return [];
}
$output = [];
$exitCode = 0;
$cmd = "/usr/bin/pdftotext -layout " . escapeshellarg($filePath) . " -";
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
Log::error("[FatturaParser] Errore nell'esecuzione di pdftotext (Codice: $exitCode) per: $filePath");
return [];
}
$text = implode("\n", $output);
return $this->parseText($text);
}
/**
* Riconoscimento del fornitore a partire dal testo (P.IVA, CF, o Denominazione)
*/
public function detectFornitore(string $text): array
{
$textLower = strtolower($text);
foreach ($this->fornitoriDizionario as $key => $info) {
if (strpos($textLower, $key) !== false) {
return $info;
}
}
if (preg_match('/\bpartita\s*iva\b\s*[:\-]?\s*([0-9]{11})/i', $text, $m)) {
return [
'categoria' => 'altro',
'conto_mastro' => '3010',
'nome' => 'Fornitore P.IVA ' . $m[1]
];
}
return [
'categoria' => 'altro',
'conto_mastro' => '3010',
'nome' => 'Fornitore Sconosciuto'
];
}
/**
* Estrazione del codice CBILL standard di 18 caratteri
* e codice fornitore a 5 caratteri.
*/
public function extractCbill18(string $text): array
{
$cbill = null;
$codFornitore = null;
if (preg_match('/(?:cbill|avviso|codice\s*avviso|cod\.\s*avviso)\b\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([0-9\s]{18,24})/i', $text, $m)) {
$cleaned = preg_replace('/\s+/', '', $m[1]);
if (strlen($cleaned) === 18) {
$cbill = $cleaned;
}
}
if (!$cbill && preg_match('/\b([0-9]{18})\b/', $text, $m)) {
$cbill = $m[1];
}
if (!$cbill && preg_match('/\b([0-9\s]{18,24})\b/', $text, $m)) {
$cleaned = preg_replace('/\s+/', '', $m[1]);
if (strlen($cleaned) === 18) {
$cbill = $cleaned;
}
}
if (preg_match('/(?:codice\s*ente|sia|cod\.\s*cbill)\b\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([A-Z0-9]{5})\b/i', $text, $m)) {
$codFornitore = strtoupper($m[1]);
}
if (!$codFornitore && preg_match('/\b([A-Z0-9]{5})\b/', $text, $m)) {
$val = $m[1];
if (!in_array($val, ['CBILL', 'PAGOP', 'POSTE'], true) && !is_numeric($val)) {
$codFornitore = $val;
}
}
return [
'cbill' => $cbill,
'codice_fornitore' => $codFornitore,
];
}
/**
* Parsing del testo estratto dal PDF
*
* @param string $text
* @return array
*/
public function parseText(string $text): array
{
$textNorm = str_replace(["\r\n", "\r"], "\n", $text);
$fornitoreInfo = $this->detectFornitore($textNorm);
$cbillData = $this->extractCbill18($textNorm);
$data = [
'fornitore_nome' => $fornitoreInfo['nome'],
'partita_iva' => null,
'codice_cliente' => null,
'pod' => null,
'pdr' => null,
'matricola_contatore' => null,
'codice_utenza' => null,
'numero_contratto' => null,
'cbill' => $cbillData['cbill'],
'codice_avviso' => $cbillData['cbill'],
'codice_fornitore_ente' => $cbillData['codice_fornitore'],
'data_inizio_periodo' => null,
'data_fine_periodo' => null,
'periodi_conteggiati' => null,
'tipo_lettura' => 'effettiva',
'quantita_consumata' => null,
'unita_misura' => null,
'importo_monetario' => null,
'conto_mastro' => $fornitoreInfo['conto_mastro'],
'categoria' => $fornitoreInfo['categoria'],
];
// Partita IVA
if (preg_match('/\bpartita\s*iva\b\s*[:\-]?\s*([0-9]{11})/i', $textNorm, $m)) {
$data['partita_iva'] = $m[1];
}
// Codice Cliente
if (preg_match('/(?:codice|cod\.|n\.|n°)\s*cliente\b\s*(?:[a-z\(\)]*\s*)*[:\-]\s*([A-Z0-9\-]{5,})/is', $textNorm, $m)) {
$data['codice_cliente'] = trim($m[1]);
}
// Codice Utenza
if (preg_match('/(?:codice|cod\.)\s*utenza\b\s*(?:[a-z\(\)]*\s*)*[:\-]\s*([A-Z0-9\-]{5,})/is', $textNorm, $m)) {
$data['codice_utenza'] = trim($m[1]);
}
// Numero Contratto
if (preg_match('/(?:codice|cod\.|numero|num\.|n\.|n°)\s*contratto\b\s*(?:[a-z\(\)]*\s*)*[:\-]\s*([A-Z0-9\-]{5,})/is', $textNorm, $m)) {
$data['numero_contratto'] = trim($m[1]);
}
// POD (Luce)
if (preg_match('/([A-Z]{2}[0-9]{3}[A-Z][0-9]{8})/i', $textNorm, $m)) {
$data['pod'] = strtoupper(trim($m[1]));
$data['categoria'] = 'energia_elettrica';
$data['conto_mastro'] = 'SPESE_ENERGIA';
}
// PDR (Gas)
if (preg_match('/(?:pdr|punto\s+di\s+riconsegna)\b\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([0-9]{14})/is', $textNorm, $m)) {
$data['pdr'] = trim($m[1]);
if ($data['categoria'] !== 'riscaldamento') {
$data['categoria'] = 'gas';
}
$data['conto_mastro'] = 'SPESE_RISCALDAMENTO';
}
// Matricola Contatore
if (preg_match('/(?:matricola\s+contatore|matricola|seriale|n°?\s*contatore|contatore\s*n°?|contatore)\b\s*(?:[a-z\(\)]*\s*)*[:\-]\s*([A-Z0-9\-]{4,})/is', $textNorm, $m)) {
$data['matricola_contatore'] = trim($m[1]);
}
// Helper per parsing float in formato europeo (1.234,56 -> 1234.56)
$parseFloat = function ($str) {
$str = trim($str);
if (strpos($str, ',') !== false && strpos($str, '.') !== false) {
if (strpos($str, '.') < strpos($str, ',')) {
$str = str_replace('.', '', $str);
$str = str_replace(',', '.', $str);
} else {
$str = str_replace(',', '', $str);
}
} elseif (strpos($str, ',') !== false) {
$str = str_replace(',', '.', $str);
}
return (float) $str;
};
// Date Periodo
if (preg_match('/dal\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/iu', $textNorm, $m)) {
$data['data_inizio_periodo'] = $this->parseIsoDate($m[1]);
$data['data_fine_periodo'] = $this->parseIsoDate($m[2]);
}
// Tipo Lettura
$textLower = strtolower($textNorm);
if (strpos($textLower, 'stima') !== false || strpos($textLower, 'stim') !== false) {
$data['tipo_lettura'] = 'stimata';
}
// 5. Lettura Precedente
if (preg_match('/(?:lettura\s*(?:precedente|prec\.?)|lett\.?\s*prec\.?|prec\.?)\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([0-9]+(?:[\.,][0-9]+)*)/is', $textNorm, $m)) {
$data['lettura_precedente'] = $parseFloat($m[1]);
}
// 6. Lettura Attuale
if (preg_match('/(?:lettura\s*(?:attuale|att\.?)|lett\.?\s*att\.?|att\.?)\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([0-9]+(?:[\.,][0-9]+)*)/is', $textNorm, $m)) {
$data['lettura_attuale'] = $parseFloat($m[1]);
}
// 7. Consumo
if (preg_match('/(?:consumo|volume|m3|kwh|cons\.\s*rilevato|cons\.\s*periodo)\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([0-9]+(?:[\.,][0-9]+)*)/is', $textNorm, $m)) {
$data['quantita_consumata'] = $parseFloat($m[1]);
}
if (preg_match('/(mc|kwh)/i', $textNorm, $m)) {
$data['unita_misura'] = strtolower($m[1]);
}
// Importo monetario
if (preg_match('/(?:totale\s*da\s*pagare|totale\s*bolletta|totale\s*fattura|importo\s*da\s*pagare|da\s*pagare|totale\s*dovuto)\s*(?:[a-z\(\)]*\s*)*[:\-]?\s*([0-9]+(?:[\.,][0-9]{2}))/is', $textNorm, $m)) {
$data['importo_monetario'] = $parseFloat($m[1]);
}
return $data;
}
/**
* Normalizzazione dei float
*/
protected function parseFloatIt(string $value): ?float
{
$s = trim($value);
if ($s === '') {
return null;
}
$s = str_replace(' ', '', $s);
if (strpos($s, ',') !== false && strpos($s, '.') !== false) {
if (strpos($s, '.') < strpos($s, ',')) {
$s = str_replace('.', '', $s);
$s = str_replace(',', '.', $s);
} else {
$s = str_replace(',', '', $s);
}
} elseif (strpos($s, ',') !== false) {
$s = str_replace(',', '.', $s);
}
return (float) $s;
}
/**
* Normalizzazione date
*/
protected function parseIsoDate(string $dmy): ?string
{
try {
return Carbon::createFromFormat('d/m/Y', trim($dmy))->format('Y-m-d');
} catch (\Throwable) {
return null;
}
}
}