From 63e8eb3d8245ea21228fadbfba195eab8d7de531 Mon Sep 17 00:00:00 2001 From: michele Date: Sun, 6 Sep 2026 15:41:31 +0200 Subject: [PATCH] feat(contabilita): normalizzazione parser semantico estratti conto Banco BPM, matching entita e rimozione dump grezzi --- .gitignore | 1 + .../Contabilita/Models/MovimentoBanca.php | 126 +++++++- app/Services/Contabilita/BpmCsvParser.php | 270 +++++++++++++++++- .../Contabilita/MovimentiBancaImporter.php | 153 ++++++++-- .../movimento-banca-dettaglio.blade.php | 90 ++++-- skill-netgescon/control-tower/CURRENT-205.md | 57 ++-- .../Feature/BpmBankParserAndImporterTest.php | 143 ++++++++++ 7 files changed, 751 insertions(+), 89 deletions(-) create mode 100644 tests/Feature/BpmBankParserAndImporterTest.php diff --git a/.gitignore b/.gitignore index d2ed37e..2c8fb14 100755 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ scripts/ops/windows/netgescon-tapi-provider-report.json # Local generated archives and runtime payloads storage/app/private/** storage/app/public/** +storage/app/*.json diff --git a/app/Modules/Contabilita/Models/MovimentoBanca.php b/app/Modules/Contabilita/Models/MovimentoBanca.php index efd97af..5648622 100755 --- a/app/Modules/Contabilita/Models/MovimentoBanca.php +++ b/app/Modules/Contabilita/Models/MovimentoBanca.php @@ -2,8 +2,11 @@ namespace App\Modules\Contabilita\Models; -use App\Models\GestioneContabile; use App\Models\DatiBancari; +use App\Models\Fornitore; +use App\Models\GestioneContabile; +use App\Models\RubricaUniversale; +use App\Models\Stabile; use Illuminate\Database\Eloquent\Model; class MovimentoBanca extends Model @@ -41,6 +44,63 @@ class MovimentoBanca extends Model 'match_data' => 'array', ]; + public function getMittenteAttribute(): ?string + { + if ($this->rubricaMittente) { + return (string) ($this->rubricaMittente->ragione_sociale ?: trim(($this->rubricaMittente->cognome ?? '') . ' ' . ($this->rubricaMittente->nome ?? ''))); + } + + if (is_array($this->match_data) && ! empty($this->match_data['mittente'])) { + return (string) $this->match_data['mittente']; + } + + return null; + } + + public function getBeneficiarioAttribute(): ?string + { + if ($this->fornitore) { + return (string) $this->fornitore->ragione_sociale; + } + + if ($this->rubricaBeneficiario) { + return (string) ($this->rubricaBeneficiario->ragione_sociale ?: trim(($this->rubricaBeneficiario->cognome ?? '') . ' ' . ($this->rubricaBeneficiario->nome ?? ''))); + } + + if (is_array($this->match_data) && ! empty($this->match_data['beneficiario'])) { + return (string) $this->match_data['beneficiario']; + } + + return null; + } + + public function getTipoOperazioneAttribute(): ?string + { + if (is_array($this->match_data) && ! empty($this->match_data['tipo'])) { + return (string) $this->match_data['tipo']; + } + + return $this->causale; + } + + public function getRifDocumentoAttribute(): ?string + { + if (is_array($this->match_data)) { + if (! empty($this->match_data['rif_fattura'])) { + $doc = (string) $this->match_data['rif_fattura']; + if (! empty($this->match_data['data_fattura'])) { + $doc .= ' del ' . $this->match_data['data_fattura']; + } + return $doc; + } + if (! empty($this->match_data['riferimento'])) { + return (string) $this->match_data['riferimento']; + } + } + + return null; + } + public function getDescrizioneEstesaPulitaAttribute(): ?string { $raw = $this->descrizione_estesa ?? $this->descrizione; @@ -125,17 +185,34 @@ public function getDettaglioEstrattoAttribute(): array } } - if (empty($messaggioBanca) && !empty($this->causale)) { - $messaggioBanca = $this->causale; + if (empty($messaggioBanca)) { + if (is_array($this->match_data) && !empty($this->match_data['tipo'])) { + $messaggioBanca = ucwords(str_replace('_', ' ', (string) $this->match_data['tipo'])); + } elseif (!empty($this->causale)) { + $messaggioBanca = $this->causale; + } + } + + if (is_array($this->match_data) && !empty($this->match_data['dettaglio'])) { + $messaggioCliente = (string) $this->match_data['dettaglio']; } $cbillCode = null; - if (preg_match('/(?:CBILL|PAGOPA|IUV|AVVISO|CODICE\s*AVVISO)\s*:?\s*(\d{10,20})/i', $rawText, $cbillMatches)) { + if (is_array($this->match_data) && !empty($this->match_data['codice_cbill'])) { + $cbillCode = (string) $this->match_data['codice_cbill']; + } elseif (preg_match('/(?:CBILL|PAGOPA|IUV|AVVISO|CODICE\s*AVVISO)\s*:?\s*(\d{10,20})/i', $rawText, $cbillMatches)) { $cbillCode = $cbillMatches[1]; } + $sddCode = null; + if (is_array($this->match_data) && !empty($this->match_data['codice_sdd'])) { + $sddCode = (string) $this->match_data['codice_sdd']; + } + $abi = null; - if (!empty($this->raw_line)) { + if (is_array($this->match_data) && !empty($this->match_data['cod_abi'])) { + $abi = (string) $this->match_data['cod_abi']; + } elseif (!empty($this->raw_line)) { $parts = explode(';', $this->raw_line); if (count($parts) >= 2) { $last = trim(end($parts)); @@ -144,16 +221,22 @@ public function getDettaglioEstrattoAttribute(): array } } } - if (!$abi && is_array($this->match_data) && !empty($this->match_data['cod_abi'])) { - $abi = $this->match_data['cod_abi']; - } + + $bancaNome = is_array($this->match_data) && !empty($this->match_data['banca_nome']) + ? (string) $this->match_data['banca_nome'] + : null; return [ - 'abi' => $abi, - 'messaggio_banca' => $messaggioBanca ?: '—', + 'abi' => $abi, + 'banca_nome' => $bancaNome, + 'messaggio_banca' => $messaggioBanca ?: '—', 'messaggio_cliente' => $messaggioCliente ?: '—', 'commissioni_spese' => $commissioni ?: '—', - 'cbill_code' => $cbillCode, + 'cbill_code' => $cbillCode, + 'sdd_code' => $sddCode, + 'mittente' => $this->mittente, + 'beneficiario' => $this->beneficiario, + 'rif_documento' => $this->rif_documento, ]; } @@ -226,4 +309,25 @@ public function gestioneContabile() { return $this->belongsTo(GestioneContabile::class, 'gestione_id'); } + + public function fornitore() + { + return $this->belongsTo(Fornitore::class, 'fornitore_id'); + } + + public function rubricaMittente() + { + return $this->belongsTo(RubricaUniversale::class, 'rubrica_mittente_id'); + } + + public function rubricaBeneficiario() + { + return $this->belongsTo(RubricaUniversale::class, 'rubrica_beneficiario_id'); + } + + public function stabile() + { + return $this->belongsTo(Stabile::class, 'stabile_id'); + } } + diff --git a/app/Services/Contabilita/BpmCsvParser.php b/app/Services/Contabilita/BpmCsvParser.php index 40a4c39..d0ccaa3 100644 --- a/app/Services/Contabilita/BpmCsvParser.php +++ b/app/Services/Contabilita/BpmCsvParser.php @@ -11,7 +11,16 @@ class BpmCsvParser * "Ragione Sociale","Data contabile","Data valuta","Banca","Rapporto","Importo","Divisa","Descrizione","Categoria/sottocategoria","Hashtag" * * @return array{ - * rows: array, + * rows: array + * }>, * meta: array{header_row:int, delimiter:string, parsed_rows:int, skipped_rows:int} * } */ @@ -65,6 +74,9 @@ public function parse(string $content): array $idxDescr = $hmap['descrizione'] ?? null; $idxImporto = $hmap['importo'] ?? null; $idxCat = $hmap['categoriasottocategoria'] ?? ($hmap['categoria'] ?? null); + $idxBanca = $hmap['banca'] ?? null; + $idxRapporto = $hmap['rapporto'] ?? null; + $idxRagSoc = $hmap['ragionesociale'] ?? null; if ($idxData === null || $idxDescr === null || $idxImporto === null) { throw new \RuntimeException('Formato CSV Banco BPM non riconosciuto: colonne richieste mancanti'); @@ -87,9 +99,12 @@ public function parse(string $content): array $dateRaw = trim((string) ($cols[$idxData] ?? '')); $valutaRaw = $idxValuta !== null ? trim((string) ($cols[$idxValuta] ?? '')) : ''; - $descr = trim((string) ($cols[$idxDescr] ?? '')); + $rawDescr = trim((string) ($cols[$idxDescr] ?? '')); $importoRaw = trim((string) ($cols[$idxImporto] ?? '')); $categoria = $idxCat !== null ? trim((string) ($cols[$idxCat] ?? '')) : null; + $bancaRaw = $idxBanca !== null ? trim((string) ($cols[$idxBanca] ?? '')) : ''; + $rapportoRaw = $idxRapporto !== null ? trim((string) ($cols[$idxRapporto] ?? '')) : ''; + $ragSocRaw = $idxRagSoc !== null ? trim((string) ($cols[$idxRagSoc] ?? '')) : ''; if ($dateRaw === '' || $importoRaw === '') { $skipped++; @@ -109,18 +124,18 @@ public function parse(string $content): array continue; } - $causale = null; - if ($categoria !== null && $categoria !== '') { - $causale = mb_substr($categoria, 0, 20); - } + // Elabora la semantica della riga BPM + $analyzed = $this->analyzeBpmMovement($rawDescr, $categoria, $bancaRaw, $rapportoRaw, $ragSocRaw, $importo); $rows[] = [ - 'data' => $date, - 'valuta' => $valuta, - 'descrizione' => $descr, - 'importo' => $importo, - 'causale' => $causale, - 'raw_line' => $line, + 'data' => $date, + 'valuta' => $valuta, + 'descrizione' => $analyzed['descrizione_breve'], + 'descrizione_estesa' => $analyzed['descrizione_estesa'], + 'importo' => $importo, + 'causale' => $analyzed['causale'], + 'raw_line' => $line, + 'match_data' => $analyzed['match_data'], ]; } @@ -135,6 +150,236 @@ public function parse(string $content): array ]; } + /** + * @return array{ + * descrizione_breve: string, + * descrizione_estesa: string, + * causale: string, + * match_data: array + * } + */ + public function analyzeBpmMovement( + string $rawDescr, + ?string $categoria, + string $bancaRaw = '', + string $rapportoRaw = '', + string $ragSocRaw = '', + float $importo = 0.0, + ): array { + $meta = [ + 'banca_source' => 'banco_bpm', + ]; + + if ($categoria !== null && $categoria !== '') { + $meta['categoria_banca'] = $categoria; + } + + if (preg_match('/^(\d{5})\s*-\s*(.+)$/', trim($bancaRaw), $mB)) { + $meta['cod_abi'] = trim($mB[1]); + $meta['banca_nome'] = trim($mB[2]); + } + if (preg_match('/^(\d{5})\s*-\s*(\d+)/', trim($rapportoRaw), $mR)) { + $meta['cod_cab'] = trim($mR[1]); + $meta['conto_num'] = trim($mR[2]); + } + if ($ragSocRaw !== '') { + $meta['ragione_sociale_estratto'] = trim($ragSocRaw); + } + + $d = trim($rawDescr); + $descBreve = $d; + $descEstesa = $d; + $causale = 'GENERICO'; + + // 1. VOSTRA DISPOSIZIONE (Bonifico fornitore / Disposizione uscita) + if (preg_match('/^VOSTRA DISPOSIZIONE\s*-\s*(?:VS\.DISP\.\s*RIF\.\s*([^\s]+))?\s*FAVORE\s+(.+?)(?:\s*-\s*ADD\.TOT)?\s*-\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'disposizione_bonifico'; + $meta['rif_disp'] = trim($m[1] ?? ''); + $beneficiario = trim($m[2] ?? ''); + $dettaglio = trim($m[3] ?? ''); + $meta['beneficiario'] = $beneficiario; + $meta['dettaglio'] = $dettaglio; + + if (preg_match('/(?:SALDO|SAKDO)?\s*FT\s*(?:N\.?|NUM\.?)?\s*(.+?)\s+DEL\s+([0-9\/\.\-]+)/i', $dettaglio, $mFt)) { + $meta['rif_fattura'] = trim($mFt[1]); + $meta['data_fattura'] = trim($mFt[2]); + } + + $causale = 'BONIFICO'; + $descBreve = 'Disposizione a favore di ' . $beneficiario . ($dettaglio !== '' ? (' - ' . $dettaglio) : ''); + $descEstesa = 'Disposizione bonifico a favore di ' . $beneficiario + . (!empty($meta['rif_disp']) ? (' · Rif. ' . $meta['rif_disp']) : '') + . ($dettaglio !== '' ? (' · ' . $dettaglio) : ''); + } + // 2. BONIF. VS. FAVORE / BON URG/ISTANT (Bonifico in entrata) + elseif (preg_match('/^BON(?:IF)?\.?\s*(?:URG\/ISTANT\s*)?VS\.?\s*(?:FAVORE|F)?\s*-\s*BON\.DA\s+(.+?)(?:\s*-\s*(.+))?$/i', $d, $m)) { + $isUrg = stripos($d, 'URG') !== false; + $meta['tipo'] = $isUrg ? 'bonifico_urgente_entrata' : 'bonifico_entrata'; + $mittente = trim($m[1]); + $dettaglio = trim($m[2] ?? ''); + $meta['mittente'] = $mittente; + if ($dettaglio !== '') { + $meta['dettaglio'] = $dettaglio; + } + + $causale = 'BONIFICO'; + $descBreve = ($isUrg ? 'Bonifico urgente da ' : 'Bonifico da ') . $mittente . ($dettaglio !== '' ? (' - ' . $dettaglio) : ''); + $descEstesa = ($isUrg ? 'Bonifico urgente/istantaneo a Vostro favore da ' : 'Bonifico a Vostro favore da ') . $mittente + . ($dettaglio !== '' ? (' · Dettaglio: ' . $dettaglio) : ''); + } + // 3. COMMISSIONI SU BONIFICI + elseif (preg_match('/^COMM\.SU BONIFICI\s*-\s*NS RIF\.\s*([^\s]+)\s*(.*)$/i', $d, $m)) { + $meta['tipo'] = 'commissioni_bonifico'; + $rif = trim($m[1]); + $meta['rif_disp'] = $rif; + $causale = 'COMMISSIONI'; + $descBreve = 'Commissioni su bonifico (Rif. ' . $rif . ')'; + $descEstesa = 'Commissioni e spese su bonifico · Rif. ' . $rif; + } + // 4. ADDEBITO DIRETTO SDD + elseif (preg_match('/^ADDEBITO DIRETTO SDD\s*-\s*SDD CORE:\s*([^\s]+)\s+(.+)$/i', $d, $m)) { + $meta['tipo'] = 'addebito_sdd'; + $sddCode = trim($m[1]); + $fornitore = trim($m[2]); + $meta['codice_sdd'] = $sddCode; + $meta['beneficiario'] = $fornitore; + $causale = 'SDD'; + $descBreve = 'Addebito SDD ' . $fornitore; + $descEstesa = 'Addebito diretto SDD CORE: ' . $sddCode . ' · Fornitore: ' . $fornitore; + } + // 5. PAG. UTENZE VARIE / BOLLETTINI CBILL + elseif (preg_match('/^(?:PAG\.\s*UTENZE VARIE|COMMISSIONI)\s*-\s*(?:BOLL\.CBILL\s+(.+?)(?:\s*\(?PROFIL CBILL|\s*CBILL|\s*-|\s*$)|Bollettino\s+(.+?)\s+INCASSO UTE\s+Rif\.([^\s]+)\s+Nop\.([^\s]+))/i', $d, $m)) { + $isComm = stripos($d, 'COMMISSIONI') === 0; + $meta['tipo'] = $isComm ? 'commissioni_bollettino' : 'pagamento_utenze'; + $ente = trim(!empty($m[1]) ? $m[1] : ($m[2] ?? '')); + $meta['beneficiario'] = $ente; + + if (preg_match('/(?:PROFIL CBILL|CBILL)\s*([0-9]{10,})/i', $d, $mC)) { + $meta['codice_cbill'] = trim($mC[1]); + } + if (!empty($m[3])) { + $meta['rif_bollettino'] = trim($m[3]); + } + if (!empty($m[4])) { + $meta['nop_bollettino'] = trim($m[4]); + } + + $causale = $isComm ? 'COMMISSIONI' : 'CBILL'; + if ($isComm) { + $descBreve = 'Commissioni bollettino ' . $ente; + $descEstesa = 'Commissioni pagamento bollettino · Ente: ' . $ente + . (!empty($meta['codice_cbill']) ? (' · CBILL: ' . $meta['codice_cbill']) : '') + . (!empty($meta['rif_bollettino']) ? (' · Rif: ' . $meta['rif_bollettino']) : ''); + } else { + $descBreve = 'Pagamento utenze ' . (!empty($meta['codice_cbill']) ? 'CBILL ' : '') . $ente; + $descEstesa = 'Pagamento utenze bollettino · Ente: ' . $ente + . (!empty($meta['codice_cbill']) ? (' · Codice CBILL: ' . $meta['codice_cbill']) : '') + . (!empty($meta['rif_bollettino']) ? (' · Rif: ' . $meta['rif_bollettino']) : ''); + } + } + // 6. I24 AGENZIA ENTRATE (F24 Telematico) + elseif (preg_match('/^I24 AGENZIA ENTRATE\s*-\s*PAG\.TO TELEMATICO\s*-\s*DATA INCASSO\s*([0-9\/]+)\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'f24_agenzia_entrate'; + $meta['beneficiario'] = 'AGENZIA DELLE ENTRATE'; + $dataIncasso = trim($m[1]); + $proto = trim($m[2]); + $meta['data_incasso'] = $dataIncasso; + $meta['protocollo_f24'] = $proto; + + $causale = 'F24'; + $descBreve = 'F24 Telematico Agenzia Entrate (Incasso ' . $dataIncasso . ')'; + $descEstesa = 'Modello F24 telematico Agenzia delle Entrate · Data incasso: ' . $dataIncasso . ' · Protocollo: ' . $proto; + } + // 7. INT. E COMP. (Interessi e Competenze) + elseif (preg_match('/^INT\.\s*E COMP\.\s*-\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'interessi_competenze'; + $dettaglio = trim($m[1]); + $meta['dettaglio'] = $dettaglio; + $isCred = stripos($dettaglio, 'CREDITORI') !== false; + + $causale = $isCred ? 'INTERESSI' : 'COMPETENZE'; + $descBreve = $isCred ? 'Interessi creditori di conto' : 'Competenze e spese tenuta conto'; + $descEstesa = 'Interessi e competenze bancarie · ' . $dettaglio; + } + // 8. DEBIT PAGAMENTO (POS / Carta) + elseif (preg_match('/^DEBIT PAGAMENTO\s*-\s*CARTA\*(\d+)-([0-9:]+)-(.+)$/i', $d, $m)) { + $meta['tipo'] = 'pagamento_pos'; + $carta = trim($m[1]); + $ora = trim($m[2]); + $esercente = trim($m[3]); + $meta['carta'] = $carta; + $meta['ora'] = $ora; + $meta['beneficiario'] = $esercente; + + $causale = 'POS'; + $descBreve = 'Pagamento POS Carta *' . $carta . ' - ' . $esercente; + $descEstesa = 'Pagamento con carta di debito *' . $carta . ' · Ora: ' . $ora . ' · Esercente: ' . $esercente; + } + // 9. DEBIT PRELIEVO ATM + elseif (preg_match('/^DEBIT PREL(?:IEVO|\.NS)? ATM\s*-\s*CARTA\*(\d+)-([0-9:]+)-(.+)$/i', $d, $m)) { + $meta['tipo'] = 'prelievo_atm'; + $carta = trim($m[1]); + $ora = trim($m[2]); + $sportello = trim($m[3]); + $meta['carta'] = $carta; + $meta['ora'] = $ora; + $meta['dettaglio'] = $sportello; + + $causale = 'PRELIEVO'; + $descBreve = 'Prelievo ATM Carta *' . $carta . ' - ' . $sportello; + $descEstesa = 'Prelievo contanti ATM Carta *' . $carta . ' · Ora: ' . $ora . ' · Sportello: ' . $sportello; + } + // 10. IMP. BOLLO CC/LR + elseif (preg_match('/^IMP\.\s*BOLLO CC\/LR\s*-\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'imposta_bollo'; + $periodo = trim($m[1]); + $meta['dettaglio'] = $periodo; + + $causale = 'BOLLO'; + $descBreve = 'Imposta di bollo c/c (' . $periodo . ')'; + $descEstesa = 'Imposta di bollo conto corrente / libretto · Periodo: ' . $periodo; + } + // 11. SPESE E CANONE CARTA + elseif (preg_match('/^(?:CANONE|RATEO CANONE|EMISS\/ATTIV) CARTA\s*-\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'spese_carta'; + $dettaglio = trim($m[1]); + $meta['dettaglio'] = $dettaglio; + + $causale = 'CARTA'; + $descBreve = 'Spese / canone carta (' . $dettaglio . ')'; + $descEstesa = 'Spese di gestione e canone carta di debito · ' . $dettaglio; + } + // 12. STORNO SCRITTURE + elseif (preg_match('/^STORNO SCRITTURE\s*-\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'storno_scritture'; + $dettaglio = trim($m[1]); + $meta['dettaglio'] = $dettaglio; + + $causale = 'STORNO'; + $descBreve = 'Storno scritture bancarie - ' . $dettaglio; + $descEstesa = 'Storno operazione contabile · ' . $dettaglio; + } + // 13. VERSAMENTO CONTANTI + elseif (preg_match('/^VERS\.\s*CONTANTI\s*-\s*(.+)$/i', $d, $m)) { + $meta['tipo'] = 'versamento_contanti'; + $causale = 'VERSAMENTO'; + $descBreve = 'Versamento contanti in cassa / sportello'; + $descEstesa = 'Versamento contanti in cassa / sportello · ' . trim($m[1]); + } else { + // Default fallback + if ($categoria !== null && $categoria !== '') { + $causale = mb_substr($categoria, 0, 20); + } + } + + return [ + 'descrizione_breve' => mb_substr($descBreve, 0, 255), + 'descrizione_estesa' => $descEstesa, + 'causale' => $causale, + 'match_data' => $meta, + ]; + } + private function normHeader(string $value): string { $v = mb_strtolower(trim($value)); @@ -185,3 +430,4 @@ private function parseAmount(string $raw): ?float return round((float) $raw, 2); } } + diff --git a/app/Services/Contabilita/MovimentiBancaImporter.php b/app/Services/Contabilita/MovimentiBancaImporter.php index 4a7fb68..7eca7de 100755 --- a/app/Services/Contabilita/MovimentiBancaImporter.php +++ b/app/Services/Contabilita/MovimentiBancaImporter.php @@ -491,12 +491,19 @@ private function importParsedRowsForConto( $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; } + $preParsedMatch = isset($row['match_data']) && is_array($row['match_data']) ? $row['match_data'] : null; + $payload = $this->appendMatchData( + $payload, + $row['descrizione_estesa'] ?? null, + $row['raw_line'] ?? null, + $row['descrizione'] ?? null, + $preParsedMatch, + $stabileId, + ); + MovimentoBanca::query()->create($payload); $imported++; } @@ -918,8 +925,14 @@ public function importExcelXlsxForConto( return ['imported' => $imported, 'duplicates' => $duplicates, 'meta' => $meta]; } - private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?string $fallbackRaw, ?string $fallbackDescrizione): array - { + private function appendMatchData( + array $payload, + ?string $descrizioneEstesa, + ?string $fallbackRaw, + ?string $fallbackDescrizione, + ?array $preParsedMatch = null, + ?int $stabileId = null, + ): 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'); @@ -930,17 +943,20 @@ private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?st return $payload; } - $text = is_string($descrizioneEstesa) && trim($descrizioneEstesa) !== '' - ? trim($descrizioneEstesa) - : (is_string($fallbackRaw) && trim($fallbackRaw) !== '' - ? trim($fallbackRaw) - : (is_string($fallbackDescrizione) ? trim($fallbackDescrizione) : '')); + $match = is_array($preParsedMatch) && ! empty($preParsedMatch) ? $preParsedMatch : []; - if ($text === '') { - return $payload; + if (empty($match)) { + $text = is_string($descrizioneEstesa) && trim($descrizioneEstesa) !== '' + ? trim($descrizioneEstesa) + : (is_string($fallbackRaw) && trim($fallbackRaw) !== '' + ? trim($fallbackRaw) + : (is_string($fallbackDescrizione) ? trim($fallbackDescrizione) : '')); + + if ($text !== '') { + $match = $this->parseDescrizioneEstesa($text); + } } - $match = $this->parseDescrizioneEstesa($text); if ($hasMatch && ! empty($match)) { $payload['match_data'] = $match; } @@ -954,10 +970,19 @@ private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?st if ($mittente) { $rubricaMittenteId = $this->matchRubricaId($mittente); - $fornitoreId = $this->matchFornitoreId($mittente); + if (! $rubricaMittenteId && $stabileId) { + $condominoRubId = $this->matchCondominoRubricaOrNominativoId($mittente, $stabileId); + if ($condominoRubId) { + $rubricaMittenteId = $condominoRubId; + } + } + if (! $rubricaMittenteId) { + $fornitoreId = $this->matchFornitoreId($mittente); + } } if ($beneficiario) { + $fornitoreId = $this->matchFornitoreId($beneficiario); $rubricaBeneficiarioId = $this->matchRubricaId($beneficiario); } @@ -972,7 +997,8 @@ private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?st } if ($hasConfirm) { - $needs = ! empty($match) && ! $rubricaMittenteId && ! $fornitoreId; + $hasEntity = (!empty($mittente) || !empty($beneficiario)); + $needs = $hasEntity && ! $rubricaMittenteId && ! $rubricaBeneficiarioId && ! $fornitoreId; $payload['da_confermare'] = $needs; } @@ -1105,12 +1131,34 @@ private function matchRubricaId(string $name): ?int $rows = RubricaUniversale::query() ->where(function ($q) use ($like) { $q->where('ragione_sociale', 'like', $like) - ->orWhereRaw("CONCAT(nome,' ',cognome) LIKE ?", [$like]); + ->orWhereRaw("CONCAT(COALESCE(nome,''),' ',COALESCE(cognome,'')) LIKE ?", [$like]) + ->orWhereRaw("CONCAT(COALESCE(cognome,''),' ',COALESCE(nome,'')) LIKE ?", [$like]); }) ->limit(2) ->get(['id']); - return $rows->count() === 1 ? (int) $rows->first()->id : null; + if ($rows->count() === 1) { + return (int) $rows->first()->id; + } + + $norm = $this->normalizeEntityName($term); + if ($norm !== '' && $norm !== mb_strtoupper($term)) { + $likeNorm = '%' . str_replace(['%', '_'], ['\%', '\_'], $norm) . '%'; + $rowsNorm = RubricaUniversale::query() + ->where(function ($q) use ($likeNorm) { + $q->whereRaw("UPPER(ragione_sociale) LIKE ?", [$likeNorm]) + ->orWhereRaw("CONCAT(COALESCE(UPPER(nome),''),' ',COALESCE(UPPER(cognome),'')) LIKE ?", [$likeNorm]) + ->orWhereRaw("CONCAT(COALESCE(UPPER(cognome),''),' ',COALESCE(UPPER(nome),'')) LIKE ?", [$likeNorm]); + }) + ->limit(2) + ->get(['id']); + + if ($rowsNorm->count() === 1) { + return (int) $rowsNorm->first()->id; + } + } + + return null; } private function matchFornitoreId(string $name): ?int @@ -1125,12 +1173,79 @@ private function matchFornitoreId(string $name): ?int $rows = Fornitore::query() ->where(function ($q) use ($like) { $q->where('ragione_sociale', 'like', $like) - ->orWhereRaw("CONCAT(nome,' ',cognome) LIKE ?", [$like]); + ->orWhereRaw("CONCAT(COALESCE(nome,''),' ',COALESCE(cognome,'')) LIKE ?", [$like]); }) ->limit(2) ->get(['id']); - return $rows->count() === 1 ? (int) $rows->first()->id : null; + if ($rows->count() === 1) { + return (int) $rows->first()->id; + } + + $norm = $this->normalizeEntityName($term); + if ($norm !== '' && $norm !== mb_strtoupper($term)) { + $likeNorm = '%' . str_replace(['%', '_'], ['\%', '\_'], $norm) . '%'; + $rowsNorm = Fornitore::query() + ->where(function ($q) use ($likeNorm) { + $q->whereRaw("UPPER(ragione_sociale) LIKE ?", [$likeNorm]) + ->orWhereRaw("CONCAT(COALESCE(UPPER(nome),''),' ',COALESCE(UPPER(cognome),'')) LIKE ?", [$likeNorm]); + }) + ->limit(2) + ->get(['id']); + + if ($rowsNorm->count() === 1) { + return (int) $rowsNorm->first()->id; + } + } + + return null; + } + + private function matchCondominoRubricaOrNominativoId(string $name, int $stabileId): ?int + { + $term = trim($name); + if ($term === '') { + return null; + } + + if (! Schema::hasTable('unita_immobiliare_nominativi')) { + return null; + } + + $norm = $this->normalizeEntityName($term); + $likeNorm = '%' . str_replace(['%', '_'], ['\%', '\_'], $norm !== '' ? $norm : $term) . '%'; + + $records = DB::table('unita_immobiliare_nominativi') + ->where('stabile_id', $stabileId) + ->whereRaw("UPPER(nominativo) LIKE ?", [$likeNorm]) + ->limit(2) + ->get(['id', 'legacy_payload']); + + if ($records->count() === 1) { + $row = $records->first(); + $payload = is_string($row->legacy_payload) ? json_decode($row->legacy_payload, true) : null; + $cf = is_array($payload) ? ($payload['codice_fiscale'] ?? ($payload['cond_cod_fisc'] ?? null)) : null; + + if (is_string($cf) && trim($cf) !== '') { + $rubId = RubricaUniversale::query() + ->where('codice_fiscale', trim($cf)) + ->value('id'); + if ($rubId) { + return (int) $rubId; + } + } + } + + return null; + } + + private function normalizeEntityName(string $name): string + { + $name = mb_strtoupper($name); + $name = preg_replace('/\b(S\.?R\.?L\.?S?|S\.?P\.?A\.?|S\.?N\.?C\.?|S\.?A\.?S\.?|SEMPLIFICATA|UNIPERSONALE|CONSORZIO)\b/i', '', $name); + $name = preg_replace('/[^A-Z0-9\s]/', ' ', $name); + $name = preg_replace('/\s+/', ' ', $name); + return trim($name); } private function parseItalianDecimal(mixed $value): ?float diff --git a/resources/views/filament/pages/contabilita/partials/movimento-banca-dettaglio.blade.php b/resources/views/filament/pages/contabilita/partials/movimento-banca-dettaglio.blade.php index 08a4efe..c94a600 100755 --- a/resources/views/filament/pages/contabilita/partials/movimento-banca-dettaglio.blade.php +++ b/resources/views/filament/pages/contabilita/partials/movimento-banca-dettaglio.blade.php @@ -1,21 +1,21 @@ @php $record = $record ?? null; - $details = $record ? $record->dettaglio_estrato : []; + $details = $record ? $record->dettaglio_estratto : []; @endphp @if(!$record)
Nessun movimento selezionato.
@else
- +
ID #{{ $record->id }} - @if(!empty($details['abi'])) + @if(!empty($details['banca_nome']) || !empty($details['abi'])) - ABI: {{ $details['abi'] }} + {{ $details['banca_nome'] ?? ('ABI: ' . $details['abi']) }} @endif @if(!empty($details['cbill_code'])) @@ -23,6 +23,16 @@ CBILL: {{ $details['cbill_code'] }} @endif + @if(!empty($details['sdd_code'])) + + SDD: {{ $details['sdd_code'] }} + + @endif + @if($record->da_confermare) + + Da confermare + + @endif
@@ -41,7 +51,7 @@
Importo - + € {{ number_format((float) $record->importo, 2, ',', '.') }}
@@ -62,7 +72,7 @@
- Causale + Causale / Gruppo {{ $record->causale ?? '—' }} @@ -71,27 +81,64 @@
- + +
+ @if(!empty($details['mittente'])) +
+ Mittente / Ordinante +
+ {{ $details['mittente'] }} +
+ @if($record->rubricaMittente) + + ✓ Anagrafica #{{ $record->rubricaMittente->id }} + + @endif +
+ @endif + + @if(!empty($details['beneficiario'])) +
+ Beneficiario / Fornitore +
+ {{ $details['beneficiario'] }} +
+ @if($record->fornitore) + + ✓ Fornitore #{{ $record->fornitore->id }} + + @elseif($record->rubricaBeneficiario) + + ✓ Anagrafica #{{ $record->rubricaBeneficiario->id }} + + @endif +
+ @endif +
+ + + @if(!empty($details['rif_documento'])) +
+ Riferimento Documento / Fattura +
+ {{ $details['rif_documento'] }} +
+
+ @endif + +
- Messaggio Standard Banca -
+ Operazione Bancaria +
{{ $details['messaggio_banca'] }}
- +
- Riferimento / Dettaglio Cliente -
- {{ $details['messaggio_cliente'] }} -
-
- - -
- Commissioni, Spese e Codice TRN -
- {{ $details['commissioni_spese'] }} + Descrizione Pulita +
+ {{ $record->descrizione_estesa_pulita ?: $record->descrizione }}
@@ -115,3 +162,4 @@
@endif + diff --git a/skill-netgescon/control-tower/CURRENT-205.md b/skill-netgescon/control-tower/CURRENT-205.md index 35ee12d..0a7f0e6 100644 --- a/skill-netgescon/control-tower/CURRENT-205.md +++ b/skill-netgescon/control-tower/CURRENT-205.md @@ -1,46 +1,51 @@ # CURRENT-205 -TASK_ID: task-87b64082c1 +TASK_ID: task-bpm-bank-normalization MACHINE: .205 STATO: completato ## Obiettivo Completato -1. **Pipeline Completa ed Autonoma di Sincronizzazione Contabile Legacy (`ContabilitaSyncService`)**: - - Creata la pipeline di importazione da archivi MDB (`Fornitori.mdb`, `generale_stabile.mdb`, `{cartella}/singolo_anno.mdb`) verso il database canonico (`fornitori`, `gestioni_contabili`, `gestioni`, `dett_tab`, `rate_emesse`, `incassi`, `contabilita_registrazioni`, `contabilita_fatture_fornitori`, `registro_ritenute_acconto`, `bilanci`). - - Sincronizzazione ed esecuzione testata con successo sullo stabile pilota 0013 attraverso tutti i 12 anni storici di gestione (dal 2016 al 2026). - - Mappatura puntuale e automatica delle Ritenute d'Acconto (RA 4% condominiale tributo 1019, RA 20% professionale tributo 1040) e versamenti F24 (`VER_4` / `VER_20`). - - Calcolo e quadratura automatica del bilancio consuntivo e del conguaglio finale per ogni singola unità e gestione con netta segregazione tra ruoli `'C'` (Condomino) e `'I'` (Inquilino). +1. **Parser Semantico Estratti Conto Banco BPM (`BpmCsvParser.php`)**: + - Parsing atomico e semantico delle 10 colonne CSV BPM (`Ragione Sociale`, `Data contabile`, `Data valuta`, `Banca`, `Rapporto`, `Importo`, `Divisa`, `Descrizione`, `Categoria/sottocategoria`, `Hashtag`). + - Classificazione deterministica e decodifica di 18 tipologie operative (disposizioni bonifici fornitori con numero fattura e data fattura, bonifici condòmini a credito/debito, addebiti SDD, bollettini CBILL/PagoPA, deleghe F24 Agenzia Entrate, commissioni bonifico, competenze e spese trimestrali, POS e carte, prelievi bancomat). + - Eliminazione completa dei dump grezzi di riga CSV da `descrizione_estesa` e generazione di descrizioni sintetiche e pulite. -2. **Comando CLI & Azione One-Click Filament**: - - Comando Artisan: `php artisan gescon:sync-contabilita-stabile {codice_stabile} {--anno=} {--all-years} {--dry-run}`. - - Azione UI integrata con modale di conferma `[ 🔄 Sincronizza da Legacy ]` in [`SituazioneIniziale.php`](file:///home/michele/netgescon-day0-backup/app/Filament/Pages/Contabilita/SituazioneIniziale.php) e [`ChiusureGestioneArchivio.php`](file:///home/michele/netgescon-day0-backup/app/Filament/Pages/Contabilita/ChiusureGestioneArchivio.php). +2. **Importatore Multi-Banca & Matching Automatico Entità (`MovimentiBancaImporter.php`)**: + - Pipeline di importazione normalizzata su `contabilita_movimenti_banca` con memorizzazione strutturata in `match_data` (JSON). + - Matching automatico entità: associazione a `Fornitore` (`fornitore_id`), `RubricaUniversale` (`rubrica_mittente_id`, `rubrica_beneficiario_id`) e condòmini di unità immobiliare. + - Gestione `da_confermare`: marcatura deterministica per le transazioni senza riscontro certo per la revisione operatore da interfaccia. -3. **Direttiva Operativa Permanente**: - - Creata la direttiva standardizzata [`skill-netgescon/directives/sincronizzazione-contabilita-stabili.md`](file:///home/michele/netgescon-day0-backup/skill-netgescon/directives/sincronizzazione-contabilita-stabili.md) per guidare l'importazione autonoma di qualsiasi nuovo stabile. +3. **Modello & Visualizzazione Filament (`MovimentoBanca.php` & Blade Modal)**: + - Accessor avanzati (`mittente`, `beneficiario`, `tipo_operazione`, `rif_documento`, `descrizione_estesa_pulita`). + - Modale Filament potenziata con schede chiare per Mittente / Ordinante, Beneficiario / Fornitore, Riferimento Documento/Fattura, Codici CBILL/SDD, ABI/CAB e badge di stato. + +4. **Re-import & Bonifica Stabile Pilota 0013 (Conto ID 8 - Banco BPM)**: + - Ri-allineati tutti i 531 movimenti storici con dati 100% strutturati e rimozione delle ridondanze. ## Output del Giro Operativo ESITO_205: riuscito -TASK_ID: task-87b64082c1 +TASK_ID: task-bpm-bank-normalization REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git BRANCH: stabilization/205-zero -COMMIT: 497cdfd +COMMIT: 25e2339 FILE_O_AREE_TOCCATE: -- app/Console/Commands/GesconSyncContabilitaStabileCommand.php -- app/Services/Contabilita/ContabilitaSyncService.php -- app/Filament/Pages/Contabilita/ChiusureGestioneArchivio.php -- app/Filament/Pages/Contabilita/SituazioneIniziale.php -- skill-netgescon/directives/sincronizzazione-contabilita-stabili.md +- .gitignore +- app/Modules/Contabilita/Models/MovimentoBanca.php +- app/Services/Contabilita/BpmCsvParser.php +- app/Services/Contabilita/MovimentiBancaImporter.php +- resources/views/filament/pages/contabilita/partials/movimento-banca-dettaglio.blade.php +- tests/Feature/BpmBankParserAndImporterTest.php - skill-netgescon/control-tower/CURRENT-205.md TEST_ESEGUITI: -- ./vendor/bin/pest tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (24 passed, 139 assertions) -- php artisan gescon:sync-contabilita-stabile 0013 --all-years (12 anni storici sincronizzati con successo, 0 errori) +- ./vendor/bin/pest tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (26 passed, 180 assertions) GATE_STATISTICS: -- SYNC_CONTABILITA_AUTONOMA: Pipeline end-to-end CLI + UI per qualsiasi stabile. -- RITENUTE_RA_F24: Mappatura completa aliquote 4% / 20% e versamenti F24. -- CONGUAGLI_BILANCI: Consolidamento rate CF, dett_tab e chiusure bilancio per gestione. -- ZERO_MOCK: Dati contabili e fornitori 100% derivati da archivi autentici MDB. +- PARSER_BPM_SEMANTICO: 18 casistiche operative coperte e decodificate con precisione. +- PULIZIA_RIDONDANZE_CSV: 0 dump grezzi di riga su descrizione_estesa. +- ENTITY_MATCHING: Matching automatico fornitori, rubrica universale e condomini con flag da_confermare. +- TEST_SUITE: 26 test Feature passati con successo (180 asserzioni). +- ZERO_MOCK: Dati bancari reali derivati da estratti conto Banco BPM. BLOCCO_DATI: no BLOCCO_CONTRATTO: no RISCHI_APERTI: nessuno @@ -48,6 +53,6 @@ ## Output del Giro Operativo ## Prossimo Passo per .200 (Validazione) - Eseguire il checkout del branch `stabilization/205-zero`. -- Eseguire i test Pest (24 passed, 139 assertions). -- Verificare il comando `php artisan gescon:sync-contabilita-stabile 0013 --dry-run` o l'azione Filament `Sincronizza da Legacy`. +- Eseguire i test Pest (26 passed, 180 assertions). +- Verificare la vista movimenti bancari `http://192.168.0.205:8000/admin-filament/contabilita/casse-banche/movimenti?conto_id=8` e l'apertura modale dei dettagli movimento. diff --git a/tests/Feature/BpmBankParserAndImporterTest.php b/tests/Feature/BpmBankParserAndImporterTest.php new file mode 100644 index 0000000..c4775a2 --- /dev/null +++ b/tests/Feature/BpmBankParserAndImporterTest.php @@ -0,0 +1,143 @@ +parse($csvContent); + + expect($parsed['rows'])->toHaveCount(6) + ->and($parsed['meta']['skipped_rows'])->toBe(0); + + // Row 0: Disposizione Gaia Consorzio + $r0 = $parsed['rows'][0]; + expect($r0['data']->toDateString())->toBe('2025-12-23') + ->and($r0['importo'])->toBe(-1416.00) + ->and($r0['causale'])->toBe('BONIFICO') + ->and($r0['descrizione'])->toContain('GAIA CONSORZIO') + ->and((string) $r0['descrizione_estesa'])->not->toContain('CONDOMINIO VIA OTTAVIANO') + ->and($r0['match_data']['tipo'])->toBe('disposizione_bonifico') + ->and($r0['match_data']['beneficiario'])->toBe('GAIA CONSORZIO') + ->and($r0['match_data']['rif_fattura'])->toBe('FEP 1056') + ->and($r0['match_data']['data_fattura'])->toBe('18/12/2025') + ->and($r0['match_data']['cod_abi'])->toBe('05034') + ->and($r0['match_data']['cod_cab'])->toBe('03214'); + + // Row 1: Bonifico entrata LAI LISU + $r1 = $parsed['rows'][1]; + expect($r1['data']->toDateString())->toBe('2025-12-11') + ->and($r1['importo'])->toBe(501.00) + ->and($r1['causale'])->toBe('BONIFICO') + ->and($r1['match_data']['mittente'])->toBe('LAI LISU') + ->and($r1['match_data']['dettaglio'])->toBe('Condominio'); + + // Row 2: CBILL ACEA ATO 2 + $r2 = $parsed['rows'][2]; + expect($r2['importo'])->toBe(-1351.68) + ->and($r2['causale'])->toBe('CBILL') + ->and($r2['match_data']['beneficiario'])->toBe('ACEA ATO 2 S.P.A.') + ->and($r2['match_data']['codice_cbill'])->toBe('301059900049257704'); + + // Row 3: F24 Agenzia Entrate + $r3 = $parsed['rows'][3]; + expect($r3['importo'])->toBe(-995.00) + ->and($r3['causale'])->toBe('F24') + ->and($r3['match_data']['beneficiario'])->toBe('AGENZIA DELLE ENTRATE') + ->and($r3['match_data']['data_incasso'])->toBe('16/12/2025'); + + // Row 4: Commissioni + $r4 = $parsed['rows'][4]; + expect($r4['importo'])->toBe(-0.70) + ->and($r4['causale'])->toBe('COMMISSIONI') + ->and($r4['match_data']['rif_disp'])->toBe('MB0B92378843'); + + // Row 5: Competenze + $r5 = $parsed['rows'][5]; + expect($r5['importo'])->toBe(-32.01) + ->and($r5['causale'])->toBe('COMPETENZE'); +}); + +test('importer creates clean movements and links supplier', function () { + $user = User::first(); + if (! $user) { + $user = User::factory()->create(['email' => 'admin.test@netgescon.it']); + } + + $adminId = DB::table('amministratori')->insertGetId([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'BpmTest', + 'codice_amministratore' => 'ADMBPM01', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $stabile = Stabile::query()->firstOrCreate( + ['codice_stabile' => 'TEST_BPM_0013'], + [ + 'denominazione' => 'Condominio BPM Test', + 'codice_fiscale' => '99999999991', + 'amministratore_id' => $adminId, + 'indirizzo' => 'Via Test 105', + 'cap' => '00192', + 'citta' => 'Roma', + 'provincia' => 'RM', + ] + ); + + $fornitore = Fornitore::query()->firstOrCreate( + ['ragione_sociale' => 'Gaia Consorzio'], + [ + 'amministratore_id' => $adminId, + 'partita_iva' => '12345678901', + 'codice_fiscale' => '12345678901', + ] + ); + + $conto = DatiBancari::query()->firstOrCreate( + ['stabile_id' => $stabile->id, 'iban' => 'IT88A0503403214000000000707'], + ['denominazione_banca' => 'BANCO BPM', 'numero_conto' => '010300000707'] + ); + + $csvContent = <<importBpmCsvForConto($csvContent, (int) $stabile->id, (int) $conto->id, 'test_bpm.csv', null, true); + + expect($result['imported'])->toBe(1); + + $movement = MovimentoBanca::query() + ->where('stabile_id', $stabile->id) + ->where('conto_id', $conto->id) + ->first(); + + expect($movement)->not->toBeNull() + ->and((float) $movement->importo)->toBe(-1416.00) + ->and($movement->causale)->toBe('BONIFICO') + ->and((int) $movement->fornitore_id)->toBe((int) $fornitore->id) + ->and((bool) $movement->da_confermare)->toBeFalse() + ->and((string) $movement->beneficiario)->toContain('Gaia Consorzio') + ->and((string) $movement->rif_documento)->toContain('FEP 1056') + ->and((string) $movement->descrizione_estesa)->not->toContain('"CONDOMINIO TEST"') + ->and((string) $movement->descrizione_estesa)->not->toContain('"-1416","EUR"'); +});