netgescon-day0/app/Services/Consumi/AcquaPdfTextParser.php

435 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}