435 lines
19 KiB
PHP
435 lines
19 KiB
PHP
<?php
|
||
namespace App\Services\Consumi;
|
||
|
||
use Carbon\Carbon;
|
||
|
||
class AcquaPdfTextParser
|
||
{
|
||
/**
|
||
* @return array{
|
||
* codici: array{utenza: string|null, cliente: string|null, contratto: string|null},
|
||
* contatore: array{matricola: string|null},
|
||
* generale: array{
|
||
* tipologia_utenza: string|null,
|
||
* tariffa_applicata: string|null,
|
||
* numero_componenti_totali: int|null,
|
||
* deposito_cauzionale_euro: float|null,
|
||
* numero_unita_abitative: int|null,
|
||
* periodicita_fatturazione: string|null,
|
||
* numero_fattura_pdf: string|null,
|
||
* data_emissione_pdf: string|null
|
||
* },
|
||
* finestra_autolettura: array{dal: string|null, al: string|null, alert: bool, messaggio: string|null},
|
||
* riepilogo_letture: array<int, array{tipologia: string|null, valore_da: float|null, data_da: string|null, valore_a: float|null, data_a: string|null, consumo: float|null, unita: string}>,
|
||
* quadro_dettaglio: array{quota_fissa: float|null, acquedotto: float|null, fognatura: float|null, depurazione: float|null, oneri_perequazione: float|null, restituzione_acconti: float|null},
|
||
* tariffe: array<string,mixed>,
|
||
* iva: array{codice: string|null, aliquota_percentuale: float|null, descrizione: string|null},
|
||
* consumi: array<int, array{dal: string|null, al: string|null, valore: float|null, unita: string, tipologia_lettura: string|null}>
|
||
* }
|
||
*/
|
||
public function parse(string $text): array
|
||
{
|
||
$text = str_replace("\r\n", "\n", $text);
|
||
$text = str_replace("\r", "\n", $text);
|
||
|
||
$utenza = $this->firstMatch($text, '/\bCODICE\s+UTENZA\b\s*:?\s*([0-9]{6,})/i');
|
||
$cliente = $this->firstMatch($text, '/\bCODICE\s+CLIENTE\b\s*:?\s*([0-9]{6,})/i');
|
||
$contratto = $this->firstMatch($text, '/\bCODICE\s+CONTRATTO\b\s*:?\s*([0-9]{6,})/i');
|
||
|
||
$matricola = $this->firstMatch($text, '/\bMatricola\s+Contatore\b\s*:?\s*([A-Z0-9\-\/\.]{4,})/i');
|
||
|
||
$tipologiaUtenza = $this->firstMatch($text, '/\bTipologia\s+utenza\b\s*:?\s*([^\n]+)/iu');
|
||
$tariffaApplicata = $this->firstMatch($text, '/\bTariffa\s+applicata\b\s*:?\s*([^\n]+)/iu');
|
||
$numeroComponentiTotali = $this->toInt($this->firstMatch($text, '/\bNumero\s+componenti\s+totali\b\s*:?\s*([0-9]{1,4})/iu'));
|
||
$numeroUnitaAbitative = $this->toInt($this->firstMatch($text, '/\bNumero\s+unit[aà]\s+abitative\b\s*:?\s*([0-9]{1,4})/iu'));
|
||
$depositoCauzionale = $this->extractEuroAfterLabel($text, 'Deposito\\s+cauzionale');
|
||
$periodicitaFatturazione = $this->firstMatch($text, '/\bPeriodicit[aà]\s+fatturazione\b\s*:?\s*([^\n]+)/iu');
|
||
|
||
$numeroFatturaPdf = $this->firstMatch($text, '/\bNumero\s+Fattura\b\s*:?\s*([A-Z0-9\-\/]{6,})/iu');
|
||
$dataEmissionePdf = $this->toIsoDate($this->firstMatch($text, '/\bEmessa\s+il\b\s*:?\s*(\d{2}\/\d{2}\/\d{4})/iu'));
|
||
|
||
$finestraDal = null;
|
||
$finestraAl = null;
|
||
$finestraMsg = null;
|
||
if (preg_match('/Dal[\s\S]{0,200}?giorno\s+(\d{2}\/\d{2}\/\d{4})[\s\S]{0,80}?al\s+giorno\s+(\d{2}\/\d{2}\/\d{4})[\s\S]{0,140}?comunicare\s+la\s+lettura\s+del\s+contatore\.?/iu', $text, $mFinestra)) {
|
||
$finestraDal = $this->toIsoDate($mFinestra[1] ?? null);
|
||
$finestraAl = $this->toIsoDate($mFinestra[2] ?? null);
|
||
$finestraMsg = trim((string) ($mFinestra[0] ?? ''));
|
||
}
|
||
|
||
$riepilogoLetture = $this->extractRiepilogoLetture($text);
|
||
|
||
$quadroDettaglio = [
|
||
'quota_fissa' => $this->extractEuroAfterLabel($text, 'QUOTA\\s+FISSA'),
|
||
'acquedotto' => $this->extractEuroAfterLabel($text, 'ACQUEDOTTO'),
|
||
'fognatura' => $this->extractEuroAfterLabel($text, 'FOGNATURA'),
|
||
'depurazione' => $this->extractEuroAfterLabel($text, 'DEPURAZIONE'),
|
||
'oneri_perequazione' => $this->extractEuroAfterLabel($text, 'ONERI\\s+PEREQUAZIONE'),
|
||
'restituzione_acconti' => $this->extractEuroAfterLabel($text, 'RESTITUZIONE\\s+ACCONTI'),
|
||
];
|
||
if ($quadroDettaglio['restituzione_acconti'] === null) {
|
||
$quadroDettaglio['restituzione_acconti'] = $this->extractRestituzioneAccontiFromRows($text);
|
||
}
|
||
|
||
$tariffe = $this->extractTariffe($text);
|
||
$iva = $this->extractIvaInfo($text);
|
||
|
||
$consumi = [];
|
||
|
||
// Pattern tipico Acea: "... dal 19/07/2024 al 19/09/2024 ... 439 mc"
|
||
if (preg_match_all('/\bdal\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})(.{0,140}?)(\d{1,3}(?:[\.,]\d{3})*(?:[\.,]\d+)?)\s*mc\b/iu', $text, $m, PREG_SET_ORDER)) {
|
||
foreach ($m as $row) {
|
||
$dal = $this->toIsoDate($row[1] ?? null);
|
||
$al = $this->toIsoDate($row[2] ?? null);
|
||
$between = is_string($row[3] ?? null) ? (string) $row[3] : '';
|
||
$valore = $this->toFloatIt($row[4] ?? null);
|
||
|
||
$tipologia = $this->detectTipologiaLettura($between);
|
||
|
||
$consumi[] = [
|
||
'dal' => $dal,
|
||
'al' => $al,
|
||
'valore' => $valore,
|
||
'unita' => 'mc',
|
||
'tipologia_lettura' => $tipologia,
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'codici' => [
|
||
'utenza' => $utenza,
|
||
'cliente' => $cliente,
|
||
'contratto' => $contratto,
|
||
],
|
||
'contatore' => [
|
||
'matricola' => $matricola,
|
||
],
|
||
'generale' => [
|
||
'tipologia_utenza' => $this->normalizeInlineText($tipologiaUtenza),
|
||
'tariffa_applicata' => $this->normalizeInlineText($tariffaApplicata),
|
||
'numero_componenti_totali' => $numeroComponentiTotali,
|
||
'deposito_cauzionale_euro' => $depositoCauzionale,
|
||
'numero_unita_abitative' => $numeroUnitaAbitative,
|
||
'periodicita_fatturazione' => $this->normalizeInlineText($periodicitaFatturazione),
|
||
'numero_fattura_pdf' => $numeroFatturaPdf,
|
||
'data_emissione_pdf' => $dataEmissionePdf,
|
||
],
|
||
'finestra_autolettura' => [
|
||
'dal' => $finestraDal,
|
||
'al' => $finestraAl,
|
||
'alert' => $finestraDal !== null && $finestraAl !== null,
|
||
'messaggio' => $finestraMsg,
|
||
],
|
||
'riepilogo_letture' => $riepilogoLetture,
|
||
'quadro_dettaglio' => $quadroDettaglio,
|
||
'tariffe' => $tariffe,
|
||
'iva' => $iva,
|
||
'consumi' => $consumi,
|
||
];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function extractTariffe(string $text): array
|
||
{
|
||
$section = $this->extractSection($text, '/Informazioni\s+sulla\s+tariffa/iu', 4200);
|
||
$searchText = $section !== null ? $section : $text;
|
||
|
||
$decorrenza = $this->toIsoDate($this->firstMatch($searchText, '/tariffe\s+vigenti\s+con\s+decorrenza\s+(\d{2}\/\d{2}\/\d{4})/iu'));
|
||
if ($decorrenza === null) {
|
||
$decorrenza = $this->toIsoDate($this->firstMatch($text, '/tariffe\s+vigenti\s+con\s+decorrenza\s+(\d{2}\/\d{2}\/\d{4})/iu'));
|
||
}
|
||
|
||
$deliberaAto = $this->firstMatch($searchText, '/delibera\s+([0-9\-]+)\s+del\s+\d{2}\/\d{2}\/\d{4}/iu');
|
||
if ($deliberaAto === null) {
|
||
$deliberaAto = $this->firstMatch($text, '/delibera\s+([0-9\-]+)\s+del\s+\d{2}\/\d{2}\/\d{4}/iu');
|
||
}
|
||
|
||
$deliberaArera = $this->firstMatch($searchText, '/Delibera\s+ARERA\s+([0-9]{1,4}\/[0-9]{4}\/R\/[A-Z]+)/iu');
|
||
if ($deliberaArera === null) {
|
||
$deliberaArera = $this->firstMatch($text, '/Delibera\s+ARERA\s+([0-9]{1,4}\/[0-9]{4}\/R\/[A-Z]+)/iu');
|
||
}
|
||
|
||
$profilo = $this->firstMatch($searchText, '/Tariffa\s+Condominiale\s+Domestico/iu');
|
||
|
||
$scaglioni = [];
|
||
$scaglioniPattern = '/(Tariffa\s+agevolata|Tariffa\s+base|I\s+eccedenza|II\s+eccedenza|III\s+eccedenza)\s+([0-9N\*\s\-oltre]+?)\s+([0-9]+,[0-9]{4})/iu';
|
||
if (preg_match_all($scaglioniPattern, $searchText, $rows, PREG_SET_ORDER)) {
|
||
foreach ($rows as $row) {
|
||
$scaglioni[] = [
|
||
'fascia' => $this->normalizeInlineText($row[1] ?? null),
|
||
'classe_consumo_mc' => $this->normalizeInlineText($row[2] ?? null),
|
||
'euro_mc' => $this->toFloatIt($row[3] ?? null),
|
||
];
|
||
}
|
||
}
|
||
|
||
$componentiUi = [
|
||
'ui1_euro_mc' => $this->extractEuroPerMc($text, 'UI1'),
|
||
'ui2_euro_mc' => $this->extractEuroPerMc($text, 'UI2'),
|
||
'ui3_euro_mc' => $this->extractEuroPerMc($text, 'UI3'),
|
||
'ui4_euro_mc' => $this->extractEuroPerMc($text, 'UI4'),
|
||
'recupero_efficienza_euro_mc' => $this->extractEuroPerMc($text, 'quota\\s+da\\s+recupero\\s+efficienza'),
|
||
];
|
||
if ($componentiUi['ui3_euro_mc'] === null) {
|
||
$componentiUi['ui3_euro_mc'] = $this->extractUiFromCentesimi($text, 'UI3');
|
||
}
|
||
if ($componentiUi['ui4_euro_mc'] === null) {
|
||
$componentiUi['ui4_euro_mc'] = $this->extractUiFromCentesimi($text, 'UI4');
|
||
}
|
||
|
||
return [
|
||
'decorrenza' => $decorrenza,
|
||
'delibera_ato' => $deliberaAto,
|
||
'delibera_arera' => $deliberaArera,
|
||
'profilo' => $profilo ? 'Tariffa Condominiale Domestico' : (count($scaglioni) > 0 ? 'Tariffa Condominiale Domestico' : null),
|
||
'quota_variabile_acquedotto_scaglioni' => $scaglioni,
|
||
'quota_variabile_fognatura_euro_mc' => $this->extractDecimalAfterLabel($searchText, 'Tariffa\\s+fognatura'),
|
||
'quota_variabile_depurazione_euro_mc' => $this->extractDecimalAfterLabel($searchText, 'Tariffa\\s+depurazione'),
|
||
'quota_fissa_annua' => [
|
||
'acquedotto_euro_anno' => $this->extractDecimalAfterLabel($searchText, 'Quota\\s+fissa\\s+acquedotto'),
|
||
'fognatura_euro_anno' => $this->extractDecimalAfterLabel($searchText, 'Quota\\s+fissa\\s+fognatura'),
|
||
'depurazione_euro_anno' => $this->extractDecimalAfterLabel($searchText, 'Quota\\s+fissa\\s+depurazione'),
|
||
],
|
||
'componenti_ui' => $componentiUi,
|
||
];
|
||
}
|
||
|
||
/** @return array{codice: string|null, aliquota_percentuale: float|null, descrizione: string|null} */
|
||
private function extractIvaInfo(string $text): array
|
||
{
|
||
$codice = null;
|
||
$aliquota = null;
|
||
$descrizione = null;
|
||
|
||
if (preg_match('/\b(\d{1,2}[A-Z])\b\s+Soggetti\s+IVA\s+([0-9]{1,2})%\s+aliquota\s+([0-9]{1,2})/iu', $text, $m)) {
|
||
$codice = strtoupper(trim((string) ($m[1] ?? '')));
|
||
$aliquota = isset($m[3]) ? (float) $m[3] : (isset($m[2]) ? (float) $m[2] : null);
|
||
$descrizione = 'Soggetti IVA ' . (string) ($m[2] ?? $m[3] ?? '') . '% aliquota ' . (string) ($m[3] ?? $m[2] ?? '');
|
||
} elseif (preg_match('/\b(\d{1,2}[A-Z])\b\s+Soggetti\s+IVA\s+([0-9]{1,2})%/iu', $text, $m)) {
|
||
$codice = strtoupper(trim((string) ($m[1] ?? '')));
|
||
$aliquota = isset($m[2]) ? (float) $m[2] : null;
|
||
$descrizione = 'Soggetti IVA ' . (string) ($m[2] ?? '') . '%';
|
||
}
|
||
|
||
return [
|
||
'codice' => $codice,
|
||
'aliquota_percentuale' => $aliquota,
|
||
'descrizione' => $descrizione,
|
||
];
|
||
}
|
||
|
||
/** @return array<int, array{tipologia: string|null, valore_da: float|null, data_da: string|null, valore_a: float|null, data_a: string|null, consumo: float|null, unita: string}> */
|
||
private function extractRiepilogoLetture(string $text): array
|
||
{
|
||
$rows = [];
|
||
|
||
$pattern = '/(Autolettura\s+da\s+Utente|Lettura\s+effettiva|Lettura\s+stimata|Stima)\s+([0-9]{1,8}(?:[\.,][0-9]+)?)\s+(\d{2}\/\d{2}\/\d{4})\s+([0-9]{1,8}(?:[\.,][0-9]+)?)\s+(\d{2}\/\d{2}\/\d{4})\s+([0-9]{1,8}(?:[\.,][0-9]+)?)/iu';
|
||
if (! preg_match_all($pattern, $text, $matches, PREG_SET_ORDER)) {
|
||
return $rows;
|
||
}
|
||
|
||
foreach ($matches as $m) {
|
||
$rows[] = [
|
||
'tipologia' => $this->normalizeInlineText($m[1] ?? null),
|
||
'valore_da' => $this->toFloatIt($m[2] ?? null),
|
||
'data_da' => $this->toIsoDate($m[3] ?? null),
|
||
'valore_a' => $this->toFloatIt($m[4] ?? null),
|
||
'data_a' => $this->toIsoDate($m[5] ?? null),
|
||
'consumo' => $this->toFloatIt($m[6] ?? null),
|
||
'unita' => 'mc',
|
||
];
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
private function firstMatch(string $text, string $pattern): ?string
|
||
{
|
||
if (! preg_match($pattern, $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$v = isset($m[1]) ? trim((string) $m[1]) : '';
|
||
return $v !== '' ? $v : null;
|
||
}
|
||
|
||
private function toIsoDate(?string $dmy): ?string
|
||
{
|
||
$dmy = is_string($dmy) ? trim($dmy) : '';
|
||
if ($dmy === '') {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return Carbon::createFromFormat('d/m/Y', $dmy)->format('Y-m-d');
|
||
} catch (\Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function toFloatIt($value): ?float
|
||
{
|
||
if (! is_string($value) && ! is_numeric($value)) {
|
||
return null;
|
||
}
|
||
|
||
$s = trim((string) $value);
|
||
if ($s === '') {
|
||
return null;
|
||
}
|
||
|
||
// Normalizza formati IT: 2.543 / 2,543 / 2543,10
|
||
$s = str_replace(' ', '', $s);
|
||
|
||
// Se contiene sia . che , assumiamo . = migliaia e , = decimali.
|
||
if (str_contains($s, '.') && str_contains($s, ',')) {
|
||
$s = str_replace('.', '', $s);
|
||
$s = str_replace(',', '.', $s);
|
||
} elseif (str_contains($s, ',')) {
|
||
$s = str_replace('.', '', $s);
|
||
$s = str_replace(',', '.', $s);
|
||
}
|
||
|
||
if (! is_numeric($s)) {
|
||
return null;
|
||
}
|
||
|
||
return (float) $s;
|
||
}
|
||
|
||
private function toInt(?string $value): ?int
|
||
{
|
||
$v = is_string($value) ? trim($value) : '';
|
||
if ($v === '' || ! ctype_digit($v)) {
|
||
return null;
|
||
}
|
||
|
||
return (int) $v;
|
||
}
|
||
|
||
private function extractEuroAfterLabel(string $text, string $labelPattern): ?float
|
||
{
|
||
$pattern = '/\b' . $labelPattern . '\b[\s\S]{0,120}?([\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{2}|[\-−]?[0-9]+,[0-9]{2})(?:\s*€)?/iu';
|
||
if (! preg_match($pattern, $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$raw = is_string($m[1] ?? null) ? str_replace('−', '-', (string) $m[1]) : ($m[1] ?? null);
|
||
return $this->toFloatIt($raw);
|
||
}
|
||
|
||
private function extractEuroPerMc(string $text, string $labelPattern): ?float
|
||
{
|
||
$pattern = '/\b' . $labelPattern . '\b[\s\S]{0,220}?([\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{4}|[\-−]?[0-9]+,[0-9]{4}|[\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{3}|[\-−]?[0-9]+,[0-9]{3})\s*\/?\s*mc/iu';
|
||
if (! preg_match($pattern, $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$raw = is_string($m[1] ?? null) ? str_replace('−', '-', (string) $m[1]) : ($m[1] ?? null);
|
||
return $this->toFloatIt($raw);
|
||
}
|
||
|
||
private function extractEuroPerAnno(string $text, string $labelPattern): ?float
|
||
{
|
||
$pattern = '/\b' . $labelPattern . '\b[\s\S]{0,120}?([\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{4}|[\-−]?[0-9]+,[0-9]{4}|[\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{3}|[\-−]?[0-9]+,[0-9]{3})\s*€\/?\s*anno/iu';
|
||
if (! preg_match($pattern, $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$raw = is_string($m[1] ?? null) ? str_replace('−', '-', (string) $m[1]) : ($m[1] ?? null);
|
||
return $this->toFloatIt($raw);
|
||
}
|
||
|
||
private function extractDecimalAfterLabel(string $text, string $labelPattern): ?float
|
||
{
|
||
$pattern = '/\b' . $labelPattern . '\b[\s\S]{0,180}?([\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{4}|[\-−]?[0-9]+,[0-9]{4}|[\-−]?[0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{3}|[\-−]?[0-9]+,[0-9]{3})/iu';
|
||
if (! preg_match($pattern, $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$raw = is_string($m[1] ?? null) ? str_replace('−', '-', (string) $m[1]) : ($m[1] ?? null);
|
||
return $this->toFloatIt($raw);
|
||
}
|
||
|
||
private function extractSection(string $text, string $startPattern, int $length = 3000): ?string
|
||
{
|
||
if (! preg_match($startPattern, $text, $m, PREG_OFFSET_CAPTURE)) {
|
||
return null;
|
||
}
|
||
|
||
$start = (int) ($m[0][1] ?? 0);
|
||
$slice = mb_substr($text, $start, $length);
|
||
return is_string($slice) && trim($slice) !== '' ? $slice : null;
|
||
}
|
||
|
||
private function extractUiFromCentesimi(string $text, string $uiCode): ?float
|
||
{
|
||
$pattern = '/\b' . preg_quote($uiCode, '/') . '\b[\s\S]{0,260}?([0-9]+,[0-9]+)\s+centesimi/iu';
|
||
if (! preg_match($pattern, $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$cent = $this->toFloatIt($m[1] ?? null);
|
||
if ($cent === null) {
|
||
return null;
|
||
}
|
||
|
||
return round($cent / 100, 4);
|
||
}
|
||
|
||
private function normalizeInlineText(?string $value): ?string
|
||
{
|
||
$v = is_string($value) ? trim($value) : '';
|
||
if ($v === '') {
|
||
return null;
|
||
}
|
||
|
||
$v = preg_replace('/\s+/', ' ', $v) ?? $v;
|
||
return $v !== '' ? $v : null;
|
||
}
|
||
|
||
private function extractRestituzioneAccontiFromRows(string $text): ?float
|
||
{
|
||
if (! preg_match('/RESTITUZIONE\s+ACCONTI([\s\S]{0,2200})/iu', $text, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$chunk = (string) ($m[1] ?? '');
|
||
if ($chunk === '') {
|
||
return null;
|
||
}
|
||
|
||
$sum = 0.0;
|
||
$has = false;
|
||
if (preg_match_all('/Acconti[^\n\r]{0,160}?([\-−][0-9]{1,3}(?:\.[0-9]{3})*,[0-9]{2}|[\-−][0-9]+,[0-9]{2})\s*€/iu', $chunk, $rows, PREG_SET_ORDER)) {
|
||
foreach ($rows as $row) {
|
||
$value = is_string($row[1] ?? null) ? str_replace('−', '-', (string) $row[1]) : null;
|
||
$f = $this->toFloatIt($value);
|
||
if ($f === null) {
|
||
continue;
|
||
}
|
||
$sum += $f;
|
||
$has = true;
|
||
}
|
||
}
|
||
|
||
return $has ? round($sum, 2) : null;
|
||
}
|
||
|
||
private function detectTipologiaLettura(string $context): ?string
|
||
{
|
||
$c = mb_strtolower($context);
|
||
|
||
if (str_contains($c, 'stima') || str_contains($c, 'stim')) {
|
||
return 'stimata';
|
||
}
|
||
|
||
if (str_contains($c, 'effett') || str_contains($c, 'lettura eff')) {
|
||
return 'effettiva';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|