netgescon-day0/app/Services/Contabilita/ExcelMovimentiParser.php

522 lines
19 KiB
PHP

<?php
namespace App\Services\Contabilita;
use Carbon\Carbon;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
class ExcelMovimentiParser
{
private const MAX_DB_IMPORTO_ABS = 9999999999999.99; // decimal(15,2)
private const MAX_PLAUSIBLE_IMPORTO_ABS = 1000000000.0; // 1B: euristica per inferenza (evita colonne come numeri conto)
/**
* Parser "best-effort" per estratti conto in Excel (XLSX).
*
* Cerca una riga header e mappa colonne tipiche:
* - data / data operazione
* - descrizione / causale
* - importo (oppure dare/avere)
*
* @return array{
* rows: array<int, array{data: Carbon, valuta: Carbon|null, descrizione: string, descrizione_estesa?: string|null, importo: float, causale: string|null, raw_line: string}>,
* meta: array{header_row:int, header_map: array<string,int|null>, 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<int,int> $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<int,string> $cols
* @param array<int,string> $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;
}
}