netgescon-day0/app/Services/Import/FinancialFlowImportService.php

556 lines
20 KiB
PHP

<?php
namespace App\Services\Import;
use App\Models\ContoBancario;
use App\Models\Incasso;
use App\Models\MovimentoBancario;
use App\Models\Condominio;
use App\Models\Condomino;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
/**
* FINANCIAL FLOW IMPORT SERVICE
*
* Gestisce l'import completo del flusso finanziario da Gescon:
* 1. Conti bancari (da Anagr_casse)
* 2. Incassi (da incassi)
* 3. Movimenti bancari (da file Unicredit WRI)
* 4. Riconciliazione automatica
*/
class FinancialFlowImportService
{
private string $tenantId;
private array $stats = [];
public function __construct(string $tenantId)
{
$this->tenantId = $tenantId;
$this->stats = [
'conti_bancari' => ['imported' => 0, 'errors' => 0],
'incassi' => ['imported' => 0, 'errors' => 0],
'movimenti_bancari' => ['imported' => 0, 'errors' => 0],
'riconciliazioni' => ['automatiche' => 0, 'manuali' => 0, 'sospese' => 0]
];
}
/**
* Import completo flusso finanziario
*/
public function importCompleteFinancialFlow(array $filePaths): array
{
Log::info("Starting complete financial flow import for tenant: {$this->tenantId}");
DB::beginTransaction();
try {
// 1. Import conti bancari
if (isset($filePaths['anagr_casse'])) {
$this->importContiBancari($filePaths['anagr_casse']);
}
// 2. Import incassi
if (isset($filePaths['incassi'])) {
$this->importIncassi($filePaths['incassi']);
}
// 3. Import movimenti bancari
if (isset($filePaths['unicredit_wri'])) {
$this->importMovimentiBancariUnicredit($filePaths['unicredit_wri']);
}
// 4. Riconciliazione automatica
$this->executeAutoReconciliation();
DB::commit();
Log::info("Financial flow import completed successfully", $this->stats);
return [
'success' => true,
'message' => 'Import flusso finanziario completato',
'stats' => $this->stats
];
} catch (\Exception $e) {
DB::rollBack();
Log::error("Financial flow import failed: " . $e->getMessage(), [
'tenant_id' => $this->tenantId,
'trace' => $e->getTraceAsString()
]);
return [
'success' => false,
'message' => 'Errore durante import: ' . $e->getMessage(),
'stats' => $this->stats
];
}
}
/**
* Import conti bancari da Anagr_casse.csv
*/
public function importContiBancari(string $filePath): void
{
Log::info("Importing conti bancari from: {$filePath}");
$csvData = $this->readCsvFile($filePath);
foreach ($csvData as $row) {
try {
$contoBancario = ContoBancario::updateOrCreate([
'tenant_id' => $this->tenantId,
'codice' => $row['Codice']
], [
'id_cassa_gescon' => $row['Id_cassa'] ?? null,
'descrizione' => $row['Descrizione'] ?? '',
'saldo_iniziale' => (float)($row['Saldo_iniziale_EURO'] ?? 0),
'saldo_corrente' => (float)($row['Saldo_iniziale_EURO'] ?? 0),
'tipo_conto' => $this->determinaTipoConto($row['Descrizione'] ?? ''),
'banca' => $this->estraiBanca($row['Descrizione'] ?? ''),
'numero_conto' => $this->estraiNumeroConto($row['Descrizione'] ?? ''),
'attivo' => true,
'formato_file' => $this->determinaFormatoFile($row['Descrizione'] ?? ''),
'metadati_gescon' => json_encode($row)
]);
$this->stats['conti_bancari']['imported']++;
} catch (\Exception $e) {
Log::error("Error importing conto bancario", [
'row' => $row,
'error' => $e->getMessage()
]);
$this->stats['conti_bancari']['errors']++;
}
}
}
/**
* Import incassi da incassi.csv
*/
public function importIncassi(string $filePath): void
{
Log::info("Importing incassi from: {$filePath}");
$csvData = $this->readCsvFile($filePath);
foreach ($csvData as $row) {
try {
// Skip se importo = 0
if ((float)($row['importo_pagato_euro'] ?? 0) == 0) {
continue;
}
// Trova condominio
$condominio = $this->findCondominio($row['cod_cond'] ?? '');
if (!$condominio) {
Log::warning("Condominio not found for incasso", ['cod_cond' => $row['cod_cond'] ?? '']);
continue;
}
// Trova conto bancario
$contoBancario = ContoBancario::where('tenant_id', $this->tenantId)
->where('codice', $row['cod_cassa'] ?? '')
->first();
if (!$contoBancario) {
Log::warning("Conto bancario not found for incasso", ['cod_cassa' => $row['cod_cassa'] ?? '']);
continue;
}
// Trova condomino (se possibile)
$condomino = $this->findCondomino($row['cod_cond'] ?? '', $row['cond_inquil'] ?? '');
$incasso = Incasso::create([
'tenant_id' => $this->tenantId,
'condominio_id' => $condominio->id,
'conto_bancario_id' => $contoBancario->id,
'condomino_id' => $condomino?->id,
'id_incasso_gescon' => $row['ID_incasso'] ?? null,
'cod_cond_gescon' => $row['cod_cond'] ?? '',
'cond_inquil' => $row['cond_inquil'] ?? '',
'n_riferimento' => $row['n_riferimento'] ?? '',
'anno_rif' => (int)($row['anno_rif'] ?? 0),
'da_ricev_diretto' => $row['da_ricev_diretto'] ?? '',
'n_ricevuta' => (int)($row['n_ricevuta'] ?? 0),
'posiz_riga' => (int)($row['posiz_riga'] ?? 0),
'anno_ricev' => $row['anno_ricev'] ?? '',
'n_mese' => $row['n_mese'] ?? '',
'o_r_s' => $row['o_r_s'] ?? '',
'importo_pagato' => (float)($row['importo_pagato'] ?? 0),
'importo_pagato_euro' => (float)($row['importo_pagato_euro'] ?? 0),
'd_p_e' => $row['d_p_e'] ?? '',
'dt_empag' => $this->parseGesconDate($row['dt_empag'] ?? ''),
'data_pagamento' => $this->parseGesconDate($row['dt_empag'] ?? ''),
'descrizione' => $row['descrizione'] ?? '',
'cod_cassa_gescon' => $row['cod_cassa'] ?? '',
'totale' => (int)($row['Totale'] ?? 0),
'str_orig' => (int)($row['str_orig'] ?? 0),
'linea_sep_stp' => $row['linea_sep_stp'] ?? '',
'proviene_ors' => $row['Proviene_ORS'] ?? '',
'proviene_n_stra' => (int)($row['Proviene_n_stra'] ?? 0),
'proviene_eserc' => (int)($row['Proviene_Eserc'] ?? 0),
'tipo_incasso' => $this->determinaTipoIncasso($row['o_r_s'] ?? ''),
'stato_riconciliazione' => 'sospesa',
'metadati_gescon' => json_encode($row)
]);
$this->stats['incassi']['imported']++;
} catch (\Exception $e) {
Log::error("Error importing incasso", [
'row' => $row,
'error' => $e->getMessage()
]);
$this->stats['incassi']['errors']++;
}
}
}
/**
* Import movimenti bancari da file Unicredit WRI
*/
public function importMovimentiBancariUnicredit(string $filePath): void
{
Log::info("Importing movimenti bancari from Unicredit WRI: {$filePath}");
// Trova conto bancario Unicredit
$contoBancario = ContoBancario::where('tenant_id', $this->tenantId)
->where('banca', 'LIKE', '%UNICREDIT%')
->first();
if (!$contoBancario) {
throw new \Exception("Conto bancario Unicredit non trovato");
}
$fileContent = file_get_contents($filePath);
$lines = explode("\n", $fileContent);
// Trova l'inizio dei dati CSV
$csvStartLine = null;
foreach ($lines as $index => $line) {
if (strpos($line, 'Data;Valuta;Descrizione;Euro;Caus.') !== false) {
$csvStartLine = $index + 1;
break;
}
}
if (!$csvStartLine) {
throw new \Exception("Formato file Unicredit WRI non riconosciuto");
}
// Processa i movimenti
for ($i = $csvStartLine; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if (empty($line) || !preg_match('/^\d{2}\/\d{2}\/\d{4}/', $line)) {
continue;
}
try {
$parts = explode(';', $line);
if (count($parts) < 5) continue;
$dataOp = Carbon::createFromFormat('d/m/Y', $parts[0]);
$dataValuta = Carbon::createFromFormat('d/m/Y', $parts[1]);
$descrizione = $parts[2];
$importo = (float)str_replace(',', '.', $parts[3]);
$causale = $parts[4];
// Genera hash per evitare duplicati
$hash = hash('sha256', $this->tenantId . $line);
// Pattern matching
$patternData = $this->executePatternMatching($descrizione);
$movimento = MovimentoBancario::updateOrCreate([
'tenant_id' => $this->tenantId,
'hash_movimento' => $hash
], [
'conto_bancario_id' => $contoBancario->id,
'data_operazione' => $dataOp,
'data_valuta' => $dataValuta,
'importo' => abs($importo),
'tipo_movimento' => $importo > 0 ? 'entrata' : 'uscita',
'descrizione_completa' => $descrizione,
'causale_bancaria' => $causale,
'causale_descrizione' => $this->getCausaleDescrizione($causale),
'controparte_nome' => $patternData['nome_estratto'],
'pattern_scala' => $patternData['scala'],
'pattern_interno' => $patternData['interno'],
'pattern_nome_estratto' => $patternData['nome_estratto'],
'pattern_keywords' => json_encode($patternData['keywords']),
'file_origine' => basename($filePath),
'formato_file' => 'WRI',
'riga_file' => $i + 1,
'unicredit_raw_line' => $line,
'stato_riconciliazione' => 'sospesa',
'metadati_import' => json_encode([
'import_date' => now(),
'source_file' => $filePath,
'line_number' => $i + 1
])
]);
$this->stats['movimenti_bancari']['imported']++;
} catch (\Exception $e) {
Log::error("Error importing movimento bancario", [
'line' => $line,
'line_number' => $i + 1,
'error' => $e->getMessage()
]);
$this->stats['movimenti_bancari']['errors']++;
}
}
}
/**
* Esegue riconciliazione automatica
*/
public function executeAutoReconciliation(): void
{
Log::info("Starting auto-reconciliation");
// Trova movimenti in entrata non riconciliati
$movimentiEntrata = MovimentoBancario::where('tenant_id', $this->tenantId)
->where('tipo_movimento', 'entrata')
->where('stato_riconciliazione', 'sospesa')
->get();
foreach ($movimentiEntrata as $movimento) {
$this->tentaRiconciliazioneMovimento($movimento);
}
}
/**
* Tenta riconciliazione singolo movimento
*/
private function tentaRiconciliazioneMovimento(MovimentoBancario $movimento): void
{
// Cerca incassi candidati per matching
$candidati = Incasso::where('tenant_id', $this->tenantId)
->where('stato_riconciliazione', 'sospesa')
->where('conto_bancario_id', $movimento->conto_bancario_id)
->get();
$bestMatch = null;
$bestScore = 0;
foreach ($candidati as $incasso) {
$score = $this->calcolaScoreMatching($movimento, $incasso);
if ($score > $bestScore && $score >= 70) { // Soglia minima 70%
$bestMatch = $incasso;
$bestScore = $score;
}
}
if ($bestMatch) {
// Riconciliazione automatica
$movimento->update([
'incasso_id' => $bestMatch->id,
'stato_riconciliazione' => 'automatica',
'confidenza_match' => $bestScore
]);
$bestMatch->update([
'movimento_bancario_id' => $movimento->id,
'stato_riconciliazione' => 'automatica',
'confidenza_match' => $bestScore,
'scostamento_importo' => abs($movimento->importo - $bestMatch->importo_pagato_euro),
'giorni_scostamento' => abs($movimento->data_operazione->diffInDays($bestMatch->data_pagamento))
]);
$this->stats['riconciliazioni']['automatiche']++;
} else {
$this->stats['riconciliazioni']['sospese']++;
}
}
/**
* Calcola score matching movimento-incasso
*/
private function calcolaScoreMatching(MovimentoBancario $movimento, Incasso $incasso): float
{
$score = 0;
// 1. Matching importo (40 punti max)
$diffImporto = abs($movimento->importo - $incasso->importo_pagato_euro);
if ($diffImporto == 0) {
$score += 40;
} elseif ($diffImporto <= 1) {
$score += 35;
} elseif ($diffImporto <= 5) {
$score += 25;
} elseif ($diffImporto <= 10) {
$score += 15;
}
// 2. Matching temporale (30 punti max)
if ($movimento->data_operazione && $incasso->data_pagamento) {
$diffGiorni = abs($movimento->data_operazione->diffInDays($incasso->data_pagamento));
if ($diffGiorni == 0) {
$score += 30;
} elseif ($diffGiorni <= 3) {
$score += 25;
} elseif ($diffGiorni <= 7) {
$score += 20;
} elseif ($diffGiorni <= 30) {
$score += 10;
}
}
// 3. Matching nome/UI (30 punti max)
if ($movimento->pattern_scala && $movimento->pattern_interno) {
// Cerca condomino con stessa scala/interno
$condomino = Condomino::where('tenant_id', $this->tenantId)
->where('scala', $movimento->pattern_scala)
->where('interno', $movimento->pattern_interno)
->first();
if ($condomino && $incasso->condomino_id == $condomino->id) {
$score += 30;
}
}
return min($score, 100); // Max 100%
}
/**
* Pattern matching per movimenti bancari
*/
private function executePatternMatching(string $descrizione): array
{
$result = [
'scala' => null,
'interno' => null,
'nome_estratto' => null,
'keywords' => []
];
// Pattern per Scala-Interno: ([A-D])\s*-?\s*(\d+)
if (preg_match('/([A-D])\s*-?\s*(\d+)/', $descrizione, $matches)) {
$result['scala'] = $matches[1];
$result['interno'] = $matches[2];
$result['keywords'][] = 'scala_interno';
}
// Pattern per nome: BONIFICO SEPA DA\s+([A-Z\s]+)\s+PER
if (preg_match('/BONIFICO SEPA DA\s+([A-Z\s]+)\s+PER/', $descrizione, $matches)) {
$result['nome_estratto'] = trim($matches[1]);
$result['keywords'][] = 'bonifico_sepa';
}
// Pattern per condominio
if (preg_match('/(RATA|RATE|COND|CONDOMINIO)/i', $descrizione)) {
$result['keywords'][] = 'rata_condominiale';
}
return $result;
}
// Utility methods
private function readCsvFile(string $filePath): array
{
$data = [];
if (($handle = fopen($filePath, "r")) !== FALSE) {
$headers = fgetcsv($handle);
while (($row = fgetcsv($handle)) !== FALSE) {
$data[] = array_combine($headers, $row);
}
fclose($handle);
}
return $data;
}
private function determinaTipoConto(string $descrizione): string
{
if (stripos($descrizione, 'UNICREDIT') !== false || stripos($descrizione, 'CC') !== false) {
return 'bancario';
} elseif (stripos($descrizione, 'CASSA') !== false || stripos($descrizione, 'CONTANTI') !== false) {
return 'contanti';
}
return 'bancario';
}
private function estraiBanca(string $descrizione): ?string
{
if (stripos($descrizione, 'UNICREDIT') !== false) {
return 'UNICREDIT';
}
return null;
}
private function estraiNumeroConto(string $descrizione): ?string
{
if (preg_match('/N\.(\d+)/', $descrizione, $matches)) {
return $matches[1];
}
return null;
}
private function determinaFormatoFile(string $descrizione): ?string
{
if (stripos($descrizione, 'UNICREDIT') !== false) {
return 'WRI';
}
return null;
}
private function parseGesconDate(?string $date): ?Carbon
{
if (!$date) return null;
try {
// Formato: "07/15/14 00:00:00" -> MM/dd/yy
return Carbon::createFromFormat('m/d/y H:i:s', $date);
} catch (\Exception $e) {
return null;
}
}
private function determinaTipoIncasso(string $ors): string
{
switch (strtoupper($ors)) {
case 'O':
return 'rata_ordinaria';
case 'R':
return 'riscaldamento';
case 'S':
return 'rata_straordinaria';
default:
return 'altro';
}
}
private function getCausaleDescrizione(string $causale): string
{
$causali = [
'048' => 'BONIFICO ENTRATA',
'219' => 'IMPOSTA BOLLO',
'008' => 'ADDEBITO DIRETTO',
'043' => 'ACCREDITO STIPENDIO',
// Aggiungi altre causali note
];
return $causali[$causale] ?? 'CAUSALE ' . $causale;
}
private function findCondominio(string $codCond): ?Condominio
{
return Condominio::where('tenant_id', $this->tenantId)
->where('cod_condominio_gescon', $codCond)
->first();
}
private function findCondomino(string $codCond, string $condInquil): ?Condomino
{
return Condomino::where('tenant_id', $this->tenantId)
->where('cod_condominio_gescon', $codCond)
->where('inquilino_proprietario', $condInquil)
->first();
}
public function getStats(): array
{
return $this->stats;
}
}