From accaeb75e13b4029025426cda5a920b13e28d2af Mon Sep 17 00:00:00 2001 From: balbettoni Date: Wed, 24 Jun 2026 19:52:50 +0200 Subject: [PATCH 1/5] feat(ec-converter): export CSV date in formato italiano gg/MM/aaaa L'export CSV dell'estratto conto ora riporta le date in formato italiano gg/MM/aaaa invece di DD.MM.YYYY (con il punto). La conversione e' localizzata solo in fase di export tramite il nuovo helper _formatta_data_csv() in csv_exporter.py; la rappresentazione interna dei Movimento resta DD.MM.YYYY, quindi normalizer, dataclass e template bancari non sono toccati. L'helper converte DD.MM.YYYY -> gg/MM/aaaa e lascia passare through date gia' in altro formato o campi vuoti. Applicato a mov.data_operazione in entrambe le modalita' (due_colonne e colonna_unica). Aggiornato 1 assert esistente + 2 nuovi test (formato slash, through di data non-standard). Suite completa: 133 passed. Co-Authored-By: Claude --- ec_converter/csv_exporter.py | 19 +++++++++++++++++-- tests/test_csv_exporter.py | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/ec_converter/csv_exporter.py b/ec_converter/csv_exporter.py index f5bf2d2..82860a4 100644 --- a/ec_converter/csv_exporter.py +++ b/ec_converter/csv_exporter.py @@ -16,6 +16,21 @@ from templates.base import Movimento +def _formatta_data_csv(data: str | None) -> str: + """ + Converte una data dal formato interno (DD.MM.YYYY) al formato italiano + richiesto dall'export CSV (gg/MM/aaaa). + + Lascia inalterate le stringhe che non corrispondono al formato interno, + cosi' eventuali date gia' in altro formato o campi vuoti passano through. + """ + if not data: + return "" + if len(data) == 10 and data[2] == "." and data[5] == ".": + return f"{data[0:2]}/{data[3:5]}/{data[6:10]}" + return data + + def export_csv( movimenti: list[Movimento], modalita_importo: Literal["due_colonne", "colonna_unica"] = "due_colonne", @@ -58,7 +73,7 @@ def export_csv( if modalita_importo == "due_colonne": dare_str = formatta_importo(mov.dare) if mov.dare is not None else "" avere_str = formatta_importo(mov.avere) if mov.avere is not None else "" - row = [mov.data_operazione, mov.descrizione] + row = [_formatta_data_csv(mov.data_operazione), mov.descrizione] if includi_causale: row.append(causale_str) row.extend([dare_str, avere_str]) @@ -70,7 +85,7 @@ def export_csv( importo_str = formatta_importo(mov.avere) else: importo_str = "0,00" - row = [mov.data_operazione, mov.descrizione] + row = [_formatta_data_csv(mov.data_operazione), mov.descrizione] if includi_causale: row.append(causale_str) row.append(importo_str) diff --git a/tests/test_csv_exporter.py b/tests/test_csv_exporter.py index 1b58ffe..104dc2a 100644 --- a/tests/test_csv_exporter.py +++ b/tests/test_csv_exporter.py @@ -42,7 +42,7 @@ def test_export_csv_due_colonne_header_e_righe(): out = export_csv([_mov(dare=50.0), _mov(avere=100.0)]) rows = _parse(out) assert rows[0] == ["Data Operazione", "Descrizione", "Addebiti", "Accrediti"] - assert rows[1][0] == "05.03.2026" + assert rows[1][0] == "05/03/2026" assert rows[1][2] == "50,00" assert rows[1][3] == "" assert rows[2][2] == "" @@ -111,6 +111,21 @@ def test_export_csv_importi_formattati_italiano(): assert rows[1][2] == "1.234,50" +def test_export_csv_data_in_formato_italiano_slash(): + """La data operazione esce in formato gg/MM/aaaa (slash), non DD.MM.YYYY.""" + out = export_csv([_mov(dare=50.0)]) + rows = _parse(out) + assert rows[1][0] == "05/03/2026" + + +def test_export_csv_data_gia_in_altro_formato_passa_through(): + """Una data non nel formato interno DD.MM.YYYY resta inalterata.""" + mov = Movimento("2026-03-05", None, "x", "x", dare=50.0) + out = export_csv([mov]) + rows = _parse(out) + assert rows[1][0] == "2026-03-05" + + # --------------------------------------------------------------------------- # fatture_converter.export_fatture_csv # --------------------------------------------------------------------------- From 212a059707630e454eb1103a4b16da5f5eae40e6 Mon Sep 17 00:00:00 2001 From: balbettoni Date: Wed, 24 Jun 2026 20:45:12 +0200 Subject: [PATCH 2/5] feat(ec-converter): restyling UI Gradio (tema custom + layout + CSS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migliora estetica e UX della UI del converter estratto conto, finora con look Gradio generico, nessuna gerarchia visiva e totali come semplice testo. - Tema custom gr.themes.Soft: palette emerald/slate, font Inter + JetBrains Mono, radius lg - CSS custom: header/footer strutturati, KPI card HTML per i totali (movimenti, addebiti, accrediti, differenza con colore di stato, causali), zebra-striping tabella movimenti - Layout: titolare conto spostato in Accordion "Opzioni avanzate", sezione "Export CSV" separata Nota tecnica: in Gradio 6.0 theme/css vanno passati a launch(), non al costruttore Blocks() (deprecato e ignorato). Logica pipeline invariata; elabora_pdf restituisce un 4° valore (kpi_html). Branding neutro. Co-Authored-By: Claude --- ec_converter/app.py | 248 +++++++++++++++++++++++++++++++++----------- 1 file changed, 188 insertions(+), 60 deletions(-) diff --git a/ec_converter/app.py b/ec_converter/app.py index 6b40d31..123415f 100644 --- a/ec_converter/app.py +++ b/ec_converter/app.py @@ -41,6 +41,126 @@ AUTO_DETECT_LABEL = "Auto-detect" +# --------------------------------------------------------------------------- +# Tema custom + CSS +# --------------------------------------------------------------------------- + +CUSTOM_THEME = gr.themes.Soft( + primary_hue="emerald", + secondary_hue="slate", + neutral_hue="slate", + radius_size="lg", + font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], + font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], +) + +CUSTOM_CSS = """ +/* Header */ +#ec-header { + border-bottom: 1px solid var(--block-border-color); + padding-bottom: 0.75rem; + margin-bottom: 1rem; +} +#ec-header h1 { + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} +#ec-header h1 .ec-logo { + font-size: 1.4em; +} +#ec-header .ec-sub { + color: var(--body-text-color-subdued); + margin: 0.25rem 0 0 0; + font-size: 0.92rem; +} + +/* KPI cards */ +.ec-kpi-row { gap: 0.75rem; } +.ec-kpi { + border: 1px solid var(--block-border-color); + border-radius: var(--radius-lg); + padding: 0.65rem 0.85rem; + background: var(--block-background-fill); + text-align: center; +} +.ec-kpi .ec-kpi-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--body-text-color-subdued); +} +.ec-kpi .ec-kpi-value { + font-family: var(--font-mono); + font-size: 1.15rem; + font-weight: 600; + margin-top: 0.15rem; +} +.ec-kpi.is-ok .ec-kpi-value { color: var(--color-accent-500, #10b981); } +.ec-kpi.is-warn .ec-kpi-value { color: var(--color-yellow-500, #d97706); } + +/* Tabella movimenti: zebra + sticky header */ +.gradio-container .table-wrap table tbody tr:nth-child(even) td { + background: color-mix(in srgb, var(--block-background-fill) 92%, var(--body-text-color) 4%); +} + +/* Footer */ +#ec-footer { + margin-top: 1.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--block-border-color); + color: var(--body-text-color-subdued); + font-size: 0.8rem; + text-align: center; +} +""" + +HEADER_HTML = """ +
+

Estratto Conto → CSV Ago Zucchetti

+

Carica un PDF, verifica i movimenti estratti e scarica il CSV pronto per l'import in contabilità.

+
+""" + +FOOTER_HTML = '' + + +def _kpi_cards(tot_dare: float, tot_avere: float, n_causali: int, n_mov: int) -> str: + """Render HTML per le KPI card (totali + quadratura sintetica).""" + diff = tot_avere - tot_dare + diff_cls = "is-ok" if abs(diff) < 0.01 else "is-warn" + return f""" +
+
+
Movimenti
+
{n_mov}
+
+
+
Totale Addebiti
+
{formatta_importo(tot_dare)}
+
+
+
Totale Accrediti
+
{formatta_importo(tot_avere)}
+
+
+
Differenza
+
{formatta_importo(diff)}
+
+
+
Causali
+
{n_causali}/{n_mov}
+
+
+ """ + + +# --------------------------------------------------------------------------- +# Logica (inalterata rispetto alla versione precedente) +# --------------------------------------------------------------------------- + + def get_template_choices() -> list[str]: choices = [AUTO_DETECT_LABEL] choices.extend(TEMPLATES[k]().display_name for k in list_templates()) @@ -61,13 +181,14 @@ def elabora_pdf(pdf_file, template_display, titolare_conto, progress=gr.Progress global _current_movimenti, _current_saldi if pdf_file is None: - return None, "Nessun file caricato.", "" + return None, "Nessun file caricato.", "", "" if not check_ocr_server(): return ( None, "Server OCR (dots-ocr) non raggiungibile. Verificare che il servizio sia attivo.", "", + "", ) template_name = get_template_name_from_display(template_display) @@ -91,10 +212,10 @@ def progress_cb(step, detail): ) except Exception as e: logger.exception("Errore durante l'elaborazione") - return None, f"Errore: {e}", "" + return None, f"Errore: {e}", "", "" if not movimenti: - return None, "Nessun movimento trovato nel PDF.", "" + return None, "Nessun movimento trovato nel PDF.", "", "" _current_movimenti = movimenti _current_saldi = saldi or {} @@ -119,9 +240,10 @@ def progress_cb(step, detail): if n_corretti: msg += f"\nCorretti {n_corretti} movimenti per inversione dare/avere (causale univoca)" + kpi_html = _kpi_cards(tot_dare, tot_avere, n_causali, len(movimenti)) quadratura = _formatta_quadratura(saldi or {}, tot_dare, tot_avere) - return df, msg, quadratura + return df, msg, quadratura, kpi_html def _formatta_quadratura(saldi: dict, tot_dare: float, tot_avere: float) -> str: @@ -273,20 +395,57 @@ def salva_causali_tabella(df_causali): return f"Salvate {len(causali)} causali." +# --- Funzioni tab Replace --- + + +def carica_replace_tabella(): + """Carica i replace come DataFrame per la tabella Gradio.""" + replace_list = carica_replace() + rows = [] + for r in replace_list: + rows.append( + { + "Trova": r.get("trova", ""), + "Sostituisci": r.get("sostituisci", ""), + "Nota": r.get("nota", ""), + } + ) + return pd.DataFrame(rows) if rows else pd.DataFrame(columns=["Trova", "Sostituisci", "Nota"]) + + +def salva_replace_tabella(df_replace): + """Salva i replace dalla tabella Gradio al file JSON.""" + if df_replace is None or (isinstance(df_replace, pd.DataFrame) and df_replace.empty): + return "Nessun replace da salvare." + + replace_list = [] + for _, row in df_replace.iterrows(): + trova = str(row.get("Trova", "")).strip() + sostituisci = str(row.get("Sostituisci", "")) + nota = str(row.get("Nota", "")).strip() + if not trova: + continue + entry = {"trova": trova, "sostituisci": sostituisci} + if nota: + entry["nota"] = nota + replace_list.append(entry) + + salva_replace(replace_list) + return f"Salvati {len(replace_list)} replace." + + +# --------------------------------------------------------------------------- +# UI +# --------------------------------------------------------------------------- + + def build_ui(): - with gr.Blocks( - title="EC Converter - Estratto Conto PDF -> CSV Ago", - theme=gr.themes.Soft(), - ) as app: - gr.Markdown("# Estratto Conto PDF -> CSV Ago Zucchetti") + with gr.Blocks(title="EC Converter - Estratto Conto PDF -> CSV Ago") as app: + gr.HTML(HEADER_HTML) with gr.Tabs(): # --- Tab Converter --- with gr.Tab("Converter"): - gr.Markdown( - "Carica un estratto conto PDF, verifica i movimenti estratti e scarica il CSV." - ) - with gr.Row(): with gr.Column(scale=1): pdf_input = gr.File( @@ -299,16 +458,21 @@ def build_ui(): choices=get_template_choices(), value=AUTO_DETECT_LABEL, ) - titolare_input = gr.Textbox( - label="Titolare conto (opzionale)", - placeholder="Es. FARMACIA ESEMPIO", - info="Se valorizzato, il nome viene rimosso dalle descrizioni POS (utile per Intesa ufficiale).", - ) + with gr.Accordion("Opzioni avanzate", open=False): + titolare_input = gr.Textbox( + label="Titolare conto (opzionale)", + placeholder="Es. FARMACIA ESEMPIO", + info=( + "Se valorizzato, il nome viene rimosso dalle descrizioni POS " + "(utile per Intesa ufficiale)." + ), + ) btn_elabora = gr.Button("Elabora PDF", variant="primary") with gr.Column(scale=1): + gr.Markdown("#### Export CSV") modalita_importo = gr.Radio( - label="Modalita' importo CSV", + label="Modalità importo", choices=["Due colonne (Dare/Avere)", "Colonna unica (con segno +/-)"], value="Due colonne (Dare/Avere)", ) @@ -320,6 +484,7 @@ def build_ui(): csv_output = gr.File(label="Download CSV") status_msg = gr.Textbox(label="Stato", interactive=False, lines=4) + kpi_box = gr.HTML("") quadratura_box = gr.Markdown("") gr.Markdown("### Anteprima Movimenti") @@ -343,7 +508,7 @@ def build_ui(): btn_elabora.click( fn=elabora_pdf, inputs=[pdf_input, template_choice, titolare_input], - outputs=[preview_table, status_msg, quadratura_box], + outputs=[preview_table, status_msg, quadratura_box, kpi_box], ) btn_csv.click( fn=genera_csv, @@ -417,48 +582,11 @@ def build_ui(): outputs=[replace_table], ) - return app - - -# --- Funzioni tab Replace --- - - -def carica_replace_tabella(): - """Carica i replace come DataFrame per la tabella Gradio.""" - replace_list = carica_replace() - rows = [] - for r in replace_list: - rows.append( - { - "Trova": r.get("trova", ""), - "Sostituisci": r.get("sostituisci", ""), - "Nota": r.get("nota", ""), - } - ) - return pd.DataFrame(rows) if rows else pd.DataFrame(columns=["Trova", "Sostituisci", "Nota"]) - - -def salva_replace_tabella(df_replace): - """Salva i replace dalla tabella Gradio al file JSON.""" - if df_replace is None or (isinstance(df_replace, pd.DataFrame) and df_replace.empty): - return "Nessun replace da salvare." - - replace_list = [] - for _, row in df_replace.iterrows(): - trova = str(row.get("Trova", "")).strip() - sostituisci = str(row.get("Sostituisci", "")) - nota = str(row.get("Nota", "")).strip() - if not trova: - continue - entry = {"trova": trova, "sostituisci": sostituisci} - if nota: - entry["nota"] = nota - replace_list.append(entry) + gr.HTML(FOOTER_HTML) - salva_replace(replace_list) - return f"Salvati {len(replace_list)} replace." + return app if __name__ == "__main__": app = build_ui() - app.launch(server_name="0.0.0.0", server_port=7860) + app.launch(server_name="0.0.0.0", server_port=7860, theme=CUSTOM_THEME, css=CUSTOM_CSS) From 5617eaf8d62c281fd9da1cb0c556ac739034c513 Mon Sep 17 00:00:00 2001 From: balbettoni Date: Thu, 25 Jun 2026 14:56:13 +0200 Subject: [PATCH 3/5] feat(ec-converter): aggiunge template BNL POS per rendiconto finanziamenti Nuovo template bnl_pos.py per estratti conto terminali POS BNL (documento "Finanziamenti"). Gestisce 3 formati OCR variabili (5/6/7 col) e completa le date DD/MM prive di anno dalla data valuta adiacente. Auto-detect tramite pattern "finanziamenti" in _DETECT_PATTERNS. 20 test nuovi; suite totale 153 test, coverage 84.85%. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + ec_converter/causali.json | 8 +- ec_converter/templates/__init__.py | 5 + ec_converter/templates/bnl_pos.py | 139 ++++++++++++++++ tests/test_templates.py | 3 +- tests/test_templates_bnl_pos.py | 250 +++++++++++++++++++++++++++++ 6 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 ec_converter/templates/bnl_pos.py create mode 100644 tests/test_templates_bnl_pos.py diff --git a/.gitignore b/.gitignore index a8e9c17..dbc0e19 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ secrets/ credentials.json .claude +.playwright-mcp/ *.tar.gz *.zip diff --git a/ec_converter/causali.json b/ec_converter/causali.json index 1dd0ffe..3153267 100644 --- a/ec_converter/causali.json +++ b/ec_converter/causali.json @@ -9,7 +9,8 @@ "pos al netto a pagament", "pos a pagamento", "pos al netto", - "Accredito POS lordo" + "Accredito POS lordo", + "incasso pagamento" ] }, { @@ -56,7 +57,10 @@ "Canone fisso mensile", "Commissione e spese", "Commissioni bonifico", - "Commissioni bonifico" + "Commissioni bonifico", + "spese invio documenti", + "lettere cont.", + "spese iniziative lettere" ] }, { diff --git a/ec_converter/templates/__init__.py b/ec_converter/templates/__init__.py index 37e3497..91a85ba 100644 --- a/ec_converter/templates/__init__.py +++ b/ec_converter/templates/__init__.py @@ -1,6 +1,7 @@ from .base import BankTemplate from .bnl import BNLTemplate from .bnl_lista_movimenti import BNLListaMovimentiTemplate +from .bnl_pos import BNLPosTemplate from .intesa_sanpaolo import IntesaSanpaoloTemplate from .intesa_sanpaolo_ufficiale import IntesaSanpaoloUfficialeTemplate @@ -9,6 +10,7 @@ "intesa_sanpaolo": IntesaSanpaoloTemplate, "bnl": BNLTemplate, "bnl_lista_movimenti": BNLListaMovimentiTemplate, + "bnl_pos": BNLPosTemplate, } # Pattern per auto-detect banca dalla prima pagina OCR. @@ -17,6 +19,9 @@ # "dettaglio movimenti del conto corrente" identifica il layout ufficiale Intesa # e va prima del pattern generico "intesa sanpaolo". _DETECT_PATTERNS = [ + # "finanziamenti" e' specifico del rendiconto POS BNL (prima riga del doc). + # Va prima di "bnl" generico per evitare il fallthrough. + ("bnl_pos", ["finanziamenti"]), ("bnl_lista_movimenti", ["lista movimenti"]), ( "intesa_sanpaolo_ufficiale", diff --git a/ec_converter/templates/bnl_pos.py b/ec_converter/templates/bnl_pos.py new file mode 100644 index 0000000..cc3f886 --- /dev/null +++ b/ec_converter/templates/bnl_pos.py @@ -0,0 +1,139 @@ +""" +Template per BNL Rendiconto POS (Gruppo BNP Paribas). + +Documento: "Finanziamenti" — estratto conto terminali POS BNL. +L'OCR produce strutture con numero di colonne variabile per pagina: + + Pagina 1 (7 col): Data(DD/MM/YYYY) | Valuta(DD/MM/YY) | Desc*3 | Dare | Avere + Pagina 2 (5 col): Data(DD/MM) | Valuta(DD/MM/YY) | Desc | Dare | Avere + Pagina 3 (6 col): Data(DD/MM) | Valuta(DD/MM/YY) | Desc*2 | Dare | Avere + +Strategia colonne: + - Col 0: Data operazione (DD/MM/YYYY o DD/MM) + - Col 1: Data valuta (DD/MM/YY) + - Col 2..n-3: Descrizione (join delle parti) + - Col n-2: Dare (se non e' un numero, viene accodato alla descrizione) + - Col n-1: Avere + +Le date DD/MM prive di anno vengono completate con l'anno estratto dalla +data valuta adiacente (formato DD/MM/YY). +""" + +import re + +from bs4 import BeautifulSoup + +from normalizer import normalizza_data, normalizza_importo, pulisci_descrizione +from templates.base import BankTemplate, Movimento + + +class BNLPosTemplate(BankTemplate): + name = "bnl_pos" + display_name = "BNL POS (Rendiconto)" + + def _ha_tabella_movimenti(self, html: str) -> bool: + soup = BeautifulSoup(html, "html.parser") + for table in soup.find_all("table"): + headers = [th.get_text(strip=True).lower() for th in table.find_all("th")] + header_text = " ".join(headers) + if "data" in header_text and "avere" in header_text: + return True + return False + + def _parse_anno_da_valuta(self, raw: str) -> int | None: + """Estrae l'anno intero da una data valuta DD/MM/YY o DD/MM/YYYY.""" + s = raw.strip() if raw else "" + m = re.match(r"^\d{2}[/.]\d{2}[/.](\d{2})$", s) + if m: + return 2000 + int(m.group(1)) + m = re.match(r"^\d{2}[/.]\d{2}[/.](\d{4})$", s) + if m: + return int(m.group(1)) + return None + + def _normalizza_data_pos(self, raw: str, anno_fallback: int | None = None) -> str | None: + """ + Normalizza date nei formati BNL POS: + DD/MM/YYYY → DD.MM.YYYY (via normalizza_data) + DD/MM/YY → DD.MM.20YY + DD/MM → DD.MM.YYYY (usa anno_fallback dalla valuta) + """ + s = raw.strip() if raw else "" + if not s: + return None + result = normalizza_data(s) + if result: + return result + # DD/MM/YY (anno a 2 cifre) + m = re.match(r"^(\d{2})[/.](\d{2})[/.](\d{2})$", s) + if m: + g, mes, yy = m.groups() + return f"{g}.{mes}.{2000 + int(yy)}" + # DD/MM senza anno + m = re.match(r"^(\d{2})[/.](\d{2})$", s) + if m and anno_fallback: + g, mes = m.groups() + return f"{g}.{mes}.{anno_fallback}" + return None + + def estrai_movimenti(self, pages_html: list[str]) -> list[Movimento]: + movimenti: list[Movimento] = [] + + for page_idx, html in enumerate(pages_html, start=1): + if not self._ha_tabella_movimenti(html): + continue + + soup = BeautifulSoup(html, "html.parser") + for table in soup.find_all("table"): + headers = [th.get_text(strip=True).lower() for th in table.find_all("th")] + header_text = " ".join(headers) + if "data" not in header_text or "avere" not in header_text: + continue + + for row in table.find_all("tr"): + cells = row.find_all(["td", "th"]) + if not cells or cells[0].name == "th": + continue + if len(cells) < 4: + continue + + n = len(cells) + data_op_raw = self._get_plain_text(cells[0]) + data_val_raw = self._get_plain_text(cells[1]) + + anno = self._parse_anno_da_valuta(data_val_raw) + data_op = self._normalizza_data_pos(data_op_raw, anno) + data_val = self._normalizza_data_pos(data_val_raw) + + avere_raw = self._get_plain_text(cells[n - 1]) + dare_raw = self._get_plain_text(cells[n - 2]) + + avere = normalizza_importo(avere_raw) + dare = normalizza_importo(dare_raw) + + # Colonne intermedie → descrizione; se dare_raw non e' un numero + # (es. "da POS" spill dall'OCR), lo accoda alla descrizione. + desc_parts = [self._get_plain_text(cells[i]) for i in range(2, n - 2)] + if dare is None and dare_raw.strip(): + desc_parts.append(dare_raw.strip()) + desc_raw = " ".join(p for p in desc_parts if p) + + if not desc_raw.strip(): + continue + if desc_raw.strip().lower().startswith("saldo"): + continue + if data_op is None and dare is None and avere is None: + continue + + mov = Movimento( + data_operazione=data_op or "", + data_valuta=data_val, + descrizione_raw=desc_raw, + descrizione=pulisci_descrizione(desc_raw), + dare=dare, + avere=avere, + pagina=page_idx, + ) + movimenti.append(mov) + + return movimenti diff --git a/tests/test_templates.py b/tests/test_templates.py index 787be9f..8215ef1 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -20,12 +20,13 @@ # --------------------------------------------------------------------------- -def test_list_templates_restituisce_i_quattro_template(): +def test_list_templates_restituisce_i_cinque_template(): assert set(list_templates()) == { "intesa_sanpaolo_ufficiale", "intesa_sanpaolo", "bnl", "bnl_lista_movimenti", + "bnl_pos", } diff --git a/tests/test_templates_bnl_pos.py b/tests/test_templates_bnl_pos.py new file mode 100644 index 0000000..20503d1 --- /dev/null +++ b/tests/test_templates_bnl_pos.py @@ -0,0 +1,250 @@ +"""Tests per BNLPosTemplate (ec_converter/templates/bnl_pos.py). + +Verifica: +- Estrazione movimenti dai 3 formati colonne prodotti dall'OCR (7/5/6 col) +- Completamento anno per date DD/MM prive di anno +- Normalizzazione date DD/MM/YY a 2 cifre +- Assemblaggio descrizione da colonne multiple +- "da POS" nel campo dare viene accodato alla descrizione, non scartato +- Righe saldo/vuote escluse correttamente +- Tabelle non-movimenti (Riepilogo, Scalare) ignorate +- detect_bank riconosce "finanziamenti" → bnl_pos +""" + +import pytest + +from templates import detect_bank, get_template, list_templates +from templates.bnl_pos import BNLPosTemplate + +# --------------------------------------------------------------------------- +# Fixture HTML — basate sull'output OCR reale, dati anonimizzati +# --------------------------------------------------------------------------- + +HTML_PAG1_7COL = """ + + + + + + + + + + + + + + + + +
DataValutaSaldo inizialeDescrizioneOperazioniDareAvere
31/12/202510.000,00
07/01/202607/01/26incasso pagamentoeseguitida POS346,95
07/01/202607/01/26incasso pagamentoeseguitida POS474,85
16/01/202613/12/25SpeseiniziativeLettere cont.al 31.12.25
+""" + +HTML_PAG2_5COL = """ + + + + + + + + + + + + +
DataValutaDescrizione OperazioniDareAvere
06/0206/02/26incasso pagamentoda POS1.223,70
13/0231/01/26Spese invio documenti Lettere cont. al 31.01.263,90
16/0216/02/26Giroconto da nostra APAC F/P per RICHIESTA DI DISP45.000,00
+""" + +HTML_PAG3_6COL = """ + + + + + + + + + +
DataValutaDescrizioneOperazioniDareAvere
16/0316/03/26incasso pagamentoeseguiti da POS1.294,45
31/03Saldo finale80.784,93
+""" + +HTML_NON_MOVIMENTI = """ + + + +
Saldo Iniziale al:Totale Entrate:Totale Uscite:Saldo Finale al:
02/01/202631/03/2026
+ + + +
interessi creditoriDecorrenzaTasso
01/01/264,82
+""" + + +# --------------------------------------------------------------------------- +# Test: detect_bank e registry +# --------------------------------------------------------------------------- + + +def test_detect_bank_riconosce_finanziamenti(): + assert detect_bank("Finanziamenti\nResoconto n. 1 al 31/03/2026") == "bnl_pos" + + +def test_detect_bank_finanziamenti_non_confonde_bnl_cc(): + # Un estratto conto BNL normale (senza "finanziamenti") → "bnl" + assert detect_bank("Estratto conto BNL BNP Paribas") == "bnl" + + +def test_bnl_pos_in_list_templates(): + assert "bnl_pos" in list_templates() + + +def test_get_template_bnl_pos(): + t = get_template("bnl_pos") + assert isinstance(t, BNLPosTemplate) + assert t.name == "bnl_pos" + + +# --------------------------------------------------------------------------- +# Test: pagina 1 (7 colonne) +# --------------------------------------------------------------------------- + + +def test_pag1_7col_conta_movimenti(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL]) + # saldo iniziale (desc vuota) escluso → 3 movimenti (2 incassi + 1 spese) + assert len(movs) == 3 + + +def test_pag1_7col_incasso_avere(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL]) + incassi = [m for m in movs if m.avere is not None] + assert len(incassi) == 2 + assert incassi[0].avere == pytest.approx(346.95) + assert incassi[1].avere == pytest.approx(474.85) + + +def test_pag1_7col_incasso_descrizione_assemblata(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL]) + incasso = movs[0] + assert "incasso pagamento" in incasso.descrizione_raw.lower() + assert "da pos" in incasso.descrizione_raw.lower() + + +def test_pag1_7col_data_formato_completo(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL]) + assert movs[0].data_operazione == "07.01.2026" + + +def test_pag1_7col_spese_dare_spill_in_descrizione(): + """'al 31.12.25' che l'OCR mette nel campo Dare finisce nella descrizione.""" + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL]) + spese = movs[2] + assert "al 31.12.25" in spese.descrizione_raw + assert spese.dare is None + assert spese.avere is None + + +# --------------------------------------------------------------------------- +# Test: pagina 2 (5 colonne, date DD/MM) +# --------------------------------------------------------------------------- + + +def test_pag2_5col_conta_movimenti(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG2_5COL]) + assert len(movs) == 3 + + +def test_pag2_5col_data_completata_con_anno_valuta(): + """Data DD/MM viene completata con l'anno estratto dalla data valuta DD/MM/YY.""" + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG2_5COL]) + assert movs[0].data_operazione == "06.02.2026" + + +def test_pag2_5col_incasso_da_pos_in_descrizione(): + """'da POS' nel campo Dare (spill OCR) viene accodato alla descrizione.""" + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG2_5COL]) + incasso = movs[0] + assert "da pos" in incasso.descrizione_raw.lower() + assert incasso.avere == pytest.approx(1223.70) + assert incasso.dare is None + + +def test_pag2_5col_spese_dare_numerico(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG2_5COL]) + spese = movs[1] + assert spese.dare == pytest.approx(3.90) + assert spese.avere is None + + +def test_pag2_5col_giroconto_dare(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG2_5COL]) + giroconto = movs[2] + assert giroconto.dare == pytest.approx(45000.00) + assert giroconto.avere is None + + +# --------------------------------------------------------------------------- +# Test: pagina 3 (6 colonne, saldo finale escluso) +# --------------------------------------------------------------------------- + + +def test_pag3_6col_conta_movimenti(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG3_6COL]) + # saldo finale escluso → 1 movimento + assert len(movs) == 1 + + +def test_pag3_6col_incasso_avere(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG3_6COL]) + assert movs[0].avere == pytest.approx(1294.45) + + +def test_pag3_6col_descrizione_da_due_celle(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG3_6COL]) + desc = movs[0].descrizione_raw.lower() + assert "incasso pagamento" in desc + assert "eseguiti da pos" in desc + + +# --------------------------------------------------------------------------- +# Test: tabelle non-movimenti ignorate +# --------------------------------------------------------------------------- + + +def test_tabelle_non_movimenti_ignorate(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_NON_MOVIMENTI]) + assert movs == [] + + +# --------------------------------------------------------------------------- +# Test: pagine multiple +# --------------------------------------------------------------------------- + + +def test_pagine_multiple_sommano_movimenti(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL, HTML_PAG2_5COL, HTML_PAG3_6COL]) + # pag1: 3, pag2: 3, pag3: 1 + assert len(movs) == 7 + + +def test_pagine_multiple_numerazione_pagina(): + t = BNLPosTemplate() + movs = t.estrai_movimenti([HTML_PAG1_7COL, HTML_PAG2_5COL, HTML_PAG3_6COL]) + pagine = {m.pagina for m in movs} + assert pagine == {1, 2, 3} From ecb045dc51432f0ac64b849d4b59a88707d31068 Mon Sep 17 00:00:00 2001 From: balbettoni Date: Fri, 26 Jun 2026 14:36:48 +0200 Subject: [PATCH 4/5] docs(readme): add Italian section for commercialisti, fix clone URL, list supported banks - Add Italian-language intro targeting Italian accounting firms using Ago Zucchetti - Fix critical bug: clone URL pointed to non-existent repo ai-ocr-stack - Add supported banks table in both IT and EN sections (5 banks: Intesa x2, BNL x3) - Add LinkedIn link in Author section Co-Authored-By: Claude Sonnet 4.6 --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ddc6e8c..1d79561 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,35 @@ Built around [dots.ocr](https://huggingface.co/rednote-hilab/dots.ocr), a specia --- +## 🇮🇹 Per studi commercialisti — Estratti conto PDF → Ago Zucchetti + +Inserire manualmente le righe dell'estratto conto in Ago Zucchetti richiede decine di minuti per documento. +Questo strumento converte il PDF direttamente in un CSV pronto per l'import, con causali già assegnate. + +### Banche supportate + +| Banca | Formato | +|---|---| +| Intesa Sanpaolo | Layout ufficiale (con riepilogo saldi) | +| Intesa Sanpaolo | Formato generico | +| BNL | Standard con causale ABI | +| BNL | Lista Movimenti | +| BNL | Rendiconto POS / finanziamenti | + +### Come funziona + +1. Carica il PDF dell'estratto conto nell'interfaccia web (porta 8224) +2. Il sistema riconosce la banca automaticamente e assegna le causali +3. Revisiona le transazioni nella tabella (modificabile) +4. Scarica il CSV — pronto per l'import in Ago Zucchetti + +L'elaborazione avviene **interamente in locale**: nessun documento viene inviato a servizi esterni. + +> **Hai bisogno di supporto o di un template per la tua banca?** +> Contattami su [LinkedIn](https://www.linkedin.com/in/riccardo-bettoni-fi/) + +--- + ## What it does ### EC Converter (Estratto Conto) @@ -98,8 +127,8 @@ Watches an input folder and automatically OCRs any PDF dropped in: ### 1. Clone and start ```bash -git clone https://github.com/bertorico/ai-ocr-stack -cd ai-ocr-stack +git clone https://github.com/bertorico/pdf2conta +cd pdf2conta docker compose up -d ``` @@ -135,9 +164,15 @@ curl http://localhost:8222/health # dots-ocr vLLM ### Supported banks -Add new banks by creating a template class in `ec_converter/templates/`. +| Bank | Template | Notes | +|---|---|---| +| Intesa Sanpaolo | `intesa_sanpaolo_ufficiale` | Official layout with balance summary | +| Intesa Sanpaolo | `intesa_sanpaolo` | Generic fallback | +| BNL | `bnl` | Includes ABI causale column | +| BNL | `bnl_lista_movimenti` | "Lista movimenti" variant | +| BNL | `bnl_pos` | POS / financing statement | -Each template implements `estrai_movimenti(pages_html: list[str]) -> list[Movimento]`. +Auto-detected from the first page. To add a new bank, create a template class in `ec_converter/templates/` implementing `estrai_movimenti(pages_html: list[str]) -> list[Movimento]`. ### Causali configuration @@ -295,6 +330,5 @@ MIT ## Author -[@bertorico](https://github.com/bertorico) — self-hosted AI, Italian fiscal domain, n8n automation. - -*Built for real Italian accounting workflows with Ago Zucchetti.* +[@bertorico](https://github.com/bertorico) — self-hosted AI, Italian fiscal domain, n8n automation. +[LinkedIn](https://www.linkedin.com/in/riccardo-bettoni-fi/) · *Built for real Italian accounting workflows with Ago Zucchetti.* From 841eae5c41d4fdd242ea38aa4f45c2c41ac6f4aa Mon Sep 17 00:00:00 2001 From: balbettoni Date: Fri, 26 Jun 2026 16:24:27 +0200 Subject: [PATCH 5/5] security: add *.pdf to .gitignore to prevent real data commits Real bank statement PDFs must never be tracked. The e_c/ rule protected that directory but PDFs in repo root were unguarded. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dbc0e19..9fb58a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # ========= DATI SENSIBILI / LOCALI (repo pubblico: MAI committare) ========= # Estratti conto e fatture reali +*.pdf e_c/ fatture_converter/e_fatture/ fatture_converter/output/