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 csv
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
import glob import glob
import requests import requests
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -51,6 +51,9 @@ class TelegramTriggerBot:
self.last_update_id = 0 self.last_update_id = 0
self.poll_timeout = POLL_TIMEOUT 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: if not self.bot_token:
raise ValueError("TELEGRAM_BOT_TOKEN nu este setat în .env!") 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_zip", "description": "Rulează scraper + trimite ZIP"},
{"command": "scrape_solduri", "description": "Extrage doar soldurile (fără CSV)"}, {"command": "scrape_solduri", "description": "Extrage doar soldurile (fără CSV)"},
{"command": "solduri", "description": "Afișează ultimul fișier solduri"}, {"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": "zip", "description": "Trimite ultimele fișiere ca ZIP"},
{"command": "status", "description": "Status sistem"}, {"command": "status", "description": "Status sistem"},
{"command": "help", "description": "Ajutor comenzi"} {"command": "help", "description": "Ajutor comenzi"}
@@ -318,6 +322,321 @@ class TelegramTriggerBot:
logging.error(f"Eroare show_cached_balances: {e}", exc_info=True) 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) 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): def send_zip_files(self, chat_id, reply_to_message_id=None):
"""Trimite ultimele fișiere ca arhivă ZIP""" """Trimite ultimele fișiere ca arhivă ZIP"""
try: try:
@@ -471,6 +790,25 @@ class TelegramTriggerBot:
self.send_message(chat_id, "*ACCES INTERZIS*\n\nNu ai permisiunea sa folosesti acest bot.", message_id) self.send_message(chat_id, "*ACCES INTERZIS*\n\nNu ai permisiunea sa folosesti acest bot.", message_id)
return 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 # Procesează comenzi
if text == '/start': if text == '/start':
welcome_msg = "*BTGO Scraper Trigger Bot*\n\n" welcome_msg = "*BTGO Scraper Trigger Bot*\n\n"
@@ -482,6 +820,7 @@ class TelegramTriggerBot:
"`/scrape_zip` - Ruleaza scraper + trimite ZIP\n" "`/scrape_zip` - Ruleaza scraper + trimite ZIP\n"
"`/scrape_solduri` - Extrage doar soldurile (rapid)\n" "`/scrape_solduri` - Extrage doar soldurile (rapid)\n"
"`/solduri` - Afiseaza ultimul fisier solduri\n" "`/solduri` - Afiseaza ultimul fisier solduri\n"
"`/tranzactii` - Afiseaza tranzactii recente\n"
"`/zip` - Trimite ultimele fisiere ca ZIP\n" "`/zip` - Trimite ultimele fisiere ca ZIP\n"
"`/status` - Status sistem\n" "`/status` - Status sistem\n"
"`/help` - Ajutor" "`/help` - Ajutor"
@@ -504,6 +843,10 @@ class TelegramTriggerBot:
logging.info(f"Comandă /solduri primită în {context}") logging.info(f"Comandă /solduri primită în {context}")
self.show_cached_balances(chat_id, message_id) 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': elif text == '/zip':
logging.info(f"Comandă /zip primită în {context}") logging.info(f"Comandă /zip primită în {context}")
self.send_zip_files(chat_id, message_id) self.send_zip_files(chat_id, message_id)
@@ -539,6 +882,7 @@ class TelegramTriggerBot:
"`/scrape_zip` - Ruleaza scraper + trimite arhiva ZIP\n" "`/scrape_zip` - Ruleaza scraper + trimite arhiva ZIP\n"
"`/scrape_solduri` - Extrage doar soldurile (fara CSV tranzactii)\n" "`/scrape_solduri` - Extrage doar soldurile (fara CSV tranzactii)\n"
"`/solduri` - Afiseaza ultimul fisier solduri (instant)\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" "`/zip` - Trimite ultimele fisiere ca arhiva ZIP (fara scraping)\n"
"`/status` - Informatii sistem\n" "`/status` - Informatii sistem\n"
"`/help` - Acest mesaj\n\n" "`/help` - Acest mesaj\n\n"