Adauga comanda /tranzactii pentru vizualizare tranzactii interactive

- Sistem interactiv de selectie conturi cu state management
- Filtrare pe perioade: [nr cont] [nr zile] (ex: 2 30 = ultimele 30 zile)
- Format compact: tranzactii grupate pe date, fara sold
- Extragere inteligenta comercianti din platile POS (TID pattern)
- Escape automat caractere speciale Markdown pentru Telegram
- Timeout 5 minute pentru sesiuni de selectie
- Suport comenzi: doar numar = ultimele 10, numar + zile = filtrare

Corectari:
- Fix import timedelta pentru filtrare pe date
- Fix conflict nume variabila period vs csv_period
- Fix Markdown parsing errors (underscore, dot, etc)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 14:32:56 +02:00
parent 91021fa530
commit 58399e25fb

View File

@@ -12,7 +12,7 @@ import json
import csv
import zipfile
from pathlib import Path
from datetime import datetime
from datetime import datetime, timedelta
import glob
import requests
from dotenv import load_dotenv
@@ -51,6 +51,9 @@ class TelegramTriggerBot:
self.last_update_id = 0
self.poll_timeout = POLL_TIMEOUT
# State management pentru selecție interactivă conturi
self.pending_account_selection = {} # {user_id: {'accounts': [...], 'timestamp': ...}}
if not self.bot_token:
raise ValueError("TELEGRAM_BOT_TOKEN nu este setat în .env!")
@@ -70,6 +73,7 @@ class TelegramTriggerBot:
{"command": "scrape_zip", "description": "Rulează scraper + trimite ZIP"},
{"command": "scrape_solduri", "description": "Extrage doar soldurile (fără CSV)"},
{"command": "solduri", "description": "Afișează ultimul fișier solduri"},
{"command": "tranzactii", "description": "Afișează tranzacții recente din cont"},
{"command": "zip", "description": "Trimite ultimele fișiere ca ZIP"},
{"command": "status", "description": "Status sistem"},
{"command": "help", "description": "Ajutor comenzi"}
@@ -318,6 +322,321 @@ class TelegramTriggerBot:
logging.error(f"Eroare show_cached_balances: {e}", exc_info=True)
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
def show_transactions_menu(self, chat_id, user_id, reply_to_message_id=None):
"""Afișează meniu cu conturi disponibile pentru selecție tranzacții"""
try:
data_dir = Path('data')
if not data_dir.exists():
self.send_message(chat_id, "*EROARE*\n\nDirectorul 'data' nu există!", reply_to_message_id)
return
# Găsește toate fișierele de tranzacții
transaction_files = sorted(
data_dir.glob('tranzactii_*.csv'),
key=lambda x: x.stat().st_mtime,
reverse=True
)
if not transaction_files:
self.send_message(
chat_id,
"*EROARE*\n\nNu s-au găsit fișiere cu tranzacții!\n\nRulează mai întâi /scrape pentru a descărca tranzacții.",
reply_to_message_id
)
return
# Extrage nume conturi unice din numele fișierelor
# Format: tranzactii_Nume_Cont_YYYY-MM-DD_HH-MM-SS.csv
accounts = {}
for file_path in transaction_files:
filename = file_path.stem # fără extensie
# Elimină prefixul "tranzactii_" și sufixul timestamp
# rsplit('_', 3) split-uiește: ['Nume', 'Cont', 'YYYY-MM-DD', 'HH-MM-SS']
parts = filename.replace('tranzactii_', '').rsplit('_', 3)
if len(parts) >= 4:
# parts[0] = nume de bază, parts[1] = număr cont
account_name = f"{parts[0]}_{parts[1]}" # "Nume_Cont"
# Convertește Nume_Cont → Nume Cont
display_name = account_name.replace('_', ' ')
# Păstrează doar cel mai recent fișier pentru fiecare cont
if display_name not in accounts:
accounts[display_name] = file_path
if not accounts:
self.send_message(
chat_id,
"*EROARE*\n\nNu s-au putut procesa fișierele de tranzacții!",
reply_to_message_id
)
return
# Sortează conturile alfabetic
sorted_accounts = sorted(accounts.items())
# Construiește mesaj
message = "*TRANZACTII BANCARE*\n\n"
message += "Conturi disponibile:\n\n"
for idx, (account_name, file_path) in enumerate(sorted_accounts, 1):
message += f"{idx}. {account_name}\n"
message += f"\n*Scrie numarul contului (1-{len(sorted_accounts)}):*\n"
message += " • Doar numar (ex: 2) = ultimele 10\n"
message += " • Numar + zile (ex: 2 7, 2 30)"
# Salvează starea pentru user
self.pending_account_selection[user_id] = {
'accounts': sorted_accounts,
'timestamp': datetime.now().timestamp()
}
self.send_message(chat_id, message, reply_to_message_id)
logging.info(f"Afișat meniu tranzacții pentru user {user_id}: {len(sorted_accounts)} conturi")
except Exception as e:
logging.error(f"Eroare show_transactions_menu: {e}", exc_info=True)
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
def show_account_transactions(self, chat_id, user_id, account_index, reply_to_message_id=None, period="10"):
"""Afișează tranzacții pentru contul selectat (perioada: 10/luna/sapt)"""
try:
# Verifică dacă userul are o selecție pending
if user_id not in self.pending_account_selection:
self.send_message(
chat_id,
"*EROARE*\n\nNu există selecție activă. Folosește /tranzactii pentru a începe.",
reply_to_message_id
)
return
pending_data = self.pending_account_selection[user_id]
accounts = pending_data['accounts']
# Verifică timeout (5 minute)
if datetime.now().timestamp() - pending_data['timestamp'] > 300:
del self.pending_account_selection[user_id]
self.send_message(
chat_id,
"*TIMEOUT*\n\nSelecția a expirat. Folosește /tranzactii pentru a începe din nou.",
reply_to_message_id
)
return
# Verifică index valid
if account_index < 1 or account_index > len(accounts):
self.send_message(
chat_id,
f"*EROARE*\n\nNumăr invalid! Scrie un număr între 1 și {len(accounts)}.",
reply_to_message_id
)
return
# Obține contul selectat
account_name, csv_path = accounts[account_index - 1]
# Verifică dacă fișierul există
if not csv_path.exists():
del self.pending_account_selection[user_id]
self.send_message(
chat_id,
f"*EROARE*\n\nFișierul pentru contul {account_name} nu mai există!",
reply_to_message_id
)
return
# Citește CSV-ul
# Format BT: Primele 17 linii = metadata, linia 18 = header, linia 19+ = date
transactions = []
account_iban = ""
csv_period = "" # Perioada din CSV (nu parametrul funcției!)
with open(csv_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Extrage metadate
if len(lines) > 8:
# Linia 9: Perioada:,29.10.2025-12.11.2025
period_line = lines[8].strip()
if 'Perioada:' in period_line:
csv_period = period_line.split(',')[1] if ',' in period_line else ""
# Linia 8: Numar cont:,RO32BTRLRONCRT0637236701 RON
iban_line = lines[7].strip()
if 'Numar cont:' in iban_line:
account_iban = iban_line.split(',')[1] if ',' in iban_line else ""
# Citește tranzacțiile (de la header - linia 18, index 17)
if len(lines) > 17:
csv_content = ''.join(lines[17:]) # Include header + date
reader = csv.DictReader(csv_content.splitlines())
for row in reader:
if row.get('Data tranzactie'): # Skip linii goale
transactions.append(row)
# Șterge selecția pending
del self.pending_account_selection[user_id]
if not transactions:
self.send_message(
chat_id,
f"*{account_name}*\n\nNu există tranzacții în acest fișier.",
reply_to_message_id
)
return
# Filtrează tranzacțiile în funcție de perioada selectată (nr zile)
from collections import defaultdict
recent_transactions = []
period_description = ""
# Convertește period în număr de zile
try:
num_days = int(period)
except (ValueError, TypeError):
num_days = 10 # Default
if num_days <= 0:
num_days = 10 # Sigură
# Filtrează pe bază de zile
if num_days == 10:
# Optimizare: ultimele 10 tranzacții direct
recent_transactions = transactions[:10]
period_description = "Ultimele 10 tranzactii"
else:
# Filtrare pe bază de dată
now = datetime.now()
cutoff_date = now - timedelta(days=num_days)
for tx in transactions:
tx_date_str = tx.get('Data tranzactie', '')
if tx_date_str:
try:
tx_date = datetime.strptime(tx_date_str, '%Y-%m-%d')
if tx_date >= cutoff_date:
recent_transactions.append(tx)
except ValueError:
continue
period_description = f"Ultimele {num_days} zile"
# Grupează tranzacțiile pe date
transactions_by_date = defaultdict(list)
for tx in recent_transactions:
date = tx.get('Data tranzactie', '')
if date:
transactions_by_date[date].append(tx)
# Construiește mesaj
message = f"*TRANZACTII - {account_name}*\n\n"
if period_description:
message += f"Perioada: {period_description}\n"
message += f"Total: {len(recent_transactions)} tranzactii\n"
message += "=" * 30 + "\n\n"
# Sortează datele descrescător (cele mai recente primul)
sorted_dates = sorted(transactions_by_date.keys(), reverse=True)
for date in sorted_dates:
# Header pentru dată
message += f"*{date}*\n"
# Tranzacțiile din acea zi
for tx in transactions_by_date[date]:
description = tx.get('Descriere', '')
debit = tx.get('Debit', '').strip().replace('"', '').replace(',', '')
credit = tx.get('Credit', '').strip().replace('"', '').replace(',', '')
# Extrage nume mai inteligent din descriere
display_name = description
desc_parts = description.split(';')
if len(desc_parts) > 2:
# Caz special: Plăți POS - extrage comerciantul din Parts[1]
if 'POS' in desc_parts[0] and len(desc_parts) > 1:
# Căutăm pattern: "TID:XXXXXXX <COMERCIANT> <REST>"
import re
tid_match = re.search(r'TID:\S+\s+(.+?)\s{2,}', desc_parts[1])
if tid_match:
candidate = tid_match.group(1).strip()
else:
# Fallback: încearcă să extragă după TID până la două spații
if 'TID:' in desc_parts[1]:
after_tid = desc_parts[1].split('TID:')[1]
# Skip ID-ul TID și ia textul până la două spații consecutive
parts_after = after_tid.split(None, 1) # Split la primul spațiu
if len(parts_after) > 1:
# Ia textul până la " " sau până la sfârșitul
merchant_text = parts_after[1]
double_space_idx = merchant_text.find(' ')
if double_space_idx > 0:
candidate = merchant_text[:double_space_idx].strip()
else:
candidate = merchant_text.strip()
else:
candidate = desc_parts[2].strip()
else:
candidate = desc_parts[2].strip()
else:
# Încearcă part[2] (de obicei numele)
candidate = desc_parts[2].strip()
# Dacă part[2] este doar număr/scurt/REF, încearcă part[3]
if (candidate.isdigit() or
len(candidate) < 3 or
candidate.startswith('REF:')):
if len(desc_parts) > 3:
candidate = desc_parts[3].strip()
# Dacă tot e invalid, folosește tipul tranzacției (part[0])
if len(candidate) < 3 or candidate.startswith('REF:'):
candidate = desc_parts[0].strip()
display_name = candidate
# Truncate dacă prea lung
if len(display_name) > 35:
display_name = display_name[:32] + "..."
# Escape caractere speciale Markdown pentru Telegram
# Caracterele care trebuie escapate: _ * [ ] ( ) ~ ` > # + - = | { } . !
markdown_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
for char in markdown_chars:
display_name = display_name.replace(char, '\\' + char)
# Determină suma (debit poate avea deja minus în CSV)
if credit:
amount_str = f"+{credit}"
elif debit:
# Elimină minus-ul dacă există deja
debit_clean = debit.lstrip('-')
amount_str = f"-{debit_clean}"
else:
amount_str = "0.00"
# Format compact: " • Nume: +suma RON"
message += f" {display_name}: {amount_str} RON\n"
message += "\n"
self.send_message(chat_id, message, reply_to_message_id)
logging.info(f"Afișat {len(recent_transactions)} tranzacții pentru {account_name}")
except Exception as e:
# Curăță selecția în caz de eroare
if user_id in self.pending_account_selection:
del self.pending_account_selection[user_id]
logging.error(f"Eroare show_account_transactions: {e}", exc_info=True)
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
def send_zip_files(self, chat_id, reply_to_message_id=None):
"""Trimite ultimele fișiere ca arhivă ZIP"""
try:
@@ -471,6 +790,25 @@ class TelegramTriggerBot:
self.send_message(chat_id, "*ACCES INTERZIS*\n\nNu ai permisiunea sa folosesti acest bot.", message_id)
return
# Procesează răspuns numeric pentru selecție cont tranzacții
if user_id in self.pending_account_selection:
# Parse număr cont + opțional nr zile
# Formate acceptate: "2" (default 10), "2 7" (7 zile), "2 30" (30 zile)
parts = text.strip().split()
if len(parts) >= 1 and parts[0].isdigit():
account_index = int(parts[0])
# Determină numărul de zile
period = "10" # default: ultimele 10 tranzacții
if len(parts) >= 2 and parts[1].isdigit():
period = parts[1] # număr de zile
logging.info(f"Răspuns selecție cont: {account_index}, perioada: {period} zile de la user {user_id}")
self.show_account_transactions(chat_id, user_id, account_index, message_id, period)
return
# Procesează comenzi
if text == '/start':
welcome_msg = "*BTGO Scraper Trigger Bot*\n\n"
@@ -482,6 +820,7 @@ class TelegramTriggerBot:
"`/scrape_zip` - Ruleaza scraper + trimite ZIP\n"
"`/scrape_solduri` - Extrage doar soldurile (rapid)\n"
"`/solduri` - Afiseaza ultimul fisier solduri\n"
"`/tranzactii` - Afiseaza tranzactii recente\n"
"`/zip` - Trimite ultimele fisiere ca ZIP\n"
"`/status` - Status sistem\n"
"`/help` - Ajutor"
@@ -504,6 +843,10 @@ class TelegramTriggerBot:
logging.info(f"Comandă /solduri primită în {context}")
self.show_cached_balances(chat_id, message_id)
elif text == '/tranzactii':
logging.info(f"Comandă /tranzactii primită în {context}")
self.show_transactions_menu(chat_id, user_id, message_id)
elif text == '/zip':
logging.info(f"Comandă /zip primită în {context}")
self.send_zip_files(chat_id, message_id)
@@ -539,6 +882,7 @@ class TelegramTriggerBot:
"`/scrape_zip` - Ruleaza scraper + trimite arhiva ZIP\n"
"`/scrape_solduri` - Extrage doar soldurile (fara CSV tranzactii)\n"
"`/solduri` - Afiseaza ultimul fisier solduri (instant)\n"
"`/tranzactii` - Afiseaza tranzactii recente din cont (interactiv)\n"
"`/zip` - Trimite ultimele fisiere ca arhiva ZIP (fara scraping)\n"
"`/status` - Informatii sistem\n"
"`/help` - Acest mesaj\n\n"