, * meta: array{header_row:int, header_map: array, highest_row:int, highest_col:int, parsed_rows:int} * } */ public function parseXlsx(string $filePath): array { $spreadsheet = IOFactory::load($filePath); $sheet = $spreadsheet->getSheet(0); $highestRow = (int) $sheet->getHighestRow(); $highestCol = $sheet->getHighestColumn(); $highestColIndex = Coordinate::columnIndexFromString($highestCol); $headerRow = null; $headerMap = null; // Cerca header entro le prime 400 righe (alcuni export bancari hanno molte righe introduttive). $scanMax = min($highestRow, 400); for ($r = 1; $r <= $scanMax; $r++) { $values = []; for ($c = 1; $c <= $highestColIndex; $c++) { $coord = Coordinate::stringFromColumnIndex($c) . $r; $values[] = $this->normHeader((string) $sheet->getCell($coord)->getValue()); } $idxData = $this->findFirstIndex($values, ['data', 'dataoperazione', 'dataop', 'datacontabile']); $idxDescr = $this->findFirstIndex($values, ['descrizione', 'causale', 'descrizioneoperazione']); $idxDescrEstesa = $this->findFirstIndex($values, [ 'descrizioneestesa', 'dettaglio', 'dettagli', 'dettagliooperazione', 'dettaglioperazione', 'dettaglioperazioni', 'descrizioneoperazioneestesa', 'descrizioneestesaoperazione', 'descrizionecompleta', 'causaleestesa', ]); $idxImporto = $this->findFirstIndex($values, ['importo', 'importoeuro', 'euro', 'importooperazione', 'ammontare']); $idxDare = $this->findFirstIndex($values, ['dare', 'addebito', 'importodare']); $idxAvere = $this->findFirstIndex($values, ['avere', 'accredito', 'importoavere']); $idxValuta = $this->findFirstIndex($values, ['datavaluta', 'valuta', 'dataval']); if ($idxData !== null && $idxDescr !== null && ($idxImporto !== null || ($idxDare !== null || $idxAvere !== null))) { $headerRow = $r; $headerMap = [ 'data' => $idxData + 1, 'descrizione' => $idxDescr + 1, 'descrizione_estesa' => $idxDescrEstesa !== null ? ($idxDescrEstesa + 1) : null, 'importo' => $idxImporto !== null ? ($idxImporto + 1) : null, 'dare' => $idxDare !== null ? ($idxDare + 1) : null, 'avere' => $idxAvere !== null ? ($idxAvere + 1) : null, 'valuta' => $idxValuta !== null ? ($idxValuta + 1) : null, ]; break; } } // Fallback: file senza header (o header non standard). Prova a inferire le colonne analizzando i valori. $inferred = false; $startRow = null; if ($headerRow === null || $headerMap === null) { $intesa = $this->inferIntesaLayoutWithoutHeader($sheet, $highestRow, $highestColIndex); if ($intesa) { $headerRow = 0; $startRow = (int) ($intesa['start_row'] ?? 1); $headerMap = [ 'data' => (int) $intesa['data'], 'descrizione' => (int) $intesa['descrizione'], 'descrizione_estesa' => (int) $intesa['descrizione_estesa'], 'importo' => null, 'dare' => (int) $intesa['dare'], 'avere' => (int) $intesa['avere'], 'valuta' => isset($intesa['valuta']) ? (int) $intesa['valuta'] : null, ]; $inferred = true; } } if ($headerRow === null || $headerMap === null) { $inf = $this->inferColumnsWithoutHeader($sheet, $highestRow, $highestColIndex); if ($inf) { $headerRow = 0; $startRow = (int) ($inf['start_row'] ?? 1); $headerMap = [ 'data' => (int) $inf['data'], 'descrizione' => (int) $inf['descrizione'], 'descrizione_estesa' => null, 'importo' => (int) $inf['importo'], 'dare' => null, 'avere' => null, 'valuta' => isset($inf['valuta']) ? (int) $inf['valuta'] : null, ]; $inferred = true; } } if ($headerRow === null || $headerMap === null) { $samples = $this->sampleTopRows($sheet, min($highestRow, 10), min($highestColIndex, 12)); $hint = $samples !== '' ? ("\nEsempio prime righe (normalizzate):\n" . $samples) : ''; throw new \RuntimeException('Formato Excel non riconosciuto: header non trovato' . $hint); } $rows = []; $skipped = [ 'importo_out_of_range' => 0, ]; $firstDataRow = $startRow !== null ? $startRow : ($headerRow + 1); for ($r = $firstDataRow; $r <= $highestRow; $r++) { $dataCoord = Coordinate::stringFromColumnIndex((int) $headerMap['data']) . $r; $dataCell = $sheet->getCell($dataCoord); $data = $this->parseExcelDate($dataCell->getValue()); if (! $data) { continue; } $valuta = null; if ($headerMap['valuta'] !== null) { $valutaCoord = Coordinate::stringFromColumnIndex((int) $headerMap['valuta']) . $r; $valutaCell = $sheet->getCell($valutaCoord); $valuta = $this->parseExcelDate($valutaCell->getValue()); } $descrCoord = Coordinate::stringFromColumnIndex((int) $headerMap['descrizione']) . $r; $descrCell = $sheet->getCell($descrCoord); $descrizione = trim((string) $descrCell->getValue()); if ($descrizione === '') { continue; } $descrizioneEstesa = null; if (!empty($headerMap['descrizione_estesa'])) { $descrEstCoord = Coordinate::stringFromColumnIndex((int) $headerMap['descrizione_estesa']) . $r; $descrEstCell = $sheet->getCell($descrEstCoord); $descrizioneEstesa = trim((string) $descrEstCell->getValue()); if ($descrizioneEstesa === '') { $descrizioneEstesa = null; } } $importo = null; if ($headerMap['importo'] !== null) { $impCoord = Coordinate::stringFromColumnIndex((int) $headerMap['importo']) . $r; $impCell = $sheet->getCell($impCoord); $importo = $this->parseMoney($impCell->getCalculatedValue()); } else { $dare = null; $avere = null; if ($headerMap['dare'] !== null) { $dareCoord = Coordinate::stringFromColumnIndex((int) $headerMap['dare']) . $r; $dareCell = $sheet->getCell($dareCoord); $dare = $this->parseMoney($dareCell->getCalculatedValue()); } if ($headerMap['avere'] !== null) { $avereCoord = Coordinate::stringFromColumnIndex((int) $headerMap['avere']) . $r; $avereCell = $sheet->getCell($avereCoord); $avere = $this->parseMoney($avereCell->getCalculatedValue()); } if ($dare !== null && $dare != 0.0) { $importo = -abs($dare); } elseif ($avere !== null && $avere != 0.0) { $importo = abs($avere); } } if ($importo === null) { continue; } // Sicurezza: evita insert SQL falliti su importi palesemente errati. if (abs((float) $importo) > self::MAX_DB_IMPORTO_ABS) { $skipped['importo_out_of_range']++; continue; } $rows[] = [ 'data' => $data, 'valuta' => $valuta, 'descrizione' => $descrizione, 'descrizione_estesa' => $descrizioneEstesa, 'importo' => (float) $importo, 'causale' => null, 'raw_line' => 'xlsx:' . $r . ($descrizioneEstesa ? ('|' . $descrizioneEstesa) : ''), ]; } return [ 'rows' => $rows, 'meta' => [ 'header_row' => (int) $headerRow, 'header_map' => $headerMap, 'highest_row' => (int) $highestRow, 'highest_col' => (int) $highestColIndex, 'parsed_rows' => (int) count($rows), 'inferred' => (bool) $inferred, 'first_data_row' => (int) $firstDataRow, 'scan_max' => (int) $scanMax, 'skipped' => $skipped, ], ]; } /** * Heuristica per file XLSX senza header: trova colonne data/descrizione/importo analizzando le prime righe. * * @return array{data:int, descrizione:int, importo:int, valuta?:int, start_row:int}|null */ private function inferColumnsWithoutHeader(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, int $highestRow, int $highestColIndex): ?array { $scanRows = min($highestRow, 400); $scanCols = min($highestColIndex, 40); $dateScore = array_fill(1, $scanCols, 0); $moneyScore = array_fill(1, $scanCols, 0); $textScore = array_fill(1, $scanCols, 0); for ($r = 1; $r <= $scanRows; $r++) { for ($c = 1; $c <= $scanCols; $c++) { $coord = Coordinate::stringFromColumnIndex($c) . $r; $v = $sheet->getCell($coord)->getCalculatedValue(); if ($this->parseExcelDate($v)) { $dateScore[$c]++; continue; } if ($this->parseMoneyCandidate($v) !== null) { $moneyScore[$c]++; continue; } $s = trim((string) $v); if ($s !== '' && !is_numeric($s)) { $textScore[$c]++; } } } $dataCol = $this->bestScoreColumn($dateScore); $importoCol = $this->bestScoreColumn($moneyScore); $descrCol = $this->bestScoreColumn($textScore); if (!$dataCol || !$importoCol || !$descrCol) { return null; } // Trova la prima riga che sembra una riga dati. $startRow = null; for ($r = 1; $r <= $scanRows; $r++) { $vData = $sheet->getCell(Coordinate::stringFromColumnIndex($dataCol) . $r)->getCalculatedValue(); $vImp = $sheet->getCell(Coordinate::stringFromColumnIndex($importoCol) . $r)->getCalculatedValue(); $vDesc = $sheet->getCell(Coordinate::stringFromColumnIndex($descrCol) . $r)->getCalculatedValue(); if (!$this->parseExcelDate($vData)) { continue; } if ($this->parseMoneyCandidate($vImp) === null) { continue; } $s = trim((string) $vDesc); if ($s === '') { continue; } $startRow = $r; break; } if ($startRow === null) { return null; } return [ 'data' => $dataCol, 'descrizione' => $descrCol, 'importo' => $importoCol, 'start_row' => (int) $startRow, ]; } /** * Heuristica specifica per layout Intesa (senza header): * A=data, B=valuta, C=descrizione breve, D=accredito, E=addebito, F=descrizione estesa. * * @return array{data:int, valuta:int, descrizione:int, descrizione_estesa:int, dare:int, avere:int, start_row:int}|null */ private function inferIntesaLayoutWithoutHeader(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, int $highestRow, int $highestColIndex): ?array { if ($highestColIndex < 6) { return null; } $scanRows = min($highestRow, 400); for ($r = 1; $r <= $scanRows; $r++) { $a = $sheet->getCell('A' . $r)->getValue(); $b = $sheet->getCell('B' . $r)->getValue(); $c = trim((string) $sheet->getCell('C' . $r)->getValue()); $d = $sheet->getCell('D' . $r)->getCalculatedValue(); $e = $sheet->getCell('E' . $r)->getCalculatedValue(); $f = trim((string) $sheet->getCell('F' . $r)->getValue()); $isData = $this->parseExcelDate($a) !== null; $isValuta = $this->parseExcelDate($b) !== null; $hasDesc = $c !== '' && !is_numeric($c); $hasImporto = $this->parseMoneyCandidate($d) !== null || $this->parseMoneyCandidate($e) !== null; $hasDett = strlen($f) > 20; if ($isData && $isValuta && $hasDesc && $hasImporto && $hasDett) { return [ 'data' => 1, 'valuta' => 2, 'descrizione' => 3, 'avere' => 4, 'dare' => 5, 'descrizione_estesa' => 6, 'start_row' => $r, ]; } } return null; } /** * Versione "prudente" di parseMoney usata SOLO per inferenza colonne. * Serve a non scambiare numeri conto/ID lunghi per importi. */ private function parseMoneyCandidate(mixed $value): ?float { $parsed = $this->parseMoney($value); if ($parsed === null) { return null; } $abs = abs((float) $parsed); if ($abs <= 0.0) { return null; } // Taglio duro: importi "veri" raramente sono nell'ordine dei miliardi. if ($abs > self::MAX_PLAUSIBLE_IMPORTO_ABS) { return null; } // Se il valore originale è una stringa numerica molto lunga senza separatori, è più probabile sia un ID. $raw = is_string($value) ? trim($value) : null; if (is_string($raw) && $raw !== '') { $rawCompact = str_replace(["\u{00A0}", "\t", ' '], '', $raw); if (preg_match('/^[0-9]{12,}$/', $rawCompact)) { return null; } } return (float) $parsed; } /** @param array $scores */ private function bestScoreColumn(array $scores): ?int { $bestCol = null; $best = 0; foreach ($scores as $col => $score) { if ($score > $best) { $best = $score; $bestCol = (int) $col; } } // Soglia minima: evita falsi positivi su fogli quasi vuoti. return ($bestCol !== null && $best >= 3) ? $bestCol : null; } private function sampleTopRows(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, int $rows, int $cols): string { $out = []; for ($r = 1; $r <= $rows; $r++) { $vals = []; for ($c = 1; $c <= $cols; $c++) { $v = (string) $sheet->getCell(Coordinate::stringFromColumnIndex($c) . $r)->getValue(); $v = $this->normHeader($v); if ($v !== '') { $vals[] = $v; } } if ($vals) { $out[] = $r . ': ' . implode(' | ', $vals); } } return implode("\n", array_slice($out, 0, 8)); } 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 parseExcelDate(mixed $value): ?Carbon { if ($value === null || $value === '') { return null; } try { if (is_numeric($value)) { $dt = ExcelDate::excelToDateTimeObject((float) $value); return Carbon::instance($dt)->startOfDay(); } $s = trim((string) $value); if ($s === '') { return null; } foreach (['d/m/Y', 'd-m-Y', 'Y-m-d'] as $fmt) { try { return Carbon::createFromFormat($fmt, $s)->startOfDay(); } catch (\Throwable) { // keep trying } } } catch (\Throwable) { return null; } return null; } private function parseMoney(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { $f = (float) $value; // Evita overflow e valori assurdi (es. scientific notation da celle non importo) if (abs($f) > self::MAX_DB_IMPORTO_ABS) { return null; } return $f; } $s = trim((string) $value); if ($s === '') { return null; } $s = str_replace(["\u{00A0}", "\t", ' '], '', $s); $s = str_ireplace(['EUR', 'EURO', '€'], '', $s); $isParenNegative = str_starts_with($s, '(') && str_ends_with($s, ')'); if ($isParenNegative) { $s = substr($s, 1, -1); } $s = preg_replace('/[^0-9,\.\-\+]/', '', $s) ?? $s; $s = str_replace('.', '', $s); $s = str_replace(',', '.', $s); if ($isParenNegative && ! str_starts_with($s, '-')) { $s = '-' . $s; } if (! is_numeric($s)) { return null; } $f = (float) $s; if (abs($f) > self::MAX_DB_IMPORTO_ABS) { return null; } return $f; } }