434 lines
15 KiB
PHP
434 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Contabilita;
|
|
|
|
use Carbon\Carbon;
|
|
use DateTimeInterface;
|
|
use PhpOffice\PhpSpreadsheet\Shared\Date as SpreadsheetDate;
|
|
|
|
class CedhouseCsvParser
|
|
{
|
|
/**
|
|
* Parser best-effort per export CEDHOUSE (es. "Banca Doria.csv").
|
|
* Header tipico:
|
|
* R;NR;Data;Tipo Riga;Descrizione;Entrate;Uscite;Saldo;Gestione
|
|
*
|
|
* @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, gestione?: string|null}>, meta?: array<string,mixed>}
|
|
*/
|
|
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<int, array<int, string|int|float|null>> $rows
|
|
* @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, gestione?: string|null}>, meta?: array<string,mixed>}
|
|
*/
|
|
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<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($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);
|
|
}
|
|
}
|