, * 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, * iva: array{codice: string|null, aliquota_percentuale: float|null, descrizione: string|null}, * consumi: array * } */ 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 */ 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 */ 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; } }