207 lines
6.6 KiB
PHP
207 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Contabilita;
|
|
|
|
use Carbon\Carbon;
|
|
|
|
class UnicreditWriParser
|
|
{
|
|
/**
|
|
* @return array{iban: string|null, rows: array<int, array{data: Carbon, valuta: Carbon|null, descrizione: string, 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(array_map('trim', explode("\n", $content)), fn($l) => $l !== ''));
|
|
|
|
// Alcuni export arrivano con BOM UTF-8 sulla prima riga (es. "\xEF\xBB\xBFData;...")
|
|
if (! empty($lines)) {
|
|
$lines[0] = preg_replace('/^\xEF\xBB\xBF/', '', $lines[0]) ?? $lines[0];
|
|
}
|
|
|
|
$iban = null;
|
|
foreach ($lines as $line) {
|
|
if (preg_match('/Conto\s+Corrente\s+(IT\s*[0-9A-Z ]{20,})/i', $line, $m)) {
|
|
$iban = strtoupper(preg_replace('/\s+/', '', $m[1]));
|
|
break;
|
|
}
|
|
}
|
|
|
|
$headerIndex = null;
|
|
foreach ($lines as $i => $line) {
|
|
$lineNoBom = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line;
|
|
if (str_starts_with($lineNoBom, 'Data;Valuta;Descrizione;Euro;')) {
|
|
$headerIndex = $i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($headerIndex === null) {
|
|
throw new \RuntimeException('Formato non riconosciuto: header CSV non trovato');
|
|
}
|
|
|
|
$rows = [];
|
|
for ($i = $headerIndex + 1; $i < count($lines); $i++) {
|
|
$line = $lines[$i];
|
|
|
|
$parsed = $this->parseDataLine($line);
|
|
if ($parsed === null) {
|
|
continue;
|
|
}
|
|
|
|
$data = $this->parseDate($parsed['data'] ?? null);
|
|
if (! $data) {
|
|
continue;
|
|
}
|
|
|
|
$valuta = $this->parseDate($parsed['valuta'] ?? null);
|
|
$descrizione = trim((string) ($parsed['descrizione'] ?? ''));
|
|
$importo = $this->parseMoney($parsed['importo'] ?? null);
|
|
$causale = isset($parsed['causale']) ? trim((string) $parsed['causale']) : null;
|
|
|
|
if ($descrizione === '' || $importo === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'data' => $data,
|
|
'valuta' => $valuta,
|
|
'descrizione' => $descrizione,
|
|
'importo' => $importo,
|
|
'causale' => $causale !== '' ? $causale : null,
|
|
'raw_line' => $line,
|
|
];
|
|
}
|
|
|
|
return ['iban' => $iban, 'rows' => $rows];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Normalizza: rimuove simboli valuta, NBSP, spazi, prefissi (es. "EUR")
|
|
$value = str_replace(["\u{00A0}", "\t", ' '], '', $value);
|
|
$value = str_ireplace(['EUR', 'EURO', '€'], '', $value);
|
|
|
|
// Gestione negativo tra parentesi: (1.234,56)
|
|
$isParenNegative = str_starts_with($value, '(') && str_ends_with($value, ')');
|
|
if ($isParenNegative) {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
|
|
// Tiene solo caratteri numerici e separatori comuni
|
|
$value = preg_replace('/[^0-9,\.\-\+]/', '', $value) ?? $value;
|
|
|
|
// Es: -1.326,52 oppure 1.714,79
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Il CSV Unicredit WRI è delimitato da ';' ma spesso la descrizione contiene ';' NON quotati.
|
|
* In quel caso, lo split "naïve" fa slittare l'importo in colonne sbagliate.
|
|
*
|
|
* Strategia:
|
|
* - parse con str_getcsv (così se ci sono virgolette le rispettiamo)
|
|
* - trova importo e (opzionale) causale partendo da destra
|
|
* - ricompone la descrizione unendo i token centrali con ';'
|
|
*
|
|
* @return array{data:string, valuta:string, descrizione:string, importo:string, causale:string|null}|null
|
|
*/
|
|
private function parseDataLine(string $line): ?array
|
|
{
|
|
$parts = str_getcsv($line, ';');
|
|
$parts = array_map(static fn($v) => trim((string) $v), $parts);
|
|
|
|
if (count($parts) < 4) {
|
|
return null;
|
|
}
|
|
|
|
// Rimuove eventuali token vuoti finali
|
|
while (count($parts) > 0 && end($parts) === '') {
|
|
array_pop($parts);
|
|
}
|
|
if (count($parts) < 4) {
|
|
return null;
|
|
}
|
|
|
|
$data = $parts[0] ?? '';
|
|
$valuta = $parts[1] ?? '';
|
|
|
|
// Prova standard: ultimo = causale (3 cifre), penultimo = importo
|
|
$last = $parts[count($parts) - 1] ?? '';
|
|
$prev = $parts[count($parts) - 2] ?? '';
|
|
|
|
$moneyIndex = null;
|
|
$causale = null;
|
|
|
|
if (preg_match('/^\d{3}$/', $last) && $this->parseMoney($prev) !== null) {
|
|
$causale = $last;
|
|
$moneyIndex = count($parts) - 2;
|
|
} elseif ($this->parseMoney($last) !== null) {
|
|
// Formato senza causale
|
|
$moneyIndex = count($parts) - 1;
|
|
} else {
|
|
// Fallback: cerca da destra il primo token che sembra un importo
|
|
for ($idx = count($parts) - 1; $idx >= 2; $idx--) {
|
|
if ($this->parseMoney($parts[$idx]) !== null) {
|
|
$moneyIndex = $idx;
|
|
$maybeCausale = $parts[$idx + 1] ?? null;
|
|
if (is_string($maybeCausale) && preg_match('/^\d{3}$/', $maybeCausale)) {
|
|
$causale = $maybeCausale;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($moneyIndex === null || $moneyIndex < 3) {
|
|
return null;
|
|
}
|
|
|
|
$importo = $parts[$moneyIndex] ?? '';
|
|
$descrParts = array_slice($parts, 2, $moneyIndex - 2);
|
|
$descrizione = trim(implode(';', $descrParts));
|
|
|
|
if ($descrizione === '' || $importo === '') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'data' => $data,
|
|
'valuta' => $valuta,
|
|
'descrizione' => $descrizione,
|
|
'importo' => $importo,
|
|
'causale' => $causale,
|
|
];
|
|
}
|
|
}
|