294 lines
11 KiB
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;
|
|
}
|
|
}
|
|
}
|