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

434 lines
18 KiB
PHP

<?php
namespace App\Services\Contabilita;
use App\Models\DatiBancari;
use App\Models\Stabile;
use App\Models\UnitaImmobiliare;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Services\Gescon\GesconEstrattoContoService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class GermanicoBancaReconciliationService
{
public const STABILE_ID = 19;
public const COD_STABILE = '0016';
public const IBAN_GERMANICO = 'IT48W0312403203000000233378';
public const DEFAULT_BANCA_DIR = '/home/michele/netgescon-day0-backup/Miki-Bug-workspace/Banca/germanico 96';
public function __construct(
protected ExcelMovimentiParser $parser,
protected GesconEstrattoContoService $gesconService
) {}
/**
* Assicura l'esistenza del conto bancario CCB per lo Stabile Germanico 96.
*/
public function ensureContoBancario(int $stabileId = self::STABILE_ID): DatiBancari
{
$conto = DatiBancari::query()
->where('stabile_id', $stabileId)
->where(function ($q) {
$q->where('iban', self::IBAN_GERMANICO)
->orWhere('legacy_cod_cassa', 'CCB')
->orWhere('numero_conto', '233378');
})
->first();
if ($conto) {
return $conto;
}
return DatiBancari::create([
'stabile_id' => $stabileId,
'tipo_conto' => 'corrente',
'denominazione_banca' => 'Banca del Fucino',
'numero_conto' => '233378',
'legacy_cod_cassa' => 'CCB',
'iban' => self::IBAN_GERMANICO,
'abi' => '03124',
'cab' => '03203',
'cin' => 'W',
'intestazione_conto' => 'CONDOMINIO VIA GERMANICO 96',
'data_saldo_iniziale' => '2022-10-01',
'saldo_iniziale' => 3433.49,
'valuta' => 'EUR',
'stato_conto' => 'attivo',
'is_nostro_conto' => true,
'note' => '[GESCON Germanico 96 CCB]',
]);
}
/**
* Esegue sincronizzazione completa: importazione dai file .xls e riconciliazione contabile.
*/
public function syncAndReconcileAll(?string $dir = null, int $stabileId = self::STABILE_ID): array
{
$importRes = $this->importFromFiles($dir, $stabileId);
$recRes = $this->reconcile($stabileId);
return array_merge($importRes, $recRes);
}
/**
* Importa tutti i file .xls della banca nella tabella contabilita_movimenti_banca.
*
* @return array{total_files: int, raw_rows: int, imported: int, duplicates: int}
*/
public function importFromFiles(?string $dir = null, int $stabileId = self::STABILE_ID): array
{
$dir = $dir ?: self::DEFAULT_BANCA_DIR;
if (!is_dir($dir)) {
return ['total_files' => 0, 'raw_rows' => 0, 'imported' => 0, 'duplicates' => 0, 'error' => "Directory {$dir} non trovata"];
}
$conto = $this->ensureContoBancario($stabileId);
$files = glob($dir . '/*.xls');
sort($files);
$totalFiles = count($files);
$rawRows = 0;
$imported = 0;
$duplicates = 0;
foreach ($files as $filePath) {
$fileName = basename($filePath);
try {
$parsed = $this->parser->parseXlsx($filePath);
} catch (\Throwable $e) {
Log::warning("Errore nel parsing del file bancario {$fileName}: " . $e->getMessage());
continue;
}
foreach ($parsed['rows'] as $r) {
$rawRows++;
$data = $r['data'] instanceof Carbon ? $r['data'] : Carbon::parse($r['data']);
$valuta = !empty($r['valuta']) ? ($r['valuta'] instanceof Carbon ? $r['valuta'] : Carbon::parse($r['valuta'])) : null;
$importo = round((float) $r['importo'], 2);
$descrizione = trim((string) ($r['descrizione'] ?? ''));
// Calcolo hash univoco di riga
$hashBase = implode('|', [
$stabileId,
$data->format('Y-m-d'),
$valuta ? $valuta->format('Y-m-d') : '',
number_format($importo, 2, '.', ''),
$descrizione,
]);
$rowHash = hash('sha256', $hashBase);
$exists = MovimentoBanca::query()
->where('stabile_id', $stabileId)
->where('row_hash', $rowHash)
->exists();
if ($exists) {
$duplicates++;
continue;
}
// Estrazione dati strutturati dalla descrizione bancaria
$mittente = null;
$beneficiario = null;
$cro = null;
$note = null;
$tipoOperazione = 'altro';
if ($importo > 0) {
$tipoOperazione = 'incasso';
if (preg_match('/BONIFICO A VOSTRO FAVORE\s+([A-Z0-9\.\s\,\&\-\'\/\(\)]+?)\s+(?:Data|Coord|Banca|Cro|Note|Id)/i', $descrizione, $m)) {
$mittente = trim($m[1]);
} elseif (preg_match('/BONIFICO.*?FAVORE\s+([A-Z0-9\.\s\,\&\-\'\/\(\)]+)/i', $descrizione, $m)) {
$mittente = trim($m[1]);
} elseif (preg_match('/VERSAMENTO CONTANTE\s+([A-Z0-9\.\s\,\&\-\'\/\(\)]+)/i', $descrizione, $m)) {
$mittente = trim($m[1]);
}
} else {
$tipoOperazione = 'spesa';
if (preg_match('/ADDEBITO BONIFICO DA HOME BANKING\s+([A-Z0-9\.\s\,\&\-\'\/\(\)]+?)\s+(?:Bonifico|Data|Coord|Banca|Cro|Note|Id)/i', $descrizione, $m)) {
$beneficiario = trim($m[1]);
} elseif (preg_match('/ADDEBITO DIRETTO CORE RCUR.*?Prg\.Car\.:\s*\d+\s+([A-Z0-9\.\s\,\&\-\'\/\(\)]+?)\s+-\s+/i', $descrizione, $m)) {
$beneficiario = trim($m[1]);
} elseif (preg_match('/PAGAMENTO POS.*?C\/O\s+([A-Z0-9\.\s\,\&\-\'\/\(\)]+)/i', $descrizione, $m)) {
$beneficiario = trim($m[1]);
} elseif (str_contains($descrizione, 'COMM.')) {
$beneficiario = 'Banca del Fucino (Commissioni)';
}
}
if (preg_match('/Cro:\s*([A-Za-z0-9]+)/i', $descrizione, $m)) {
$cro = trim($m[1]);
}
if (preg_match('/Note:\s*(.+?)(?:Id\.Operazione|$)/i', $descrizione, $m)) {
$note = trim($m[1]);
}
MovimentoBanca::create([
'stabile_id' => $stabileId,
'conto_id' => $conto->id,
'iban' => self::IBAN_GERMANICO,
'data' => $data,
'valuta' => $valuta,
'descrizione' => $descrizione,
'descrizione_estesa' => $note ?: $descrizione,
'importo' => $importo,
'causale' => $r['causale'] ?? null,
'tipo_operazione' => $tipoOperazione,
'mittente' => $mittente,
'beneficiario' => $beneficiario,
'rif_disposizione' => $cro,
'banca_nome' => 'Banca del Fucino',
'source_file' => $fileName,
'raw_line' => $r['raw_line'] ?? null,
'row_hash' => $rowHash,
'stato_riconciliazione' => 'da_riconciliare',
]);
$imported++;
}
}
return [
'total_files' => $totalFiles,
'raw_rows' => $rawRows,
'imported' => $imported,
'duplicates' => $duplicates,
];
}
/**
* Riconcilia tutti i movimenti bancari con gli Incassi Gescon (Inc_da_ec e incassi)
* e con le Spese (Operazioni).
*
* @return array{total_movimenti: int, riconciliati_incassi: int, riconciliati_spese: int, totale_riconciliati: int, percentuale: float}
*/
public function reconcile(int $stabileId = self::STABILE_ID): array
{
$genMdb = "/mnt/gescon-archives/gescon/" . self::COD_STABILE . "/generale_stabile.mdb";
$incDaEc = $this->gesconService->runMdbExport($genMdb, 'Inc_da_ec');
// Mappa delle unità per scala e interno e legacy_cond_id
$unitaList = UnitaImmobiliare::query()->where('stabile_id', $stabileId)->get();
$unitaByScInt = [];
$unitaByLegacyId = [];
foreach ($unitaList as $u) {
$key = strtoupper(trim((string)$u->scala)) . '|' . trim((string)$u->interno);
$unitaByScInt[$key] = $u;
if (!empty($u->legacy_cond_id)) {
$unitaByLegacyId[trim((string)$u->legacy_cond_id)] = $u;
}
}
// Movimenti bancari dello stabile
$movimenti = MovimentoBanca::query()->where('stabile_id', $stabileId)->get();
$totalMovimenti = $movimenti->count();
$riconciliatiIncassi = 0;
$riconciliatiSpese = 0;
// Indicizzazione Inc_da_ec per importo
$ecByAmount = [];
foreach ($incDaEc as $ec) {
$amt = number_format(round((float)($ec['Importo'] ?? 0), 2), 2, '.', '');
$ecByAmount[$amt][] = $ec;
}
// Spese consolidate Gescon dagli archivi annuali (0914, 0913, 0912, 0909)
$speseArchivio = [];
foreach (['0914', '0913', '0912', '0909', '0005', '0004'] as $yrDir) {
$singoloMdb = "/mnt/gescon-archives/gescon/" . self::COD_STABILE . "/{$yrDir}/singolo_anno.mdb";
if (!file_exists($singoloMdb)) continue;
$ops = $this->gesconService->runMdbExport($singoloMdb, 'Operazioni');
foreach ($ops as $op) {
$amt = round((float)($op['importo_euro'] ?? 0), 2);
if ($amt > 0) {
$dt = !empty($op['dt_spe']) ? date('Y-m-d', strtotime($op['dt_spe'])) : null;
$speseArchivio[] = [
'anno_dir' => $yrDir,
'id_operaz' => $op['id_operaz'] ?? null,
'data' => $dt,
'importo' => $amt,
'beneficiario' => trim((string)($op['benef'] ?? '')),
'cod_forn' => $op['cod_for'] ?? null,
'num_fat' => $op['num_fat'] ?? null,
];
}
}
}
foreach ($movimenti as $mov) {
$importo = (float) $mov->importo;
$dataMov = $mov->data ? $mov->data->format('Y-m-d') : null;
$desc = strtoupper($mov->descrizione);
// 1. Riconciliazione INCASSI (importo > 0)
if ($importo > 0) {
$amtKey = number_format($importo, 2, '.', '');
$candidates = $ecByAmount[$amtKey] ?? [];
$bestMatch = null;
foreach ($candidates as $ec) {
$ecDateRaw = $ec['Data_pag'] ?? '';
$ecDate = $ecDateRaw ? date('Y-m-d', strtotime($ecDateRaw)) : null;
// Match esatto o per data ravvicinata (+/- 14 giorni) o per nome
$nomeCond = strtoupper(trim((string)($ec['Nome_condomino'] ?? '')));
$cognomeTokens = array_filter(explode(' ', $nomeCond));
$nameMatched = false;
foreach ($cognomeTokens as $tok) {
if (strlen($tok) >= 4 && str_contains($desc, $tok)) {
$nameMatched = true;
break;
}
}
$daysDiff = ($dataMov && $ecDate) ? abs(strtotime($dataMov) - strtotime($ecDate)) / 86400 : 999;
if ($nameMatched || $daysDiff <= 14) {
$bestMatch = $ec;
break;
}
}
// Match speciale per bonifici cumulativi noti (es. Barone Michele 23/12/2025 € 960,79)
if (!$bestMatch && abs($importo - 960.79) < 0.01 && str_contains($desc, 'BARONE')) {
foreach ($candidates as $ec) {
if (str_contains(strtoupper($ec['Nome_condomino'] ?? ''), 'BARONE')) {
$bestMatch = $ec;
break;
}
}
}
if ($bestMatch) {
$scInt = trim((string)($bestMatch['sc_int'] ?? ''));
$scIntParts = explode('/', $scInt);
$scala = trim($scIntParts[0] ?? '');
$interno = trim($scIntParts[1] ?? '');
$unita = null;
if ($scala !== '' && $interno !== '') {
$unita = $unitaByScInt[strtoupper($scala) . '|' . $interno] ?? null;
}
if (!$unita && !empty($bestMatch['id_condomino'])) {
$unita = $unitaByLegacyId[trim((string)$bestMatch['id_condomino'])] ?? null;
}
$pdfFile = trim((string)($bestMatch['Nome_file_pdf'] ?? ''));
$matchData = [
'tipo' => 'incasso',
'protocollo' => $bestMatch['protocollo'] ?? null,
'data_pagamento' => $bestMatch['Data_pag'] ?? null,
'num_incasso' => $bestMatch['Num_incasso'] ?? null,
'anno_incasso' => $bestMatch['Anno_incasso'] ?? null,
'id_condomino' => $bestMatch['id_condomino'] ?? null,
'sc_int' => $scInt,
'nome_condomino' => $bestMatch['Nome_condomino'] ?? null,
'pdf_ricevuta' => $pdfFile,
'pdf_url' => $pdfFile ? "/admin/gescon-inc-ec-pdf/0016/{$pdfFile}" : null,
];
$mov->update([
'stato_riconciliazione' => 'riconciliato',
'unita_immobiliare_id' => $unita?->id,
'mittente' => $bestMatch['Nome_condomino'] ?? $mov->mittente,
'match_data' => $matchData,
]);
$riconciliatiIncassi++;
}
}
// 2. Riconciliazione SPESE (importo < 0)
if ($importo < 0) {
$absAmt = abs($importo);
$bestSpesa = null;
foreach ($speseArchivio as $sp) {
if (abs($sp['importo'] - $absAmt) < 0.01) {
$daysDiff = ($dataMov && $sp['data']) ? abs(strtotime($dataMov) - strtotime($sp['data'])) / 86400 : 999;
$benefUpper = strtoupper($sp['beneficiario']);
$benefTokens = array_filter(explode(' ', $benefUpper));
$tokenMatch = false;
foreach ($benefTokens as $tok) {
if (strlen($tok) >= 4 && str_contains($desc, $tok)) {
$tokenMatch = true;
break;
}
}
if ($tokenMatch || $daysDiff <= 14) {
$bestSpesa = $sp;
break;
}
}
}
// Match per commissioni bancarie o addebiti specifici
if (!$bestSpesa && (str_contains($desc, 'COMM.') || str_contains($desc, 'CANONE HB') || str_contains($desc, 'COMPETENZE'))) {
$bestSpesa = [
'id_operaz' => null,
'beneficiario' => 'Banca del Fucino - Spese e Commissioni',
'num_fat' => null,
'data' => $dataMov,
'importo' => $absAmt,
];
}
if ($bestSpesa) {
$matchData = [
'tipo' => 'spesa',
'id_operaz' => $bestSpesa['id_operaz'] ?? null,
'beneficiario' => $bestSpesa['beneficiario'],
'num_fat' => $bestSpesa['num_fat'] ?? null,
'data_spesa' => $bestSpesa['data'] ?? null,
];
$mov->update([
'stato_riconciliazione' => 'riconciliato',
'beneficiario' => $bestSpesa['beneficiario'],
'match_data' => $matchData,
]);
$riconciliatiSpese++;
}
}
}
$totaleRiconciliati = $riconciliatiIncassi + $riconciliatiSpese;
$percentuale = $totalMovimenti > 0 ? round(($totaleRiconciliati / $totalMovimenti) * 100, 2) : 0.0;
return [
'total_movimenti' => $totalMovimenti,
'riconciliati_incassi' => $riconciliatiIncassi,
'riconciliati_spese' => $riconciliatiSpese,
'totale_riconciliati' => $totaleRiconciliati,
'percentuale' => $percentuale,
];
}
/**
* Restituisce i movimenti bancari riconciliati per una specifica unità immobiliare.
*/
public function getMovimentiForUnita(int $unitaId): \Illuminate\Database\Eloquent\Collection
{
return MovimentoBanca::query()
->where('unita_immobiliare_id', $unitaId)
->orderByDesc('data')
->get();
}
/**
* Trova il movimento bancario collegato ad una specifica ricevuta PDF in Inc_da_ec.
*/
public function getBankMovementForPdf(string $pdfName): ?MovimentoBanca
{
return MovimentoBanca::query()
->where('match_data->pdf_ricevuta', $pdfName)
->first();
}
}