} */ public function parse(string $content): array { $content = str_replace(["\r\n", "\r"], "\n", $content); $lines = array_values(array_filter(array_map('trim', explode("\n", $content)), fn($l) => $l !== '')); // Alcuni export arrivano con BOM UTF-8 sulla prima riga (es. "\xEF\xBB\xBFData;...") if (! empty($lines)) { $lines[0] = preg_replace('/^\xEF\xBB\xBF/', '', $lines[0]) ?? $lines[0]; } $iban = null; foreach ($lines as $line) { if (preg_match('/Conto\s+Corrente\s+(IT\s*[0-9A-Z ]{20,})/i', $line, $m)) { $iban = strtoupper(preg_replace('/\s+/', '', $m[1])); break; } } $headerIndex = null; foreach ($lines as $i => $line) { $lineNoBom = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line; if (str_starts_with($lineNoBom, 'Data;Valuta;Descrizione;Euro;')) { $headerIndex = $i; break; } } if ($headerIndex === null) { throw new \RuntimeException('Formato non riconosciuto: header CSV non trovato'); } $rows = []; for ($i = $headerIndex + 1; $i < count($lines); $i++) { $line = $lines[$i]; $parsed = $this->parseDataLine($line); if ($parsed === null) { continue; } $data = $this->parseDate($parsed['data'] ?? null); if (! $data) { continue; } $valuta = $this->parseDate($parsed['valuta'] ?? null); $descrizione = trim((string) ($parsed['descrizione'] ?? '')); $importo = $this->parseMoney($parsed['importo'] ?? null); $causale = isset($parsed['causale']) ? trim((string) $parsed['causale']) : null; if ($descrizione === '' || $importo === null) { continue; } $rows[] = [ 'data' => $data, 'valuta' => $valuta, 'descrizione' => $descrizione, 'importo' => $importo, 'causale' => $causale !== '' ? $causale : null, 'raw_line' => $line, ]; } return ['iban' => $iban, 'rows' => $rows]; } private function parseDate(?string $value): ?Carbon { $value = trim((string) $value); if ($value === '') { return null; } try { return Carbon::createFromFormat('d/m/Y', $value)->startOfDay(); } catch (\Throwable) { return null; } } private function parseMoney(?string $value): ?float { $value = trim((string) $value); if ($value === '') { return null; } // Normalizza: rimuove simboli valuta, NBSP, spazi, prefissi (es. "EUR") $value = str_replace(["\u{00A0}", "\t", ' '], '', $value); $value = str_ireplace(['EUR', 'EURO', '€'], '', $value); // Gestione negativo tra parentesi: (1.234,56) $isParenNegative = str_starts_with($value, '(') && str_ends_with($value, ')'); if ($isParenNegative) { $value = substr($value, 1, -1); } // Tiene solo caratteri numerici e separatori comuni $value = preg_replace('/[^0-9,\.\-\+]/', '', $value) ?? $value; // Es: -1.326,52 oppure 1.714,79 $value = str_replace('.', '', $value); $value = str_replace(',', '.', $value); if ($isParenNegative && ! str_starts_with($value, '-')) { $value = '-' . $value; } if (! is_numeric($value)) { return null; } return (float) $value; } /** * Il CSV Unicredit WRI è delimitato da ';' ma spesso la descrizione contiene ';' NON quotati. * In quel caso, lo split "naïve" fa slittare l'importo in colonne sbagliate. * * Strategia: * - parse con str_getcsv (così se ci sono virgolette le rispettiamo) * - trova importo e (opzionale) causale partendo da destra * - ricompone la descrizione unendo i token centrali con ';' * * @return array{data:string, valuta:string, descrizione:string, importo:string, causale:string|null}|null */ private function parseDataLine(string $line): ?array { $parts = str_getcsv($line, ';'); $parts = array_map(static fn($v) => trim((string) $v), $parts); if (count($parts) < 4) { return null; } // Rimuove eventuali token vuoti finali while (count($parts) > 0 && end($parts) === '') { array_pop($parts); } if (count($parts) < 4) { return null; } $data = $parts[0] ?? ''; $valuta = $parts[1] ?? ''; // Prova standard: ultimo = causale (3 cifre), penultimo = importo $last = $parts[count($parts) - 1] ?? ''; $prev = $parts[count($parts) - 2] ?? ''; $moneyIndex = null; $causale = null; if (preg_match('/^\d{3}$/', $last) && $this->parseMoney($prev) !== null) { $causale = $last; $moneyIndex = count($parts) - 2; } elseif ($this->parseMoney($last) !== null) { // Formato senza causale $moneyIndex = count($parts) - 1; } else { // Fallback: cerca da destra il primo token che sembra un importo for ($idx = count($parts) - 1; $idx >= 2; $idx--) { if ($this->parseMoney($parts[$idx]) !== null) { $moneyIndex = $idx; $maybeCausale = $parts[$idx + 1] ?? null; if (is_string($maybeCausale) && preg_match('/^\d{3}$/', $maybeCausale)) { $causale = $maybeCausale; } break; } } } if ($moneyIndex === null || $moneyIndex < 3) { return null; } $importo = $parts[$moneyIndex] ?? ''; $descrParts = array_slice($parts, 2, $moneyIndex - 2); $descrizione = trim(implode(';', $descrParts)); if ($descrizione === '' || $importo === '') { return null; } return [ 'data' => $data, 'valuta' => $valuta, 'descrizione' => $descrizione, 'importo' => $importo, 'causale' => $causale, ]; } }