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

228 lines
8.0 KiB
PHP

<?php
namespace App\Services\Contabilita;
use Carbon\Carbon;
class IntesaCsvParser
{
/**
* Parser "best-effort" per CSV Intesa Sanpaolo.
*
* Supporta i casi più comuni:
* - Delimitatore ';' (o ',')
* - Date in formato d/m/Y
* - Importo in formato italiano (1.234,56) o con segno
* - Colonne: data operazione / data valuta / descrizione / importo
*
* @return array{iban: string|null, rows: array<int, array{data: Carbon, valuta: Carbon|null, descrizione: string, descrizione_estesa?: string|null, importo: float, causale: string|null, raw_line: string}>}
*/
public function parse(string $content): array
{
$content = str_replace(["\r\n", "\r"], "\n", $content);
$lines = array_values(array_filter(explode("\n", $content), fn($l) => trim((string) $l) !== ''));
if (! empty($lines)) {
$lines[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $lines[0]) ?? (string) $lines[0];
}
$iban = null;
foreach ($lines as $line) {
if (preg_match('/\bIT\s*[0-9A-Z ]{20,}\b/i', (string) $line, $m)) {
$iban = strtoupper(preg_replace('/\s+/', '', $m[0]));
break;
}
}
$headerIndex = null;
$delimiter = ';';
$map = [];
foreach ($lines as $i => $line) {
$line = preg_replace('/^\xEF\xBB\xBF/', '', (string) $line) ?? (string) $line;
$candidate = trim($line);
// Heuristica: header contiene "data" e "import".
$low = mb_strtolower($candidate);
if (! str_contains($low, 'data') || ! (str_contains($low, 'import') || str_contains($low, 'dare') || str_contains($low, 'avere'))) {
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, ['dataoperazione', 'dataop', 'datacontabile', 'data']);
$idxValuta = $this->findFirstIndex($cols, ['datavaluta', 'valuta', 'dataval']);
// "descrizione" spesso è "descrizione" o "causale" o "descrizioneoperazione".
$idxDescr = $this->findFirstIndex($cols, ['descrizione', 'descrizioneoperazione', 'causale', 'causaleabi', 'dettaglio']);
// Alcuni export Intesa includono una colonna separata di descrizione estesa.
$idxDescrEstesa = $this->findFirstIndex($cols, ['descrizioneoperazioneestesa', 'descrizioneestesa', 'dettagliooperazione', 'dettagliooperazioneesteso']);
// Importo può essere una colonna unica o 2 colonne dare/avere.
$idxImporto = $this->findFirstIndex($cols, ['importo', 'importoeuro', 'euro', 'importooperazione']);
$idxDare = $this->findFirstIndex($cols, ['dare', 'importodare']);
$idxAvere = $this->findFirstIndex($cols, ['avere', 'importoavere']);
if ($idxData === null || ($idxImporto === null && ($idxDare === null && $idxAvere === null)) || $idxDescr === null) {
continue;
}
$headerIndex = $i;
$delimiter = $delim;
$map = [
'data' => $idxData,
'valuta' => $idxValuta,
'descrizione' => $idxDescr,
'descrizione_estesa' => $idxDescrEstesa,
'importo' => $idxImporto,
'dare' => $idxDare,
'avere' => $idxAvere,
];
break;
}
if ($headerIndex === null) {
throw new \RuntimeException('Formato Intesa non riconosciuto: header CSV non trovato');
}
$rows = [];
for ($i = $headerIndex + 1; $i < count($lines); $i++) {
$raw = (string) $lines[$i];
$parts = str_getcsv($raw, $delimiter);
$parts = array_map(static fn($v) => trim((string) $v), $parts);
$data = $this->parseDate($parts[$map['data']] ?? null);
if (! $data) {
continue;
}
$valuta = null;
if ($map['valuta'] !== null) {
$valuta = $this->parseDate($parts[$map['valuta']] ?? null);
}
$descrizione = trim((string) ($parts[$map['descrizione']] ?? ''));
if ($descrizione === '') {
continue;
}
$descrizioneEstesa = null;
if (isset($map['descrizione_estesa']) && $map['descrizione_estesa'] !== null) {
$descrizioneEstesa = trim((string) ($parts[$map['descrizione_estesa']] ?? ''));
if ($descrizioneEstesa === '') {
$descrizioneEstesa = null;
}
}
$importo = null;
if ($map['importo'] !== null) {
$importo = $this->parseMoney($parts[$map['importo']] ?? null);
} else {
$dare = $map['dare'] !== null ? $this->parseMoney($parts[$map['dare']] ?? null) : null;
$avere = $map['avere'] !== null ? $this->parseMoney($parts[$map['avere']] ?? null) : null;
if ($dare !== null && $dare != 0.0) {
$importo = -abs($dare);
} elseif ($avere !== null && $avere != 0.0) {
$importo = abs($avere);
}
}
if ($importo === null) {
continue;
}
$rows[] = [
'data' => $data,
'valuta' => $valuta,
'descrizione' => $descrizione,
'descrizione_estesa' => $descrizioneEstesa,
'importo' => $importo,
'causale' => null,
'raw_line' => $raw,
];
}
return ['iban' => $iban, 'rows' => $rows];
}
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 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;
}
$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;
$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;
}
}