556 lines
20 KiB
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;
|
|
}
|
|
}
|