diff --git a/.gitignore b/.gitignore
index a8e9c17..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/
@@ -33,6 +34,7 @@ secrets/
credentials.json
.claude
+.playwright-mcp/
*.tar.gz
*.zip
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.*
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 = """
+
+"""
+
+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"""
+
+
+
+
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)
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/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/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_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
# ---------------------------------------------------------------------------
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 = """
+
+
+
+ | Data | Valuta | Saldo iniziale |
+ Descrizione | Operazioni | Dare | Avere |
+
+
+
+
+ | 31/12/2025 | | | | | 10.000,00 | |
+
+ | 07/01/2026 | 07/01/26 | incasso pagamento | eseguiti | da POS | | 346,95 |
+ | 07/01/2026 | 07/01/26 | incasso pagamento | eseguiti | da POS | | 474,85 |
+
+ | 16/01/2026 | 13/12/25 | Spese | iniziative | Lettere cont. | al 31.12.25 | |
+
+
+"""
+
+HTML_PAG2_5COL = """
+
+
+ | Data | Valuta | Descrizione Operazioni | Dare | Avere |
+
+
+
+ | 06/02 | 06/02/26 | incasso pagamento | da POS | 1.223,70 |
+
+ | 13/02 | 31/01/26 | Spese invio documenti Lettere cont. al 31.01.26 | 3,90 | |
+
+ | 16/02 | 16/02/26 | Giroconto da nostra APAC F/P per RICHIESTA DI DISP | 45.000,00 | |
+
+
+"""
+
+HTML_PAG3_6COL = """
+
+
+ | Data | Valuta | Descrizione | Operazioni | Dare | Avere |
+
+
+ | 16/03 | 16/03/26 | incasso pagamento | eseguiti da POS | | 1.294,45 |
+
+ | 31/03 | | Saldo finale | | 80.784,93 | |
+
+
+"""
+
+HTML_NON_MOVIMENTI = """
+
+ | Saldo Iniziale al: | Totale Entrate: | Totale Uscite: | Saldo Finale al: |
+ | 02/01/2026 | | | 31/03/2026 |
+
+
+ | interessi creditori | Decorrenza | Tasso |
+ | 01/01/26 | 4,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}