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

1025 lines
38 KiB
PHP

<?php
namespace App\Services\Contabilita;
use App\Models\DatiBancari;
use App\Models\RubricaUniversale;
use App\Models\Fornitore;
use App\Modules\Contabilita\Models\MovimentoBanca;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class MovimentiBancaImporter
{
private UnicreditSemicolonCsvParser $unicreditSemicolonCsvParser;
private CedhouseCsvParser $cedhouseCsvParser;
private MpsQifParser $mpsQifParser;
public function __construct(
private readonly UnicreditWriParser $unicreditParser,
private readonly IntesaCsvParser $intesaParser,
private readonly ExcelMovimentiParser $excelParser,
?UnicreditSemicolonCsvParser $unicreditSemicolonCsvParser = null,
?CedhouseCsvParser $cedhouseCsvParser = null,
?MpsQifParser $mpsQifParser = null,
) {
$this->unicreditSemicolonCsvParser = $unicreditSemicolonCsvParser ?? new UnicreditSemicolonCsvParser();
$this->cedhouseCsvParser = $cedhouseCsvParser ?? new CedhouseCsvParser();
$this->mpsQifParser = $mpsQifParser ?? new MpsQifParser();
}
/**
* Import movimenti da CSV (separatore ;) con header "Data contabile" ecc., legato a un conto specifico.
*
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
public function importUnicreditSemicolonCsvForConto(
string $content,
int $stabileId,
int $contoId,
?string $sourceFile = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->unicreditSemicolonCsvParser->parse($content);
$iban = null;
try {
$iban = DatiBancari::query()->where('id', $contoId)->value('iban');
$iban = is_string($iban) && trim($iban) !== '' ? trim($iban) : null;
} catch (\Throwable) {
$iban = null;
}
$imported = 0;
$duplicates = 0;
$minDateStr = null;
$maxDateStr = null;
if (! empty($parsed['rows'])) {
$min = null;
$max = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
$min = $min ? ($d->lt($min) ? $d : $min) : $d;
$max = $max ? ($d->gt($max) ? $d : $max) : $d;
}
$minDateStr = $min ? $min->toDateString() : null;
$maxDateStr = $max ? $max->toDateString() : null;
}
DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, &$imported, &$duplicates) {
if ($replace && ! empty($parsed['rows'])) {
$minDate = null;
$maxDate = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) {
continue;
}
$minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d;
$maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d;
}
if ($minDate && $maxDate) {
MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('conto_id', $contoId)
->whereNotNull('raw_line')
->whereNull('registrazione_id')
->whereDate('data', '>=', $minDate->toDateString())
->whereDate('data', '<=', $maxDate->toDateString())
->delete();
}
}
foreach ($parsed['rows'] as $row) {
$hashBase = implode('|', [
$iban ?? ('CONTO:' . $contoId),
$row['data']->format('Y-m-d'),
($row['valuta']?->format('Y-m-d') ?? ''),
$row['descrizione'],
number_format((float) $row['importo'], 2, '.', ''),
$row['causale'] ?? '',
]);
$rowHash = hash('sha256', $hashBase);
$exists = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('row_hash', $rowHash)
->exists();
if ($exists) {
$duplicates++;
continue;
}
$payload = [
'stabile_id' => $stabileId,
'conto_id' => $contoId,
'gestione_id' => $gestioneId,
'iban' => $iban,
'data' => $row['data'],
'valuta' => $row['valuta'],
'descrizione' => $row['descrizione'],
'importo' => $row['importo'],
'causale' => $row['causale'],
'source_file' => $sourceFile,
'raw_line' => $row['raw_line'],
'row_hash' => $rowHash,
];
if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) {
$payload['descrizione_estesa'] = isset($row['descrizione_estesa']) && is_string($row['descrizione_estesa'])
? trim($row['descrizione_estesa'])
: null;
if (($payload['descrizione_estesa'] ?? null) === null && is_string($row['raw_line'] ?? null) && str_contains($row['raw_line'], '|')) {
$parts = explode('|', (string) $row['raw_line'], 2);
$fallback = trim((string) ($parts[1] ?? ''));
$payload['descrizione_estesa'] = $fallback !== '' ? $fallback : null;
}
}
if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) {
$payload['descrizione_estesa'] = isset($row['descrizione_estesa']) && is_string($row['descrizione_estesa'])
? trim($row['descrizione_estesa'])
: null;
}
$payload = $this->appendMatchData($payload, $row['descrizione'] ?? null, $row['raw_line'] ?? null, $row['descrizione'] ?? null);
MovimentoBanca::query()->create($payload);
$imported++;
}
});
$meta = [];
if (isset($parsed['meta']) && is_array($parsed['meta'])) {
$meta = $parsed['meta'];
}
if ($minDateStr && $maxDateStr) {
$meta['min_date'] = $minDateStr;
$meta['max_date'] = $maxDateStr;
}
return ['imported' => $imported, 'duplicates' => $duplicates, 'meta' => $meta];
}
/**
* Import CSV legato a un conto, scegliendo automaticamente il parser (multi-banca).
*
* Attualmente supporta:
* - Unicredit semicolon ("Data contabile;...;Accrediti;Addebiti")
* - Unicredit WRI ("Data;Valuta;Descrizione;Euro;")
* - Intesa CSV (header tipici "data operazione" / "data valuta")
* - CEDHOUSE ("R;NR;Data;Tipo Riga;Descrizione;Entrate;Uscite;...")
*
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
public function importCsvAutoForConto(
string $content,
int $stabileId,
int $contoId,
?string $sourceFile = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$format = $this->detectCsvFormat($content);
if ($format === 'unicredit_semicolon') {
$res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace);
$res['meta'] = array_merge(['format' => $format], is_array($res['meta'] ?? null) ? $res['meta'] : []);
return $res;
}
if ($format === 'unicredit_wri') {
$parsed = $this->unicreditParser->parse($content);
return $this->importParsedRowsForConto(
$parsed,
$stabileId,
$contoId,
$sourceFile,
$gestioneId,
$replace,
$format,
);
}
if ($format === 'intesa_csv') {
$parsed = $this->intesaParser->parse($content);
return $this->importParsedRowsForConto(
$parsed,
$stabileId,
$contoId,
$sourceFile,
$gestioneId,
$replace,
$format,
);
}
if ($format === 'cedhouse_csv') {
$parsed = $this->cedhouseCsvParser->parse($content);
return $this->importParsedRowsForConto(
$parsed,
$stabileId,
$contoId,
$sourceFile,
$gestioneId,
$replace,
$format,
);
}
// Fallback: prova in sequenza (utile per CSV "strani" o header varianti)
$errors = [];
foreach (['unicredit_wri', 'intesa_csv', 'cedhouse_csv', 'unicredit_semicolon'] as $try) {
try {
if ($try === 'unicredit_semicolon') {
$res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace);
$res['meta'] = array_merge(['format' => $try], is_array($res['meta'] ?? null) ? $res['meta'] : []);
return $res;
}
$parsed = $try === 'unicredit_wri'
? $this->unicreditParser->parse($content)
: ($try === 'cedhouse_csv'
? $this->cedhouseCsvParser->parse($content)
: $this->intesaParser->parse($content));
return $this->importParsedRowsForConto($parsed, $stabileId, $contoId, $sourceFile, $gestioneId, $replace, $try);
} catch (\Throwable $e) {
$errors[] = $try . ': ' . $e->getMessage();
}
}
throw new \RuntimeException('Formato CSV non riconosciuto. Tentativi: ' . implode(' | ', $errors));
}
/**
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
public function importUnicreditWriForConto(
string $content,
int $stabileId,
int $contoId,
?string $sourceFile = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->unicreditParser->parse($content);
return $this->importParsedRowsForConto(
$parsed,
$stabileId,
$contoId,
$sourceFile,
$gestioneId,
$replace,
'unicredit_wri',
);
}
/**
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
public function importIntesaCsvForConto(
string $content,
int $stabileId,
int $contoId,
?string $sourceFile = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->intesaParser->parse($content);
return $this->importParsedRowsForConto(
$parsed,
$stabileId,
$contoId,
$sourceFile,
$gestioneId,
$replace,
'intesa_csv',
);
}
/**
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
public function importMpsQifForConto(
string $content,
int $stabileId,
int $contoId,
?string $sourceFile = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->mpsQifParser->parse($content);
return $this->importParsedRowsForConto(
$parsed,
$stabileId,
$contoId,
$sourceFile,
$gestioneId,
$replace,
'mps_qif',
);
}
/**
* @param array{rows?:array<int,array{data:\Carbon\Carbon,valuta:\Carbon\Carbon|null,descrizione:string,descrizione_estesa?:string|null,importo:float,causale:string|null,raw_line:string}>, meta?:array<string,mixed>} $parsed
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
private function importParsedRowsForConto(
array $parsed,
int $stabileId,
int $contoId,
?string $sourceFile,
?int $gestioneId,
bool $replace,
string $format,
): array {
$rows = $parsed['rows'] ?? [];
if (! is_array($rows) || empty($rows)) {
throw new \RuntimeException('Nessuna riga trovata nel file (' . $format . ').');
}
$iban = null;
try {
$iban = DatiBancari::query()->where('id', $contoId)->value('iban');
$iban = is_string($iban) && trim($iban) !== '' ? trim($iban) : null;
} catch (\Throwable) {
$iban = null;
}
$imported = 0;
$duplicates = 0;
$hasDescrizioneEstesa = false;
try {
$hasDescrizioneEstesa = DB::getSchemaBuilder()->hasColumn('contabilita_movimenti_banca', 'descrizione_estesa');
} catch (\Throwable) {
$hasDescrizioneEstesa = false;
}
$minDateStr = null;
$maxDateStr = null;
$min = null;
$max = null;
foreach ($rows as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
$min = $min ? ($d->lt($min) ? $d : $min) : $d;
$max = $max ? ($d->gt($max) ? $d : $max) : $d;
}
$minDateStr = $min ? $min->toDateString() : null;
$maxDateStr = $max ? $max->toDateString() : null;
DB::transaction(function () use ($rows, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, $hasDescrizioneEstesa, &$imported, &$duplicates) {
if ($replace && ! empty($rows)) {
$minDate = null;
$maxDate = null;
foreach ($rows as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
$minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d;
$maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d;
}
if ($minDate && $maxDate) {
MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('conto_id', $contoId)
->whereNotNull('raw_line')
->whereNull('registrazione_id')
->whereDate('data', '>=', $minDate->toDateString())
->whereDate('data', '<=', $maxDate->toDateString())
->delete();
}
}
foreach ($rows as $row) {
$hashBase = implode('|', [
$iban ?? ('CONTO:' . $contoId),
$row['data']->format('Y-m-d'),
($row['valuta']?->format('Y-m-d') ?? ''),
$row['descrizione'],
number_format((float) $row['importo'], 2, '.', ''),
$row['causale'] ?? '',
]);
$rowHash = hash('sha256', $hashBase);
$exists = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('row_hash', $rowHash)
->exists();
if ($exists) {
$duplicates++;
continue;
}
$payload = [
'stabile_id' => $stabileId,
'conto_id' => $contoId,
'gestione_id' => $gestioneId,
'iban' => $iban,
'data' => $row['data'],
'valuta' => $row['valuta'],
'descrizione' => $row['descrizione'],
'importo' => $row['importo'],
'causale' => $row['causale'],
'source_file' => $sourceFile,
'raw_line' => $row['raw_line'],
'row_hash' => $rowHash,
];
if ($hasDescrizioneEstesa) {
$descEst = isset($row['descrizione_estesa']) && is_string($row['descrizione_estesa'])
? trim($row['descrizione_estesa'])
: null;
if (! $descEst && isset($row['raw_line']) && is_string($row['raw_line'])) {
$descEst = trim($row['raw_line']);
}
$payload['descrizione_estesa'] = $descEst !== '' ? $descEst : null;
}
MovimentoBanca::query()->create($payload);
$imported++;
}
});
$meta = [];
if (isset($parsed['meta']) && is_array($parsed['meta'])) {
$meta = $parsed['meta'];
}
$meta['format'] = $format;
if ($minDateStr && $maxDateStr) {
$meta['min_date'] = $minDateStr;
$meta['max_date'] = $maxDateStr;
}
return ['imported' => $imported, 'duplicates' => $duplicates, 'meta' => $meta];
}
private function detectCsvFormat(string $content): ?string
{
$head = mb_strtolower(substr($content, 0, 10000));
// Unicredit nuovo formato: Data contabile;...;Accrediti;Addebiti
if (str_contains($head, 'data contabile') && str_contains($head, 'accrediti') && str_contains($head, 'addebiti')) {
return 'unicredit_semicolon';
}
// Unicredit WRI: Data;Valuta;Descrizione;Euro;
if (str_contains($head, 'data;valuta;descrizione') && str_contains($head, 'euro')) {
return 'unicredit_wri';
}
// Intesa: header tipici
if (str_contains($head, 'data operazione') && str_contains($head, 'data valuta')) {
return 'intesa_csv';
}
// CEDHOUSE: header tipico "R;NR;Data;Tipo Riga;Descrizione;Entrate;Uscite"
if (str_contains($head, 'tipo riga') && str_contains($head, 'entrate') && str_contains($head, 'uscite')) {
return 'cedhouse_csv';
}
return null;
}
/**
* @return array{imported:int, duplicates:int}
*/
public function importUnicreditWri(
string $content,
int $stabileId,
?string $sourceFile = null,
?string $forcedIban = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->unicreditParser->parse($content);
$iban = $forcedIban ?: ($parsed['iban'] ?? null);
$contoId = null;
try {
if (
is_string($iban) && trim($iban) !== ''
&& DB::getSchemaBuilder()->hasTable('dati_bancari')
&& DB::getSchemaBuilder()->hasColumn('contabilita_movimenti_banca', 'conto_id')
) {
$contoId = DatiBancari::query()
->where('stabile_id', $stabileId)
->where('iban', trim($iban))
->orderBy('id')
->value('id');
$contoId = is_numeric($contoId) ? (int) $contoId : null;
}
} catch (\Throwable) {
$contoId = null;
}
$imported = 0;
$duplicates = 0;
DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, &$imported, &$duplicates) {
if ($replace && !empty($parsed['rows'])) {
$minDate = null;
$maxDate = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) {
continue;
}
$minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d;
$maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d;
}
if ($minDate && $maxDate) {
// Sicurezza: elimina solo movimenti importati e non riconciliati.
$del = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->whereNotNull('raw_line')
->whereNull('registrazione_id')
->whereDate('data', '>=', $minDate->toDateString())
->whereDate('data', '<=', $maxDate->toDateString());
if ($contoId !== null) {
$del->where('conto_id', $contoId);
} elseif (is_string($iban) && trim($iban) !== '') {
$del->where('iban', trim($iban));
}
$del->delete();
}
}
foreach ($parsed['rows'] as $row) {
$hashBase = implode('|', [
$iban ?? 'NOIBAN',
$row['data']->format('Y-m-d'),
($row['valuta']?->format('Y-m-d') ?? ''),
$row['descrizione'],
number_format((float) $row['importo'], 2, '.', ''),
$row['causale'] ?? '',
]);
$rowHash = hash('sha256', $hashBase);
$exists = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('row_hash', $rowHash)
->exists();
if ($exists) {
$duplicates++;
continue;
}
MovimentoBanca::query()->create([
'stabile_id' => $stabileId,
'conto_id' => $contoId,
'gestione_id' => $gestioneId,
'iban' => $iban,
'data' => $row['data'],
'valuta' => $row['valuta'],
'descrizione' => $row['descrizione'],
'importo' => $row['importo'],
'causale' => $row['causale'],
'source_file' => $sourceFile,
'raw_line' => $row['raw_line'],
'row_hash' => $rowHash,
]);
$imported++;
}
});
return ['imported' => $imported, 'duplicates' => $duplicates];
}
/**
* @return array{imported:int, duplicates:int}
*/
public function importIntesaCsv(
string $content,
int $stabileId,
?string $sourceFile = null,
?string $forcedIban = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->intesaParser->parse($content);
$iban = $forcedIban ?: ($parsed['iban'] ?? null);
$hasDescrizioneEstesa = false;
try {
$hasDescrizioneEstesa = DB::getSchemaBuilder()->hasColumn('contabilita_movimenti_banca', 'descrizione_estesa');
} catch (\Throwable) {
$hasDescrizioneEstesa = false;
}
$contoId = null;
try {
if (
is_string($iban) && trim($iban) !== ''
&& DB::getSchemaBuilder()->hasTable('dati_bancari')
&& DB::getSchemaBuilder()->hasColumn('contabilita_movimenti_banca', 'conto_id')
) {
$contoId = DatiBancari::query()
->where('stabile_id', $stabileId)
->where('iban', trim($iban))
->orderBy('id')
->value('id');
$contoId = is_numeric($contoId) ? (int) $contoId : null;
}
} catch (\Throwable) {
$contoId = null;
}
$imported = 0;
$duplicates = 0;
DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, $hasDescrizioneEstesa, &$imported, &$duplicates) {
if ($replace && ! empty($parsed['rows'])) {
$minDate = null;
$maxDate = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) {
continue;
}
$minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d;
$maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d;
}
if ($minDate && $maxDate) {
$del = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->whereNotNull('raw_line')
->whereNull('registrazione_id')
->whereDate('data', '>=', $minDate->toDateString())
->whereDate('data', '<=', $maxDate->toDateString());
if ($contoId !== null) {
$del->where('conto_id', $contoId);
} elseif (is_string($iban) && trim($iban) !== '') {
$del->where('iban', trim($iban));
}
$del->delete();
}
}
foreach ($parsed['rows'] as $row) {
$hashBase = implode('|', [
$iban ?? 'NOIBAN',
$row['data']->format('Y-m-d'),
($row['valuta']?->format('Y-m-d') ?? ''),
$row['descrizione'],
number_format((float) $row['importo'], 2, '.', ''),
$row['causale'] ?? '',
]);
$rowHash = hash('sha256', $hashBase);
$exists = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('row_hash', $rowHash)
->exists();
if ($exists) {
$duplicates++;
continue;
}
$payload = [
'stabile_id' => $stabileId,
'conto_id' => $contoId,
'gestione_id' => $gestioneId,
'iban' => $iban,
'data' => $row['data'],
'valuta' => $row['valuta'],
'descrizione' => $row['descrizione'],
'importo' => $row['importo'],
'causale' => $row['causale'],
'source_file' => $sourceFile,
'raw_line' => $row['raw_line'],
'row_hash' => $rowHash,
];
if ($hasDescrizioneEstesa) {
$payload['descrizione_estesa'] = isset($row['descrizione_estesa']) && is_string($row['descrizione_estesa'])
? trim($row['descrizione_estesa'])
: null;
}
$payload = $this->appendMatchData($payload, $row['descrizione_estesa'] ?? null, $row['raw_line'] ?? null, $row['descrizione'] ?? null);
MovimentoBanca::query()->create($payload);
$imported++;
}
});
return ['imported' => $imported, 'duplicates' => $duplicates];
}
/**
* Import movimenti da XLSX, legato a un conto specifico.
*
* @return array{imported:int, duplicates:int, meta?:array<string,mixed>}
*/
public function importExcelXlsxForConto(
string $filePath,
int $stabileId,
int $contoId,
?string $sourceFile = null,
?int $gestioneId = null,
bool $replace = false,
): array {
$parsed = $this->excelParser->parseXlsx($filePath);
$iban = null;
try {
$iban = DatiBancari::query()->where('id', $contoId)->value('iban');
$iban = is_string($iban) && trim($iban) !== '' ? trim($iban) : null;
} catch (\Throwable) {
$iban = null;
}
$imported = 0;
$duplicates = 0;
$minDateStr = null;
$maxDateStr = null;
if (! empty($parsed['rows'])) {
$min = null;
$max = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) continue;
$min = $min ? ($d->lt($min) ? $d : $min) : $d;
$max = $max ? ($d->gt($max) ? $d : $max) : $d;
}
$minDateStr = $min ? $min->toDateString() : null;
$maxDateStr = $max ? $max->toDateString() : null;
}
DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, &$imported, &$duplicates) {
if ($replace && ! empty($parsed['rows'])) {
$minDate = null;
$maxDate = null;
foreach ($parsed['rows'] as $r) {
$d = $r['data'] ?? null;
if (! $d) {
continue;
}
$minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d;
$maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d;
}
if ($minDate && $maxDate) {
MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('conto_id', $contoId)
->whereNotNull('raw_line')
->whereNull('registrazione_id')
->whereDate('data', '>=', $minDate->toDateString())
->whereDate('data', '<=', $maxDate->toDateString())
->delete();
}
}
foreach ($parsed['rows'] as $row) {
$hashBase = implode('|', [
$iban ?? ('CONTO:' . $contoId),
$row['data']->format('Y-m-d'),
($row['valuta']?->format('Y-m-d') ?? ''),
$row['descrizione'],
number_format((float) $row['importo'], 2, '.', ''),
$row['causale'] ?? '',
]);
$rowHash = hash('sha256', $hashBase);
$exists = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('row_hash', $rowHash)
->exists();
if ($exists) {
$duplicates++;
continue;
}
$payload = [
'stabile_id' => $stabileId,
'conto_id' => $contoId,
'gestione_id' => $gestioneId,
'iban' => $iban,
'data' => $row['data'],
'valuta' => $row['valuta'],
'descrizione' => $row['descrizione'],
'importo' => $row['importo'],
'causale' => $row['causale'],
'source_file' => $sourceFile,
'raw_line' => $row['raw_line'],
'row_hash' => $rowHash,
];
if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) {
$descEst = isset($row['descrizione_estesa']) && is_string($row['descrizione_estesa'])
? trim($row['descrizione_estesa'])
: null;
if (($descEst ?? '') === '' && is_string($row['raw_line'] ?? null) && str_contains((string) $row['raw_line'], '|')) {
$parts = explode('|', (string) $row['raw_line'], 2);
$fallback = trim((string) ($parts[1] ?? ''));
$descEst = $fallback !== '' ? $fallback : null;
}
$payload['descrizione_estesa'] = $descEst !== '' ? $descEst : null;
}
$payload = $this->appendMatchData($payload, $row['descrizione_estesa'] ?? null, $row['raw_line'] ?? null, $row['descrizione'] ?? null);
MovimentoBanca::query()->create($payload);
$imported++;
}
});
$meta = [];
if (isset($parsed['meta']) && is_array($parsed['meta'])) {
$meta = $parsed['meta'];
}
if ($minDateStr && $maxDateStr) {
$meta['min_date'] = $minDateStr;
$meta['max_date'] = $maxDateStr;
}
return ['imported' => $imported, 'duplicates' => $duplicates, 'meta' => $meta];
}
private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?string $fallbackRaw, ?string $fallbackDescrizione): array
{
$hasMatch = Schema::hasColumn('contabilita_movimenti_banca', 'match_data');
$hasConfirm = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare');
$hasRubricaMitt = Schema::hasColumn('contabilita_movimenti_banca', 'rubrica_mittente_id');
$hasRubricaBen = Schema::hasColumn('contabilita_movimenti_banca', 'rubrica_beneficiario_id');
$hasFornitore = Schema::hasColumn('contabilita_movimenti_banca', 'fornitore_id');
if (! $hasMatch && ! $hasConfirm && ! $hasRubricaMitt && ! $hasRubricaBen && ! $hasFornitore) {
return $payload;
}
$text = is_string($descrizioneEstesa) && trim($descrizioneEstesa) !== ''
? trim($descrizioneEstesa)
: (is_string($fallbackRaw) && trim($fallbackRaw) !== ''
? trim($fallbackRaw)
: (is_string($fallbackDescrizione) ? trim($fallbackDescrizione) : ''));
if ($text === '') {
return $payload;
}
$match = $this->parseDescrizioneEstesa($text);
if ($hasMatch && ! empty($match)) {
$payload['match_data'] = $match;
}
$mittente = $match['mittente'] ?? null;
$beneficiario = $match['beneficiario'] ?? null;
$rubricaMittenteId = null;
$rubricaBeneficiarioId = null;
$fornitoreId = null;
if ($mittente) {
$rubricaMittenteId = $this->matchRubricaId($mittente);
$fornitoreId = $this->matchFornitoreId($mittente);
}
if ($beneficiario) {
$rubricaBeneficiarioId = $this->matchRubricaId($beneficiario);
}
if ($hasRubricaMitt && $rubricaMittenteId) {
$payload['rubrica_mittente_id'] = $rubricaMittenteId;
}
if ($hasRubricaBen && $rubricaBeneficiarioId) {
$payload['rubrica_beneficiario_id'] = $rubricaBeneficiarioId;
}
if ($hasFornitore && $fornitoreId) {
$payload['fornitore_id'] = $fornitoreId;
}
if ($hasConfirm) {
$needs = ! empty($match) && ! $rubricaMittenteId && ! $fornitoreId;
$payload['da_confermare'] = $needs;
}
return $payload;
}
private function parseDescrizioneEstesa(string $text): array
{
$res = [];
$text = trim(preg_replace('/\s+/', ' ', $text));
if ($text === '') return $res;
if (preg_match('/COD\.\s*DISP\.:\s*([A-Z0-9]+)/i', $text, $m)) {
$res['cod_disp'] = trim($m[1]);
}
if (preg_match('/RIF\.?\s*([^\n]+?)(?=\s+MITT\.|\s+BENEF\.|\s+BIC\.|$)/i', $text, $m)) {
$res['riferimento'] = trim($m[1]);
}
if (preg_match('/MITT\.:\s*([^\n]+?)(?=\s+BENEF\.|\s+BIC\.|$)/i', $text, $m)) {
$res['mittente'] = trim($m[1]);
}
if (preg_match('/BENEF\.:\s*([^\n]+?)(?=\s+BIC\.|$)/i', $text, $m)) {
$res['beneficiario'] = trim($m[1]);
}
if (preg_match('/BONIFICO\s+A\s+VOSTRO\s+FAVORE\s+DISPOSTO\s+DA\s*:\s*MITT\.:/i', $text)) {
$res['descrizione_base'] = 'Bonifico a Vostro favore disposto da:';
}
if (preg_match('/\bBONINEURVERU\b/i', $text)) {
if (preg_match('/\b(\d{10,})\b/', $text, $mDisp)) {
$res['cod_disp'] = trim($mDisp[1]);
}
if (preg_match('/\b(\d{8}-[A-Za-z0-9]+)\b/', $text, $mRef)) {
$res['cod_ref'] = trim($mRef[1]);
}
}
if (preg_match('/\bCOSPERBON\b/i', $text)) {
if (preg_match('/\b(\d{8}-[A-Za-z0-9]+)\b/', $text, $mRef)) {
$res['cod_ref'] = trim($mRef[1]);
}
if (preg_match('/\b(\d{10,})\b/', $text, $mDisp)) {
$res['cod_disp'] = trim($mDisp[1]);
}
}
if (preg_match('/\bCASH\s+NOT\s+PROVIDED\b/i', $text)) {
$res['cash'] = 'NOT PROVIDED';
} elseif (preg_match('/\bCASH\s+([0-9]{3,})\b/i', $text, $m)) {
$res['cash'] = trim($m[1]);
}
if (preg_match('/\b([A-Z])\s*0?(\d{1,3})\b/', $text, $m)) {
$res['unita'] = strtoupper($m[1]) . str_pad($m[2], 2, '0', STR_PAD_LEFT);
}
if (preg_match('/\bordinaria\s+([^\n]+?)(?=\s+[A-Z]\s*\d{1,3}|\s+Bonifico|$)/i', $text, $m)) {
$res['periodo_rate'] = trim($m[1]);
}
if (empty($res) && str_contains($text, 'Bonifico a Vostro favore')) {
$res['descrizione_raw'] = $text;
}
return $res;
}
private function matchRubricaId(string $name): ?int
{
$term = trim($name);
if ($term === '') return null;
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$rows = RubricaUniversale::query()
->where(function ($q) use ($like) {
$q->where('ragione_sociale', 'like', $like)
->orWhereRaw("CONCAT(nome,' ',cognome) LIKE ?", [$like]);
})
->limit(2)
->get(['id']);
return $rows->count() === 1 ? (int) $rows->first()->id : null;
}
private function matchFornitoreId(string $name): ?int
{
$term = trim($name);
if ($term === '') return null;
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$rows = Fornitore::query()
->where(function ($q) use ($like) {
$q->where('ragione_sociale', 'like', $like)
->orWhereRaw("CONCAT(nome,' ',cognome) LIKE ?", [$like]);
})
->limit(2)
->get(['id']);
return $rows->count() === 1 ? (int) $rows->first()->id : null;
}
}