522 lines
19 KiB
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;
|
|
}
|
|
}
|