, meta?: array} */ public function parse(string $content): array { if (! mb_check_encoding($content, 'UTF-8')) { $content = mb_convert_encoding($content, 'UTF-8', 'UTF-8, Windows-1252, ISO-8859-1'); } $content = str_replace(["\r\n", "\r"], "\n", $content); $lines = explode("\n", $content); $lines = array_values(array_filter($lines, static fn($l) => trim((string) $l) !== '')); if (! empty($lines)) { $lines[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $lines[0]) ?? (string) $lines[0]; } $headerIndex = null; $delimiter = ';'; $map = []; foreach ($lines as $i => $line) { $candidate = trim((string) $line); $low = mb_strtolower($candidate); if (! str_contains($low, 'tipo riga') || ! str_contains($low, 'entrate') || ! str_contains($low, 'uscite')) { continue; } $delim = str_contains($candidate, ';') ? ';' : (str_contains($candidate, ',') ? ',' : null); if (! $delim) { continue; } $cols = array_map(fn($v) => $this->normHeader($v), str_getcsv($candidate, $delim)); $idxData = $this->findFirstIndex($cols, ['data']); $idxDescr = $this->findFirstIndex($cols, ['descrizione']); $idxEntrate = $this->findFirstIndex($cols, ['entrate', 'entrata']); $idxUscite = $this->findFirstIndex($cols, ['uscite', 'uscita']); $idxSaldo = $this->findFirstIndex($cols, ['saldo']); $idxNr = $this->findFirstIndex($cols, ['nr', 'numero']); $idxTipoRiga = $this->findFirstIndex($cols, ['tiporiga', 'tipo']); $idxGestione = $this->findFirstIndex($cols, ['gestione']); if ($idxData === null || $idxDescr === null || ($idxEntrate === null && $idxUscite === null)) { continue; } $headerIndex = $i; $delimiter = $delim; $map = [ 'data' => $idxData, 'descrizione' => $idxDescr, 'entrate' => $idxEntrate, 'uscite' => $idxUscite, 'saldo' => $idxSaldo, 'nr' => $idxNr, 'tipo_riga' => $idxTipoRiga, 'gestione' => $idxGestione, ]; break; } if ($headerIndex === null) { throw new \RuntimeException('Formato CEDHOUSE non riconosciuto: header CSV non trovato'); } $expectedCols = count(str_getcsv((string) $lines[$headerIndex], $delimiter)); $rows = []; $buffer = ''; for ($i = $headerIndex + 1; $i < count($lines); $i++) { $line = (string) $lines[$i]; $buffer = $buffer === '' ? $line : ($buffer . "\n" . $line); $parts = str_getcsv($buffer, $delimiter); if (count($parts) < $expectedCols) { // Probabile descrizione multi-linea: accumula. continue; } $bufferTrimmed = trim($buffer); $buffer = ''; $parts = array_map(static fn($v) => trim((string) $v), $parts); $data = $this->parseDate($parts[$map['data']] ?? null); if (! $data) { continue; } $descrizioneEstesa = trim((string) ($parts[$map['descrizione']] ?? '')); if ($descrizioneEstesa === '') { continue; } $descrizione = $descrizioneEstesa; if (str_contains($descrizione, "\n")) { $descrizione = trim(strtok($descrizione, "\n") ?: $descrizione); } $entrate = $map['entrate'] !== null ? $this->parseMoney($parts[$map['entrate']] ?? null) : null; $uscite = $map['uscite'] !== null ? $this->parseMoney($parts[$map['uscite']] ?? null) : null; $saldo = null; $nr = $map['nr'] !== null ? trim((string) ($parts[$map['nr']] ?? '')) : null; $tipoRiga = $map['tipo_riga'] !== null ? trim((string) ($parts[$map['tipo_riga']] ?? '')) : null; $gestione = $map['gestione'] !== null ? $this->normalizeGestione((string) ($parts[$map['gestione']] ?? '')) : null; $importo = null; if ($entrate !== null && abs($entrate) > 0.00001) { $importo = (float) $entrate; } elseif ($uscite !== null && abs($uscite) > 0.00001) { $importo = -abs($uscite); } $isSaldoMensile = str_contains(mb_strtolower((string) $tipoRiga), 'saldo mensile'); $isMonthlyHeader = $this->isMonthlyHeader($descrizioneEstesa, (string) $tipoRiga); if ($importo === null && ($isSaldoMensile || $isMonthlyHeader)) { $importo = 0.0; } if ($isMonthlyHeader) { $saldo = null; } if ($importo === null) { continue; } $rows[] = [ 'data' => $data, 'valuta' => null, 'descrizione' => $descrizione, 'descrizione_estesa' => $descrizioneEstesa, 'importo' => $importo, 'causale' => null, 'raw_line' => $bufferTrimmed, 'saldo' => $saldo, 'numero' => $nr, 'tipo_riga' => $tipoRiga, 'gestione' => $gestione, ]; } // Se rimane un buffer non parseable, lo ignoriamo (best-effort). return [ 'iban' => null, 'rows' => $rows, 'meta' => [ 'format_hint' => 'cedhouse_csv', ], ]; } /** * @param array> $rows * @return array{iban: string|null, rows: array, meta?: array} */ public function parseSpreadsheetRows(array $rows): array { $headerIndex = null; $map = []; foreach ($rows as $i => $row) { $cells = array_map(static fn($v) => trim((string) ($v ?? '')), $row); $low = mb_strtolower(implode(' ', $cells)); if (! str_contains($low, 'tipo riga') || ! str_contains($low, 'entrate') || ! str_contains($low, 'uscite')) { continue; } $cols = array_map(fn($v) => $this->normHeader($v), $cells); $idxData = $this->findFirstIndex($cols, ['data']); $idxDescr = $this->findFirstIndex($cols, ['descrizione']); $idxEntrate = $this->findFirstIndex($cols, ['entrate', 'entrata']); $idxUscite = $this->findFirstIndex($cols, ['uscite', 'uscita']); $idxSaldo = $this->findFirstIndex($cols, ['saldo']); $idxNr = $this->findFirstIndex($cols, ['nr', 'numero']); $idxTipoRiga = $this->findFirstIndex($cols, ['tiporiga', 'tipo']); $idxGestione = $this->findFirstIndex($cols, ['gestione']); if ($idxData === null || $idxDescr === null || ($idxEntrate === null && $idxUscite === null)) { continue; } $headerIndex = $i; $map = [ 'data' => $idxData, 'descrizione' => $idxDescr, 'entrate' => $idxEntrate, 'uscite' => $idxUscite, 'saldo' => $idxSaldo, 'nr' => $idxNr, 'tipo_riga' => $idxTipoRiga, 'gestione' => $idxGestione, ]; break; } if ($headerIndex === null) { throw new \RuntimeException('Formato CEDHOUSE non riconosciuto: header non trovato nel file Excel'); } $resultRows = []; for ($i = $headerIndex + 1; $i < count($rows); $i++) { $row = $rows[$i] ?? []; $row = is_array($row) ? array_values($row) : []; $data = $this->parseDate($row[$map['data']] ?? null); if (! $data) { continue; } $descrizioneEstesa = trim((string) ($row[$map['descrizione']] ?? '')); if ($descrizioneEstesa === '') { continue; } $descrizione = $descrizioneEstesa; if (str_contains($descrizione, "\n")) { $descrizione = trim(strtok($descrizione, "\n") ?: $descrizione); } $entrate = $map['entrate'] !== null ? $this->parseMoney($row[$map['entrate']] ?? null) : null; $uscite = $map['uscite'] !== null ? $this->parseMoney($row[$map['uscite']] ?? null) : null; $saldo = null; $nr = $map['nr'] !== null ? trim((string) ($row[$map['nr']] ?? '')) : null; $tipoRiga = $map['tipo_riga'] !== null ? trim((string) ($row[$map['tipo_riga']] ?? '')) : null; $gestione = $map['gestione'] !== null ? $this->normalizeGestione((string) ($row[$map['gestione']] ?? '')) : null; $importo = null; if ($entrate !== null && abs($entrate) > 0.00001) { $importo = (float) $entrate; } elseif ($uscite !== null && abs($uscite) > 0.00001) { $importo = -abs($uscite); } $isSaldoMensile = str_contains(mb_strtolower((string) $tipoRiga), 'saldo mensile'); $isMonthlyHeader = $this->isMonthlyHeader($descrizioneEstesa, (string) $tipoRiga); if ($importo === null && ($isSaldoMensile || $isMonthlyHeader)) { $importo = 0.0; } if ($isMonthlyHeader) { $saldo = null; } if ($importo === null) { continue; } $resultRows[] = [ 'data' => $data, 'valuta' => null, 'descrizione' => $descrizione, 'descrizione_estesa' => $descrizioneEstesa, 'importo' => $importo, 'causale' => null, 'raw_line' => json_encode($row, JSON_UNESCAPED_UNICODE), 'saldo' => $saldo, 'numero' => $nr, 'tipo_riga' => $tipoRiga, 'gestione' => $gestione, ]; } return [ 'iban' => null, 'rows' => $resultRows, 'meta' => [ 'format_hint' => 'cedhouse_xls', ], ]; } private function normHeader(string $value): string { $value = trim(mb_strtolower($value)); $value = str_replace([' ', "\t", "\u{00A0}"], '', $value); $value = str_replace(['à', 'á', 'â', 'ä'], 'a', $value); $value = str_replace(['è', 'é', 'ê', 'ë'], 'e', $value); $value = str_replace(['ì', 'í', 'î', 'ï'], 'i', $value); $value = str_replace(['ò', 'ó', 'ô', 'ö'], 'o', $value); $value = str_replace(['ù', 'ú', 'û', 'ü'], 'u', $value); $value = preg_replace('/[^a-z0-9]/', '', $value) ?? $value; return $value; } /** * @param array $cols * @param array $candidates */ private function findFirstIndex(array $cols, array $candidates): ?int { foreach ($candidates as $c) { foreach ($cols as $idx => $col) { if ($col === $c) { return (int) $idx; } } } return null; } private function parseDate($value): ?Carbon { if ($value instanceof DateTimeInterface) { return Carbon::instance($value)->startOfDay(); } if (is_numeric($value)) { try { $dt = SpreadsheetDate::excelToDateTimeObject((float) $value); return Carbon::instance($dt)->startOfDay(); } catch (\Throwable) { // ignore } } $value = trim((string) $value); if ($value === '') { return null; } // Formato tipico: dd/mm/yyyy try { return Carbon::createFromFormat('d/m/Y', $value)->startOfDay(); } catch (\Throwable) { return null; } } private function parseMoney($value): ?float { if (is_numeric($value)) { return (float) $value; } $value = trim((string) $value); if ($value === '') { return null; } $value = str_replace(["\u{00A0}", "\t", ' '], '', $value); $value = str_ireplace(['EUR', 'EURO', '€'], '', $value); $isParenNegative = str_starts_with($value, '(') && str_ends_with($value, ')'); if ($isParenNegative) { $value = substr($value, 1, -1); } $value = preg_replace('/[^0-9,\.\-\+]/', '', $value) ?? $value; $hasComma = str_contains($value, ','); $hasDot = str_contains($value, '.'); if ($hasComma && $hasDot) { $lastComma = strrpos($value, ','); $lastDot = strrpos($value, '.'); if ($lastComma !== false && $lastDot !== false && $lastComma > $lastDot) { // Formato IT: 1.234,56 $value = str_replace('.', '', $value); $value = str_replace(',', '.', $value); } else { // Formato US: 1,234.56 $value = str_replace(',', '', $value); } } elseif ($hasComma && ! $hasDot) { // Solo virgola come decimale $value = str_replace(',', '.', $value); } else { // Solo punto o nessun separatore: lascia il punto come decimale $value = $value; } if ($isParenNegative && ! str_starts_with($value, '-')) { $value = '-' . $value; } if (! is_numeric($value)) { return null; } return (float) $value; } private function normalizeGestione(string $gestione): string { $gestione = trim($gestione); if ($gestione === '') { return ''; } $gestione = preg_replace('/\s+/u', ' ', $gestione) ?? $gestione; $parts = array_values(array_filter(array_map('trim', explode('-', $gestione)), static fn($p) => $p !== '')); if (count($parts) >= 2) { $gestione = $parts[1]; } elseif (count($parts) === 1) { $gestione = $parts[0]; } return trim((string) $gestione); } private function isMonthlyHeader(string $descrizione, string $tipoRiga): bool { if (trim($tipoRiga) !== '') { return false; } $text = mb_strtoupper(trim($descrizione)); if ($text === '') { return false; } return (bool) preg_match('/^(GENNAIO|FEBBRAIO|MARZO|APRILE|MAGGIO|GIUGNO|LUGLIO|AGOSTO|SETTEMBRE|OTTOBRE|NOVEMBRE|DICEMBRE)\s+20\d{2}$/u', $text); } }