Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot
Modern ERP Reports Application with microservices architecture Tech Stack: - Backend: FastAPI + python-oracledb (Oracle DB integration) - Frontend: Vue.js 3 + PrimeVue + Vite - Telegram Bot: python-telegram-bot + SQLite - Infrastructure: Shared database pool, JWT authentication, SSH tunnel Features: - FastAPI backend with async Oracle connection pool - Vue.js 3 responsive frontend with PrimeVue components - Telegram bot alternative interface - Microservices architecture with shared components - Complete deployment support (Linux Docker + Windows IIS) - Comprehensive testing (Playwright E2E + pytest) Repository Structure: - reports-app/ - Main application (backend, frontend, telegram-bot) - shared/ - Shared components (database pool, auth, utils) - deployment/ - Deployment scripts (Linux & Windows) - docs/ - Project documentation - security/ - Security scanning and git hooks
This commit is contained in:
0
reports-app/telegram-bot/app/bot/__init__.py
Normal file
0
reports-app/telegram-bot/app/bot/__init__.py
Normal file
515
reports-app/telegram-bot/app/bot/formatters.py
Normal file
515
reports-app/telegram-bot/app/bot/formatters.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Response formatters for bot commands.
|
||||
Formats API responses into user-friendly Telegram messages.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
def format_dashboard_response(data: Dict[str, Any], company_name: str = None) -> str:
|
||||
"""
|
||||
Format dashboard data for Telegram (content only, no header).
|
||||
|
||||
Note: company_name parameter kept for backwards compatibility but not used.
|
||||
Use format_response_with_company() in handlers to add company header.
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Sold total trezorerie (casa + banca) - rotunjit la leu
|
||||
treasury_totals = data.get('treasury_totals_by_currency', {})
|
||||
sold_trezorerie = round(float(treasury_totals.get('RON', 0)))
|
||||
text += f"**Sold Trezorerie:** {sold_trezorerie:,} RON\n\n"
|
||||
|
||||
# Sold Clienți - rotunjit la leu
|
||||
clienti_sold = round(float(data.get('clienti_sold_total', 0)))
|
||||
clienti_in_termen = round(float(data.get('clienti_sold_in_termen', 0)))
|
||||
clienti_restant = round(float(data.get('clienti_sold_restant', 0)))
|
||||
|
||||
text += f"**Sold Clienți:** {clienti_sold:,} RON\n"
|
||||
text += f" - În termen: {clienti_in_termen:,} RON\n"
|
||||
text += f" - Restanță: {clienti_restant:,} RON\n\n"
|
||||
|
||||
# Sold Furnizori BRUT (pentru consistență cu detaliile) - rotunjit la leu
|
||||
furnizori_in_termen = round(float(data.get('furnizori_sold_in_termen', 0)))
|
||||
furnizori_restant = round(float(data.get('furnizori_sold_restant', 0)))
|
||||
furnizori_sold_brut = furnizori_in_termen + furnizori_restant
|
||||
furnizori_avansuri = round(float(data.get('furnizori_avansuri', 0)))
|
||||
furnizori_sold_net = round(float(data.get('furnizori_sold_total', 0)))
|
||||
|
||||
text += f"**Sold Furnizori:** {furnizori_sold_brut:,} RON\n"
|
||||
text += f" - În termen: {furnizori_in_termen:,} RON\n"
|
||||
text += f" - Restanță: {furnizori_restant:,} RON\n"
|
||||
if furnizori_avansuri != 0:
|
||||
text += f" - Avansuri: {furnizori_avansuri:,} RON\n"
|
||||
text += f" - Net (după avansuri): {furnizori_sold_net:,} RON"
|
||||
else:
|
||||
text += f" - Net: {furnizori_sold_net:,} RON"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_invoices_response(
|
||||
invoices: List[Dict[str, Any]],
|
||||
company_name: str = None,
|
||||
limit: int = 10
|
||||
) -> str:
|
||||
"""
|
||||
Format invoices list for Telegram - COMPACT TABLE FORMAT.
|
||||
|
||||
Args:
|
||||
invoices: List of invoice dicts
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
limit: Maximum number of invoices to display
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram (compact, no emojis)
|
||||
"""
|
||||
if not invoices:
|
||||
return "Nu s-au gasit facturi cu aceste criterii."
|
||||
|
||||
# Header (o singură dată)
|
||||
text = f"**Facturi** ({len(invoices)} total)\n\n"
|
||||
text += "Nr | Client | Suma | Status\n"
|
||||
text += "---|--------|------|-------\n"
|
||||
|
||||
# Lista facturi - compact, o linie per factură
|
||||
for idx, inv in enumerate(invoices[:limit], 1):
|
||||
seria = inv.get('seria', '')
|
||||
numar = inv.get('numar', '')
|
||||
client = inv.get('client', 'N/A')
|
||||
suma = inv.get('suma_totala', 0)
|
||||
status = inv.get('status', 'N/A')
|
||||
|
||||
# Truncate long client names for compact display
|
||||
client_short = client[:20] + "..." if len(client) > 20 else client
|
||||
|
||||
# Status marker (no emoji)
|
||||
status_marker = "PLATIT" if status == "platit" else "NEPLATIT"
|
||||
|
||||
text += f"{seria}{numar} | {client_short} | {suma:,.0f} | {status_marker}\n"
|
||||
|
||||
if len(invoices) > limit:
|
||||
text += f"\n+{len(invoices) - limit} facturi"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# FAZA 2: New Formatter Functions for Button Interface
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def format_treasury_casa_response(data: Dict[str, Any], company_name: str = None) -> str:
|
||||
"""
|
||||
Format treasury CASH data for Telegram (content only, no header).
|
||||
|
||||
Args:
|
||||
data: Dict with casa accounts and total from treasury breakdown
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
data = {'accounts': [...], 'total': 5000}
|
||||
text = format_treasury_casa_response(data)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Total cash balance - rotunjit la leu (0 zecimale)
|
||||
total_cash = round(data.get('total', 0))
|
||||
text += f"**Sold Total Cash:** {total_cash:,} RON\n\n"
|
||||
|
||||
# Cash accounts
|
||||
casa_accounts = data.get('accounts', [])
|
||||
if casa_accounts:
|
||||
text += "**Conturi de Casa:**\n"
|
||||
for acc in casa_accounts[:5]: # Max 5
|
||||
name = acc.get('name', 'N/A')
|
||||
balance = round(acc.get('balance', 0))
|
||||
text += f" - {name}: {balance:,} RON\n"
|
||||
|
||||
if len(casa_accounts) > 5:
|
||||
text += f" ... si inca {len(casa_accounts) - 5} conturi"
|
||||
else:
|
||||
text += "Nu exista conturi de casa configurate."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_treasury_banca_response(data: Dict[str, Any], company_name: str = None) -> str:
|
||||
"""
|
||||
Format treasury BANK data for Telegram (content only, no header).
|
||||
|
||||
Args:
|
||||
data: Dict with banca accounts and total from treasury breakdown
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
data = {'accounts': [...], 'total': 15000}
|
||||
text = format_treasury_banca_response(data)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Total bank balance - rotunjit la leu (0 zecimale)
|
||||
total_bank = round(data.get('total', 0))
|
||||
text += f"**Sold Total Banca:** {total_bank:,} RON\n\n"
|
||||
|
||||
# Bank accounts
|
||||
bank_accounts = data.get('accounts', [])
|
||||
if bank_accounts:
|
||||
text += "**Conturi Bancare:**\n"
|
||||
for acc in bank_accounts[:5]: # Max 5
|
||||
name = acc.get('name', 'N/A')
|
||||
balance = round(acc.get('balance', 0))
|
||||
text += f" - {name}: {balance:,} RON\n"
|
||||
|
||||
if len(bank_accounts) > 5:
|
||||
text += f" ... si inca {len(bank_accounts) - 5} conturi"
|
||||
else:
|
||||
text += "Nu exista conturi bancare configurate."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_clients_balance_response(
|
||||
clients: List[Dict[str, Any]],
|
||||
maturity_data: Dict[str, Any],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format clients balance with maturity breakdown (content only, no header).
|
||||
|
||||
Args:
|
||||
clients: List of client dicts with id, name, balance
|
||||
maturity_data: Dict with in_term, overdue, total
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
clients = [{'id': 1, 'name': 'Client A', 'balance': 15000}]
|
||||
maturity = {'in_term': 10000, 'overdue': 5000, 'total': 15000}
|
||||
text = format_clients_balance_response(clients, maturity)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Maturity breakdown - rotunjit la leu (0 zecimale)
|
||||
total = round(maturity_data.get('total', 0))
|
||||
in_term = round(maturity_data.get('in_term', 0))
|
||||
overdue = round(maturity_data.get('overdue', 0))
|
||||
|
||||
text += f"**Sold Total:** {total:,} RON\n\n"
|
||||
|
||||
text += "**Defalcare:**\n"
|
||||
text += f" - In termen: {in_term:,} RON\n"
|
||||
text += f" - Restanta: {overdue:,} RON\n\n"
|
||||
|
||||
# Top clients
|
||||
if clients:
|
||||
text += f"**Top Clienti** ({len(clients)} total):\n"
|
||||
# Sort by balance descending
|
||||
sorted_clients = sorted(
|
||||
clients,
|
||||
key=lambda x: x.get('balance', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for idx, client in enumerate(sorted_clients[:5], 1):
|
||||
name = client.get('name', 'N/A')
|
||||
balance = round(client.get('balance', 0))
|
||||
text += f"{idx}. {name}: {balance:,} RON\n"
|
||||
|
||||
if len(clients) > 5:
|
||||
text += f"\nApasa butonul pentru lista completa"
|
||||
else:
|
||||
text += "Nu exista clienti cu solduri."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_suppliers_balance_response(
|
||||
suppliers: List[Dict[str, Any]],
|
||||
maturity_data: Dict[str, Any],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format suppliers balance with maturity breakdown (content only, no header).
|
||||
|
||||
Args:
|
||||
suppliers: List of supplier dicts with id, name, balance
|
||||
maturity_data: Dict with in_term, overdue, total
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
suppliers = [{'id': 1, 'name': 'Supplier A', 'balance': 5000}]
|
||||
maturity = {'in_term': 4000, 'overdue': 1000, 'total': 5000}
|
||||
text = format_suppliers_balance_response(suppliers, maturity)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Maturity breakdown - rotunjit la leu (0 zecimale)
|
||||
total = round(maturity_data.get('total', 0))
|
||||
in_term = round(maturity_data.get('in_term', 0))
|
||||
overdue = round(maturity_data.get('overdue', 0))
|
||||
|
||||
text += f"**Sold Total:** {total:,} RON\n\n"
|
||||
|
||||
text += "**Defalcare:**\n"
|
||||
text += f" - In termen: {in_term:,} RON\n"
|
||||
text += f" - Restanta: {overdue:,} RON\n\n"
|
||||
|
||||
# Top suppliers
|
||||
if suppliers:
|
||||
text += f"**Top Furnizori** ({len(suppliers)} total):\n"
|
||||
# Sort by balance descending
|
||||
sorted_suppliers = sorted(
|
||||
suppliers,
|
||||
key=lambda x: x.get('balance', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for idx, supplier in enumerate(sorted_suppliers[:5], 1):
|
||||
name = supplier.get('name', 'N/A')
|
||||
balance = round(supplier.get('balance', 0))
|
||||
text += f"{idx}. {name}: {balance:,} RON\n"
|
||||
|
||||
if len(suppliers) > 5:
|
||||
text += f"\nApasa butonul pentru lista completa"
|
||||
else:
|
||||
text += "Nu exista furnizori cu solduri."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_cashflow_evolution_response(
|
||||
performance_data: Dict[str, Any],
|
||||
monthly_data: Dict[str, Any],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format cash flow evolution data (content only, no header).
|
||||
|
||||
Args:
|
||||
performance_data: Dict with incasari_total, plati_total, net
|
||||
monthly_data: Dict with months, incasari, plati arrays
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
performance = {'incasari_total': 100000, 'plati_total': 80000, 'net': 20000}
|
||||
monthly = {'months': ['Ian', 'Feb'], 'incasari': [50000, 50000], 'plati': [40000, 40000]}
|
||||
text = format_cashflow_evolution_response(performance, monthly)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Performance summary - rotunjit la leu (0 zecimale)
|
||||
incasari_total = round(performance_data.get('incasari_total', 0))
|
||||
plati_total = round(performance_data.get('plati_total', 0))
|
||||
net = round(performance_data.get('net', 0))
|
||||
|
||||
text += "**Rezumat:**\n"
|
||||
text += f" - Total Incasari: {incasari_total:,} RON\n"
|
||||
text += f" - Total Plati: {plati_total:,} RON\n"
|
||||
text += f" - Net Cash Flow: {net:,} RON\n\n"
|
||||
|
||||
# Monthly breakdown
|
||||
months = monthly_data.get('months', [])
|
||||
incasari = monthly_data.get('incasari', [])
|
||||
plati = monthly_data.get('plati', [])
|
||||
|
||||
if months and len(months) > 0:
|
||||
text += "**Evolutie Lunara** (ultimele luni):\n"
|
||||
|
||||
# Show last 6 months
|
||||
display_count = min(6, len(months))
|
||||
for i in range(display_count):
|
||||
month = months[-(display_count - i)]
|
||||
inc = round(incasari[-(display_count - i)]) if i < len(incasari) else 0
|
||||
plt = round(plati[-(display_count - i)]) if i < len(plati) else 0
|
||||
net_month = inc - plt
|
||||
|
||||
# Simple ASCII bar
|
||||
net_indicator = "+" if net_month > 0 else "-" if net_month < 0 else "="
|
||||
|
||||
text += f"\n**{month}:**\n"
|
||||
text += f" {net_indicator} Incasari: {inc:,} RON\n"
|
||||
text += f" {net_indicator} Plati: {plt:,} RON\n"
|
||||
text += f" {net_indicator} Net: {net_month:,} RON"
|
||||
else:
|
||||
text += "Nu exista date lunare disponibile."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_client_detail_response(
|
||||
client: Dict[str, Any],
|
||||
invoices: List[Dict[str, Any]],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format client details with invoices - COMPACT TABLE FORMAT.
|
||||
|
||||
Args:
|
||||
client: Dict with client info (id, name, balance)
|
||||
invoices: List of invoice dicts for this client
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram (compact, no emojis)
|
||||
|
||||
Example:
|
||||
client = {'id': 1, 'name': 'Client A', 'balance': 15000}
|
||||
invoices = [{'id': 1, 'number': 'FV001', 'amount': 5000, 'status': 'unpaid'}]
|
||||
text = format_client_detail_response(client, invoices)
|
||||
"""
|
||||
client_name = client.get('name', 'N/A')
|
||||
balance = client.get('balance', 0)
|
||||
|
||||
# Header with client info
|
||||
text = f"**{client_name}**\n"
|
||||
text += f"**Sold total: {balance:,.2f} RON**"
|
||||
if invoices and len(invoices) > 1:
|
||||
text += f" • {len(invoices)} facturi"
|
||||
text += "\n\n"
|
||||
|
||||
# Invoices - compact table format (no emojis)
|
||||
if invoices:
|
||||
from datetime import datetime
|
||||
|
||||
# Sort invoices by date (most recent first)
|
||||
sorted_invoices = sorted(invoices, key=lambda x: x.get('dataact') or datetime.min, reverse=True)
|
||||
|
||||
# Invoice list - simple format without table
|
||||
text += "Facturi cu sold:\n"
|
||||
text += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
# Invoice rows - one line each, simple format
|
||||
for inv in sorted_invoices[:10]:
|
||||
# Backend returns: nract, totctva, soldfinal, datascad, dataact, achitat
|
||||
number = str(inv.get('nract', 'N/A'))
|
||||
dataact = inv.get('dataact')
|
||||
|
||||
# Parse date - handle various formats to ensure dd.mm.yyyy
|
||||
if dataact:
|
||||
if isinstance(dataact, str):
|
||||
try:
|
||||
# Try ISO format first: "2024-10-25" or "2024-10-25 00:00:00"
|
||||
if '-' in dataact and len(dataact) >= 10:
|
||||
parsed_date = datetime.strptime(dataact[:10], '%Y-%m-%d')
|
||||
date_str = parsed_date.strftime('%d.%m.%Y')
|
||||
# Already in dd.mm.yyyy format
|
||||
elif '.' in dataact:
|
||||
date_str = dataact.split()[0][:10] # Take just date part
|
||||
else:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
except:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
else:
|
||||
# Datetime object - format as dd.mm.yyyy
|
||||
date_str = dataact.strftime('%d.%m.%Y')
|
||||
else:
|
||||
date_str = 'N/A'
|
||||
|
||||
sold = float(inv.get('soldfinal', 0) or 0)
|
||||
|
||||
# Simple format: Nr • Data • Sold
|
||||
text += f"Nr {number} • {date_str} • {sold:,.2f} RON\n"
|
||||
|
||||
if len(invoices) > 10:
|
||||
text += f"\n\n+{len(invoices) - 10} facturi"
|
||||
else:
|
||||
text += "Nu exista facturi neachitate"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_supplier_detail_response(
|
||||
supplier: Dict[str, Any],
|
||||
invoices: List[Dict[str, Any]],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format supplier details with invoices - COMPACT TABLE FORMAT.
|
||||
|
||||
Args:
|
||||
supplier: Dict with supplier info (id, name, balance)
|
||||
invoices: List of invoice dicts for this supplier
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram (compact, no emojis)
|
||||
|
||||
Example:
|
||||
supplier = {'id': 1, 'name': 'Supplier A', 'balance': 5000}
|
||||
invoices = [{'id': 1, 'number': 'FC001', 'amount': 2000, 'status': 'unpaid'}]
|
||||
text = format_supplier_detail_response(supplier, invoices)
|
||||
"""
|
||||
supplier_name = supplier.get('name', 'N/A')
|
||||
balance = supplier.get('balance', 0)
|
||||
|
||||
# Header with supplier info
|
||||
text = f"**{supplier_name}**\n"
|
||||
text += f"**Sold total: {balance:,.2f} RON**"
|
||||
if invoices and len(invoices) > 1:
|
||||
text += f" • {len(invoices)} facturi"
|
||||
text += "\n\n"
|
||||
|
||||
# Invoices - compact table format (no emojis)
|
||||
if invoices:
|
||||
from datetime import datetime
|
||||
|
||||
# Sort invoices by date (most recent first)
|
||||
sorted_invoices = sorted(invoices, key=lambda x: x.get('dataact') or datetime.min, reverse=True)
|
||||
|
||||
# Invoice list - simple format without table
|
||||
text += "Facturi cu sold:\n"
|
||||
text += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
# Invoice rows - one line each, simple format
|
||||
for inv in sorted_invoices[:10]:
|
||||
# Backend returns: nract, totctva, soldfinal, datascad, dataact, achitat
|
||||
number = str(inv.get('nract', 'N/A'))
|
||||
dataact = inv.get('dataact')
|
||||
|
||||
# Parse date - handle various formats to ensure dd.mm.yyyy
|
||||
if dataact:
|
||||
if isinstance(dataact, str):
|
||||
try:
|
||||
# Try ISO format first: "2024-10-25" or "2024-10-25 00:00:00"
|
||||
if '-' in dataact and len(dataact) >= 10:
|
||||
parsed_date = datetime.strptime(dataact[:10], '%Y-%m-%d')
|
||||
date_str = parsed_date.strftime('%d.%m.%Y')
|
||||
# Already in dd.mm.yyyy format
|
||||
elif '.' in dataact:
|
||||
date_str = dataact.split()[0][:10] # Take just date part
|
||||
else:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
except:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
else:
|
||||
# Datetime object - format as dd.mm.yyyy
|
||||
date_str = dataact.strftime('%d.%m.%Y')
|
||||
else:
|
||||
date_str = 'N/A'
|
||||
|
||||
sold = float(inv.get('soldfinal', 0) or 0)
|
||||
|
||||
# Simple format: Nr • Data • Sold
|
||||
text += f"Nr {number} • {date_str} • {sold:,.2f} RON\n"
|
||||
|
||||
if len(invoices) > 10:
|
||||
text += f"\n\n+{len(invoices) - 10} facturi"
|
||||
else:
|
||||
text += "Nu exista facturi neachitate"
|
||||
|
||||
return text
|
||||
2036
reports-app/telegram-bot/app/bot/handlers.py
Normal file
2036
reports-app/telegram-bot/app/bot/handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
705
reports-app/telegram-bot/app/bot/helpers.py
Normal file
705
reports-app/telegram-bot/app/bot/helpers.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Helper functions for Telegram bot command handlers.
|
||||
Provides utilities for company selection, API calls, and response formatting.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Any
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.api.client import get_backend_client
|
||||
from app.agent.session import SessionManager
|
||||
from app.bot.menus import pad_message_for_wide_buttons
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_active_company_or_prompt(
|
||||
update: Update,
|
||||
session_manager: SessionManager,
|
||||
telegram_user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get active company from session or prompt user to select one with buttons.
|
||||
|
||||
This function checks if the user has an active company set in their session.
|
||||
If not, it fetches companies and displays selection buttons directly.
|
||||
|
||||
Args:
|
||||
update: Telegram Update object (for sending messages)
|
||||
session_manager: SessionManager instance
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Dict with company info (id, name, cui) if set, None if user needs to select
|
||||
|
||||
Example:
|
||||
company = await get_active_company_or_prompt(update, session_manager, user_id)
|
||||
if not company:
|
||||
return # User was shown company selection buttons
|
||||
# Continue with company operations...
|
||||
"""
|
||||
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||
company = session.get_active_company()
|
||||
|
||||
if not company:
|
||||
# Get auth data and companies
|
||||
from app.auth.linking import get_user_auth_data
|
||||
auth_data = await get_user_auth_data(telegram_user_id)
|
||||
jwt_token = auth_data['jwt_token']
|
||||
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
companies = await client.get_user_companies(jwt_token=jwt_token)
|
||||
|
||||
if companies:
|
||||
keyboard = create_company_selection_keyboard_paginated(companies, page=0)
|
||||
message = (
|
||||
f"**Selecteaza mai intai o companie**\n\n"
|
||||
f"Companiile tale ({len(companies)}):"
|
||||
)
|
||||
# Apply padding to make inline keyboard buttons wider
|
||||
message = pad_message_for_wide_buttons(message)
|
||||
await update.message.reply_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
"Nu ai acces la nicio companie.\n"
|
||||
"Contacteaza administratorul.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return None
|
||||
|
||||
return company
|
||||
|
||||
|
||||
async def search_companies_by_name(
|
||||
name_query: str,
|
||||
jwt_token: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search companies by partial name match (case-insensitive).
|
||||
|
||||
Fetches all companies from backend and filters them by name.
|
||||
Uses case-insensitive partial matching for flexible search.
|
||||
|
||||
Args:
|
||||
name_query: Search term (partial match, e.g., "ACME")
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
List of matching company dicts (each with id, nume_firma, cui, etc.)
|
||||
|
||||
Example:
|
||||
companies = await search_companies_by_name("acme", token)
|
||||
# Returns all companies with "acme" in their name (case-insensitive)
|
||||
"""
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
all_companies = await client.get_user_companies(jwt_token=jwt_token)
|
||||
|
||||
# Filter by name (case-insensitive partial match)
|
||||
query_lower = name_query.lower()
|
||||
matches = [
|
||||
comp for comp in all_companies
|
||||
if query_lower in comp.get('name', comp.get('nume_firma', '')).lower()
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"Search '{name_query}': {len(matches)} matches out of {len(all_companies)} total"
|
||||
)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def create_company_selection_keyboard(
|
||||
companies: List[Dict[str, Any]],
|
||||
max_buttons: int = 10
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create inline keyboard for company selection (legacy - without pagination).
|
||||
|
||||
Generates a vertical list of buttons, one per company.
|
||||
Each button shows company name and CUI, and triggers a callback.
|
||||
|
||||
NOTE: This function is deprecated in favor of create_company_selection_keyboard_paginated.
|
||||
It's kept for backwards compatibility only.
|
||||
|
||||
Args:
|
||||
companies: List of company dicts (with id, nume_firma, cui)
|
||||
max_buttons: Maximum number of buttons to show (default: 10)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with company selection buttons
|
||||
|
||||
Example:
|
||||
keyboard = create_company_selection_keyboard(companies)
|
||||
await update.message.reply_text("Select company:", reply_markup=keyboard)
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
for company in companies[:max_buttons]:
|
||||
company_id = company.get('id_firma', company.get('id'))
|
||||
company_name = company.get('name', company.get('nume_firma', 'N/A'))
|
||||
company_cui = company.get('fiscal_code', company.get('cui', ''))
|
||||
|
||||
# Button text: "ACME SRL (CUI: 12345)"
|
||||
button_text = f"{company_name}"
|
||||
if company_cui:
|
||||
button_text += f" ({company_cui})"
|
||||
|
||||
# Callback data: "select_company:123"
|
||||
callback_data = f"select_company:{company_id}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(button_text, callback_data=callback_data)])
|
||||
|
||||
# Add overflow indicator if there are more companies
|
||||
if len(companies) > max_buttons:
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"... și încă {len(companies) - max_buttons} companii",
|
||||
callback_data="noop"
|
||||
)])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_company_selection_keyboard_paginated(
|
||||
companies: List[Dict[str, Any]],
|
||||
page: int = 0,
|
||||
per_page: int = 10
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create paginated inline keyboard for company selection.
|
||||
|
||||
Generates a vertical list of buttons for one page of companies,
|
||||
with navigation buttons for previous/next pages.
|
||||
|
||||
Args:
|
||||
companies: Full list of company dicts (with id, nume_firma, cui)
|
||||
page: Current page number (0-indexed)
|
||||
per_page: Number of companies per page (default: 10)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with company buttons and pagination controls
|
||||
|
||||
Example:
|
||||
keyboard = create_company_selection_keyboard_paginated(companies, page=0)
|
||||
await update.message.reply_text("Select company:", reply_markup=keyboard)
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_companies = len(companies)
|
||||
total_pages = (total_companies + per_page - 1) // per_page # Ceiling division
|
||||
start_idx = page * per_page
|
||||
end_idx = min(start_idx + per_page, total_companies)
|
||||
|
||||
# Display companies for current page
|
||||
page_companies = companies[start_idx:end_idx]
|
||||
|
||||
for company in page_companies:
|
||||
company_id = company.get('id_firma', company.get('id'))
|
||||
company_name = company.get('name', company.get('nume_firma', 'N/A'))
|
||||
company_cui = company.get('fiscal_code', company.get('cui', ''))
|
||||
|
||||
# Button text: "ACME SRL (CUI: 12345)"
|
||||
button_text = f"{company_name}"
|
||||
if company_cui:
|
||||
button_text += f" ({company_cui})"
|
||||
|
||||
# Callback data: "select_company:123"
|
||||
callback_data = f"select_company:{company_id}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(button_text, callback_data=callback_data)])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"select_company_page:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Urmator >", callback_data=f"select_company_page:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Back to menu button
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Inapoi la Meniu", callback_data="action:menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def format_company_context_footer(company_name: str) -> str:
|
||||
"""
|
||||
Format discrete footer with company context.
|
||||
|
||||
Adds a subtle footer to command responses showing the active company
|
||||
and a quick link to change it.
|
||||
|
||||
Args:
|
||||
company_name: Active company name
|
||||
|
||||
Returns:
|
||||
Formatted footer string with separator and company name
|
||||
|
||||
Example:
|
||||
footer = format_company_context_footer("ACME SRL")
|
||||
message = f"Dashboard data...\n{footer}"
|
||||
# Output: "Dashboard data...\n\n━━━━━━━━━━━━━━\nCompanie: ACME SRL"
|
||||
"""
|
||||
return f"\n\n━━━━━━━━━━━━━━\nCompanie: {company_name}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# FAZA 2: New Helper Functions for Button Interface
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def get_treasury_breakdown_split(
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get treasury breakdown split into casa and banca.
|
||||
|
||||
Fetches treasury breakdown from backend and transforms it
|
||||
to the format expected by formatters.
|
||||
|
||||
Backend returns:
|
||||
{
|
||||
"total": float,
|
||||
"breakdown": {
|
||||
"casa": {"total": float, "items": [{"nume": str, "cont": str, "sold": float}]},
|
||||
"banca": {"total": float, "items": [{"nume": str, "cont": str, "sold": float}]}
|
||||
},
|
||||
"currency": "RON"
|
||||
}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
Dict with two keys:
|
||||
- 'casa': Dict with 'accounts' (list) and 'total' (float)
|
||||
- 'banca': Dict with 'accounts' (list) and 'total' (float)
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_treasury_breakdown_split(1, token)
|
||||
casa_total = data['casa']['total'] # Total cash balance
|
||||
bank_accounts = data['banca']['accounts'] # List of bank accounts
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
breakdown = await client.get_treasury_breakdown(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token
|
||||
)
|
||||
|
||||
if not breakdown:
|
||||
return None
|
||||
|
||||
# Backend already splits data into casa and banca
|
||||
# Transform backend structure to match formatter expectations
|
||||
breakdown_data = breakdown.get('breakdown', {})
|
||||
casa_data = breakdown_data.get('casa', {})
|
||||
banca_data = breakdown_data.get('banca', {})
|
||||
|
||||
# Transform items to accounts format (nume->name, sold->balance)
|
||||
casa_accounts = [
|
||||
{
|
||||
'name': item.get('nume', f"Cont {item.get('cont', 'N/A')}"),
|
||||
'balance': float(item.get('sold', 0)),
|
||||
'cont': item.get('cont', '')
|
||||
}
|
||||
for item in casa_data.get('items', [])
|
||||
]
|
||||
|
||||
banca_accounts = [
|
||||
{
|
||||
'name': item.get('nume', f"Cont {item.get('cont', 'N/A')}"),
|
||||
'balance': float(item.get('sold', 0)),
|
||||
'cont': item.get('cont', '')
|
||||
}
|
||||
for item in banca_data.get('items', [])
|
||||
]
|
||||
|
||||
return {
|
||||
'casa': {
|
||||
'accounts': casa_accounts,
|
||||
'total': float(casa_data.get('total', 0))
|
||||
},
|
||||
'banca': {
|
||||
'accounts': banca_accounts,
|
||||
'total': float(banca_data.get('total', 0))
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting treasury breakdown split: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_clients_with_maturity(
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get clients list with maturity breakdown.
|
||||
|
||||
Uses maturity analysis endpoint which returns client summaries
|
||||
with amounts and overdue status.
|
||||
|
||||
Backend returns:
|
||||
{
|
||||
"clients": [{"name": str, "amount": float, "dueDate": str, "daysOverdue": int}],
|
||||
"suppliers": [...],
|
||||
"balance": float,
|
||||
"metadata": {...}
|
||||
}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- 'clients': List of client dicts (id, name, balance)
|
||||
- 'maturity': Dict with 'in_term', 'overdue', 'total' amounts
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_clients_with_maturity(1, token)
|
||||
clients = data['clients'] # List of all clients
|
||||
overdue = data['maturity']['overdue'] # Overdue amount
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Get maturity analysis (contains client summaries)
|
||||
maturity_response = await client.get_maturity_data(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
period='all'
|
||||
)
|
||||
|
||||
if not maturity_response:
|
||||
return None
|
||||
|
||||
# Extract clients from maturity response
|
||||
clients_raw = maturity_response.get('clients', [])
|
||||
|
||||
# Transform to expected format: amount → balance
|
||||
clients = [
|
||||
{
|
||||
'name': c.get('name', 'N/A'),
|
||||
'balance': float(c.get('amount', 0)),
|
||||
'daysOverdue': c.get('daysOverdue', 0)
|
||||
}
|
||||
for c in clients_raw
|
||||
]
|
||||
|
||||
# Calculate maturity breakdown from clients data
|
||||
total = sum(c['balance'] for c in clients)
|
||||
overdue = sum(c['balance'] for c in clients if c.get('daysOverdue', 0) > 0)
|
||||
in_term = total - overdue
|
||||
|
||||
return {
|
||||
'clients': clients,
|
||||
'maturity': {
|
||||
'in_term': in_term,
|
||||
'overdue': overdue,
|
||||
'total': total
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting clients with maturity: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_suppliers_with_maturity(
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get suppliers list with maturity breakdown.
|
||||
|
||||
Uses maturity analysis endpoint which returns supplier summaries
|
||||
with amounts and overdue status.
|
||||
|
||||
Backend returns:
|
||||
{
|
||||
"clients": [...],
|
||||
"suppliers": [{"name": str, "amount": float, "dueDate": str, "daysOverdue": int}],
|
||||
"balance": float,
|
||||
"metadata": {...}
|
||||
}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- 'suppliers': List of supplier dicts (id, name, balance)
|
||||
- 'maturity': Dict with 'in_term', 'overdue', 'total' amounts
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_suppliers_with_maturity(1, token)
|
||||
suppliers = data['suppliers'] # List of all suppliers
|
||||
in_term = data['maturity']['in_term'] # In-term amount
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Get maturity analysis (contains supplier summaries)
|
||||
maturity_response = await client.get_maturity_data(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
period='all'
|
||||
)
|
||||
|
||||
if not maturity_response:
|
||||
return None
|
||||
|
||||
# Extract suppliers from maturity response
|
||||
suppliers_raw = maturity_response.get('suppliers', [])
|
||||
|
||||
# Transform to expected format: amount → balance
|
||||
suppliers = [
|
||||
{
|
||||
'name': s.get('name', 'N/A'),
|
||||
'balance': float(s.get('amount', 0)),
|
||||
'daysOverdue': s.get('daysOverdue', 0)
|
||||
}
|
||||
for s in suppliers_raw
|
||||
]
|
||||
|
||||
# Calculate maturity breakdown from suppliers data
|
||||
total = sum(s['balance'] for s in suppliers)
|
||||
overdue = sum(s['balance'] for s in suppliers if s.get('daysOverdue', 0) > 0)
|
||||
in_term = total - overdue
|
||||
|
||||
return {
|
||||
'suppliers': suppliers,
|
||||
'maturity': {
|
||||
'in_term': in_term,
|
||||
'overdue': overdue,
|
||||
'total': total
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting suppliers with maturity: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_cashflow_evolution_data(
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
period: str = "12m"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cash flow evolution data.
|
||||
|
||||
Uses monthly flows endpoint which returns current month data.
|
||||
Backend returns: {'inflows': float, 'outflows': float, 'period': str, 'currency': str}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
period: Period for monthly data (default: "12m")
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- 'performance': Dict with incasari_total, plati_total, net
|
||||
- 'monthly': Dict with months, incasari, plati arrays
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_cashflow_evolution_data(1, token)
|
||||
net = data['performance']['net'] # Net cash flow
|
||||
months = data['monthly']['months'] # List of month names
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Get monthly flows (current month only from backend)
|
||||
monthly_flows = await client.get_monthly_flows(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
months=12 # Note: backend ignores this and returns only current month
|
||||
)
|
||||
|
||||
if not monthly_flows:
|
||||
return None
|
||||
|
||||
# Transform backend response to expected format
|
||||
inflows = float(monthly_flows.get('inflows', 0))
|
||||
outflows = float(monthly_flows.get('outflows', 0))
|
||||
period_name = monthly_flows.get('period', 'Luna curentă')
|
||||
|
||||
# Calculate net
|
||||
net = inflows - outflows
|
||||
|
||||
# Build performance summary
|
||||
performance = {
|
||||
'incasari_total': inflows,
|
||||
'plati_total': outflows,
|
||||
'net': net
|
||||
}
|
||||
|
||||
# Build monthly breakdown (single month from backend)
|
||||
monthly = {
|
||||
'months': [period_name],
|
||||
'incasari': [inflows],
|
||||
'plati': [outflows]
|
||||
}
|
||||
|
||||
return {
|
||||
'performance': performance,
|
||||
'monthly': monthly
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cashflow evolution data: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_client_invoices(
|
||||
company_id: int,
|
||||
client_name: str,
|
||||
jwt_token: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get invoices for a specific client.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
client_name: Client name to filter by
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
List of invoice dicts for the specified client
|
||||
|
||||
Example:
|
||||
invoices = await get_client_invoices(1, "ACME Corp", token)
|
||||
for inv in invoices:
|
||||
print(inv['number'], inv['amount'])
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching invoices for client '{client_name}' (company_id={company_id})")
|
||||
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Filter only by unpaid invoices (with balance > 0)
|
||||
invoices = await client.search_invoices(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
filters={
|
||||
'partner_type': 'CLIENTI',
|
||||
'partner_name': client_name,
|
||||
'only_unpaid': True # Only show unpaid invoices (matching balance > 0)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(invoices) if invoices else 0} invoices for client '{client_name}'")
|
||||
|
||||
if invoices:
|
||||
logger.debug(f"First invoice sample: {invoices[0]}")
|
||||
|
||||
return invoices or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting client invoices for '{client_name}': {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
async def get_supplier_invoices(
|
||||
company_id: int,
|
||||
supplier_name: str,
|
||||
jwt_token: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get invoices for a specific supplier.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
supplier_name: Supplier name to filter by
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
List of invoice dicts for the specified supplier
|
||||
|
||||
Example:
|
||||
invoices = await get_supplier_invoices(1, "Supplier Inc", token)
|
||||
for inv in invoices:
|
||||
print(inv['number'], inv['amount'])
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching invoices for supplier '{supplier_name}' (company_id={company_id})")
|
||||
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Filter only by unpaid invoices (with balance > 0)
|
||||
invoices = await client.search_invoices(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
filters={
|
||||
'partner_type': 'FURNIZORI',
|
||||
'partner_name': supplier_name,
|
||||
'only_unpaid': True # Only show unpaid invoices (matching balance > 0)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(invoices) if invoices else 0} invoices for supplier '{supplier_name}'")
|
||||
|
||||
if invoices:
|
||||
logger.debug(f"First invoice sample: {invoices[0]}")
|
||||
|
||||
return invoices or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting supplier invoices for '{supplier_name}': {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
# Export all helper functions
|
||||
__all__ = [
|
||||
'get_active_company_or_prompt',
|
||||
'search_companies_by_name',
|
||||
'create_company_selection_keyboard',
|
||||
'create_company_selection_keyboard_paginated',
|
||||
'format_company_context_footer',
|
||||
'get_treasury_breakdown_split',
|
||||
'get_clients_with_maturity',
|
||||
'get_suppliers_with_maturity',
|
||||
'get_cashflow_evolution_data',
|
||||
'get_client_invoices',
|
||||
'get_supplier_invoices'
|
||||
]
|
||||
0
reports-app/telegram-bot/app/bot/keyboards.py
Normal file
0
reports-app/telegram-bot/app/bot/keyboards.py
Normal file
565
reports-app/telegram-bot/app/bot/menus.py
Normal file
565
reports-app/telegram-bot/app/bot/menus.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
Menu builders for Telegram bot inline keyboards.
|
||||
|
||||
This module provides functions to create InlineKeyboardMarkup objects
|
||||
for different menu levels and navigation patterns in the bot.
|
||||
|
||||
NOTE: All button texts are plain text WITHOUT emojis/icons as per requirements.
|
||||
|
||||
BUTTON WIDTH: Inline keyboard width is determined by the message text width.
|
||||
To make buttons wider, we pad message text with invisible characters.
|
||||
"""
|
||||
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT: BUTTON WIDTH CONFIGURATION
|
||||
# ============================================================================
|
||||
# Inline keyboard button width is determined by MESSAGE TEXT WIDTH!
|
||||
# DO NOT REMOVE PADDING - it makes buttons wide like BotFather!
|
||||
# ============================================================================
|
||||
|
||||
# Zero-Width Joiner character - invisible but prevents Telegram from trimming spaces
|
||||
# This character has ZERO width (invisible) but prevents space trimming
|
||||
ZERO_WIDTH_JOINER = '\u200D'
|
||||
|
||||
# Target character count per line to make buttons VERY WIDE
|
||||
# Higher value = wider buttons (BotFather uses ~45-50 chars)
|
||||
# DO NOT DECREASE THIS VALUE - buttons will become narrow!
|
||||
TARGET_WIDTH = 50 # Increased from 40 to make buttons WIDER
|
||||
|
||||
# Enable/disable padding globally (useful for testing)
|
||||
# KEEP THIS TRUE - disabling makes buttons narrow!
|
||||
ENABLE_BUTTON_PADDING = True
|
||||
|
||||
|
||||
def _get_current_month_ro() -> str:
|
||||
"""Get current month name in Romanian."""
|
||||
months_ro = {
|
||||
1: "Ianuarie", 2: "Februarie", 3: "Martie", 4: "Aprilie",
|
||||
5: "Mai", 6: "Iunie", 7: "Iulie", 8: "August",
|
||||
9: "Septembrie", 10: "Octombrie", 11: "Noiembrie", 12: "Decembrie"
|
||||
}
|
||||
now = datetime.now()
|
||||
return f"{months_ro[now.month]} {now.year}"
|
||||
|
||||
|
||||
def _pad_line_for_wide_buttons(text: str, target_width: int = TARGET_WIDTH) -> str:
|
||||
"""
|
||||
Pad a single line of text with invisible characters to make inline buttons wider.
|
||||
|
||||
⚠️ CRITICAL: DO NOT REMOVE THIS FUNCTION - it makes buttons wide!
|
||||
The width of InlineKeyboardMarkup buttons is determined by the message text width.
|
||||
By padding text with spaces + zero-width joiner, we force wider buttons.
|
||||
|
||||
How it works:
|
||||
1. Calculate how many characters needed to reach target_width
|
||||
2. Add spaces + Zero-Width Joiner (invisible character)
|
||||
3. Result: wider message = wider buttons (like BotFather)
|
||||
|
||||
Args:
|
||||
text: The text line to pad
|
||||
target_width: Target character count (default 50 for VERY WIDE buttons)
|
||||
|
||||
Returns:
|
||||
Padded text with invisible characters (user sees normal text, Telegram sees wider text)
|
||||
"""
|
||||
current_length = len(text)
|
||||
if current_length >= target_width:
|
||||
return text
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Add spaces + zero-width joiner at the end
|
||||
# This makes buttons WIDE without changing visible text!
|
||||
padding_needed = target_width - current_length
|
||||
padding = ' ' * padding_needed + ZERO_WIDTH_JOINER
|
||||
|
||||
return text + padding
|
||||
|
||||
|
||||
def pad_message_for_wide_buttons(message: str, target_width: int = TARGET_WIDTH, force: bool = False) -> str:
|
||||
"""
|
||||
Pad all lines in a message to make inline keyboard buttons wider.
|
||||
|
||||
⚠️ CRITICAL: DO NOT REMOVE THIS FUNCTION - it makes buttons wide!
|
||||
This is the MAIN function that applies padding to ALL messages with keyboards.
|
||||
|
||||
Why we need this:
|
||||
- Telegram determines button width based on MESSAGE TEXT width
|
||||
- Short messages = narrow buttons
|
||||
- Wide messages (with invisible padding) = WIDE buttons like BotFather
|
||||
|
||||
Args:
|
||||
message: Multi-line message text
|
||||
target_width: Target character count per line (default 50)
|
||||
force: Force padding even if ENABLE_BUTTON_PADDING is False
|
||||
|
||||
Returns:
|
||||
Message with all lines padded (if enabled or forced)
|
||||
"""
|
||||
# ⚠️ DO NOT REMOVE: Check if padding is enabled
|
||||
if not ENABLE_BUTTON_PADDING and not force:
|
||||
return message
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Apply padding to each line
|
||||
lines = message.split('\n')
|
||||
padded_lines = [_pad_line_for_wide_buttons(line, target_width) for line in lines]
|
||||
return '\n'.join(padded_lines)
|
||||
|
||||
|
||||
def format_response_with_company(
|
||||
content: str,
|
||||
company_name: Optional[str] = None,
|
||||
apply_padding: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Format a response with company name at the top (simplified format).
|
||||
|
||||
⚠️ IMPORTANT: Applies padding by default to make buttons WIDE!
|
||||
|
||||
Format:
|
||||
Company Name
|
||||
|
||||
[Content]
|
||||
|
||||
Args:
|
||||
content: The main content text
|
||||
company_name: Company name to show at top (if None, just returns content)
|
||||
apply_padding: Whether to apply invisible padding for wider buttons (default TRUE)
|
||||
|
||||
Returns:
|
||||
Formatted response with company name header AND padding for wide buttons
|
||||
"""
|
||||
if company_name:
|
||||
message = f"{company_name}\n\n{content}"
|
||||
else:
|
||||
message = content
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Apply padding to make inline keyboard buttons WIDE!
|
||||
# Without this, buttons become narrow like before
|
||||
if apply_padding:
|
||||
message = pad_message_for_wide_buttons(message)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def get_menu_message(
|
||||
company_name: Optional[str] = None,
|
||||
company_cui: Optional[str] = None,
|
||||
apply_padding: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Get the menu message text with company details (simplified format).
|
||||
|
||||
⚠️ IMPORTANT: Applies padding by default to make menu buttons WIDE!
|
||||
|
||||
Format without labels - just values:
|
||||
- Line 1: Company name
|
||||
- Line 2: CUI
|
||||
- Line 3: Accounting month
|
||||
|
||||
Args:
|
||||
company_name: Active company name
|
||||
company_cui: Company fiscal code (CUI)
|
||||
apply_padding: Whether to apply invisible padding for wider buttons (default TRUE)
|
||||
|
||||
Returns:
|
||||
Formatted message text for menu WITH padding for wide buttons
|
||||
"""
|
||||
if company_name:
|
||||
# Simplified format: just values, no labels
|
||||
message = f"{company_name}\n"
|
||||
if company_cui:
|
||||
message += f"{company_cui}\n"
|
||||
message += f"{_get_current_month_ro()}"
|
||||
else:
|
||||
# No company selected - just prompt
|
||||
message = "Selectează o companie pentru a continua"
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Apply padding to make inline keyboard buttons WIDE!
|
||||
# This makes buttons look like BotFather (wide, not narrow)
|
||||
if apply_padding:
|
||||
message = pad_message_for_wide_buttons(message)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def create_main_menu(
|
||||
company_name: Optional[str] = None,
|
||||
company_cui: Optional[str] = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create main menu keyboard (Level 1) with financial options.
|
||||
|
||||
Layout: Full-width buttons with company selection at top
|
||||
|
||||
Args:
|
||||
company_name: Active company name, or None if no company selected
|
||||
company_cui: Company fiscal code (CUI), or None
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with main menu buttons
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Row 1: Company selection (full width, single line - InlineKeyboardButton doesn't support multiline)
|
||||
if company_name:
|
||||
# Short company name for button (CUI and month will be shown in message text)
|
||||
# Truncate long names to fit in button
|
||||
max_length = 35
|
||||
display_name = company_name if len(company_name) <= max_length else company_name[:max_length-3] + "..."
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
f"{display_name}",
|
||||
callback_data="menu:select_company"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
"Selectare Companie",
|
||||
callback_data="menu:select_company"
|
||||
)
|
||||
])
|
||||
|
||||
# Rows 2-4: Financial options (2 buttons per row, made wide by message text padding)
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton("Sold Companie", callback_data="menu:sold"),
|
||||
InlineKeyboardButton("Trezorerie Casa", callback_data="menu:casa")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("Trezorerie Banca", callback_data="menu:banca"),
|
||||
InlineKeyboardButton("Sold Clienti", callback_data="menu:clienti")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("Sold Furnizori", callback_data="menu:furnizori"),
|
||||
InlineKeyboardButton("Evolutie Incasari", callback_data="menu:evolutie")
|
||||
]
|
||||
])
|
||||
|
||||
# Row 5: Help button (full width)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Help", callback_data="action:help")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_action_buttons(current_view: str, show_export: bool = True, show_back: bool = False) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create action buttons for responses (Refresh, Export, Back, Menu).
|
||||
|
||||
Layout (buttons made wide by message text padding):
|
||||
[Refresh] [Export] (if show_export=True)
|
||||
[Înapoi] (if show_back=True, full width)
|
||||
[Menu] (full width)
|
||||
|
||||
Or:
|
||||
[Refresh] (if show_export=False)
|
||||
[Înapoi] (if show_back=True, full width)
|
||||
[Menu] (full width)
|
||||
|
||||
Args:
|
||||
current_view: View identifier for refresh callback (e.g., "sold", "clienti")
|
||||
show_export: Whether to show Export button
|
||||
show_back: Whether to show Back button to list
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with action buttons
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Row 1: Refresh and optionally Export
|
||||
if show_export:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Refresh", callback_data=f"action:refresh:{current_view}"),
|
||||
InlineKeyboardButton("Export", callback_data=f"action:export:{current_view}")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Refresh", callback_data=f"action:refresh:{current_view}")
|
||||
])
|
||||
|
||||
# Row 2: Back to List (if show_back is True)
|
||||
if show_back:
|
||||
# Determine back callback based on current view
|
||||
# ✅ FIX: Handle detail views (client_detail:name, supplier_detail:name)
|
||||
if current_view.startswith("client_detail:"):
|
||||
back_callback = "menu:clienti" # Back to client list
|
||||
elif current_view.startswith("supplier_detail:"):
|
||||
back_callback = "menu:furnizori" # Back to supplier list
|
||||
elif current_view == "clienti":
|
||||
back_callback = "clients_page:0" # Match handlers.py:1689
|
||||
elif current_view == "furnizori":
|
||||
back_callback = "suppliers_page:0" # Match handlers.py:1721
|
||||
else:
|
||||
back_callback = "action:menu" # Fallback to menu
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("« Înapoi", callback_data=back_callback)
|
||||
])
|
||||
|
||||
# Row 3: Back to Menu (full width)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Meniu Principal", callback_data="action:menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create client list keyboard (Level 2) with client buttons and pagination.
|
||||
|
||||
Layout: 1 column for clients, pagination controls, 2 columns for navigation
|
||||
|
||||
Args:
|
||||
clients: List of client dicts with keys: id, name, balance
|
||||
max_items: Maximum number of clients per page (default: 10)
|
||||
page: Current page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with client list buttons and pagination
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_clients = len(clients)
|
||||
total_pages = (total_clients + max_items - 1) // max_items # Ceiling division
|
||||
start_idx = page * max_items
|
||||
end_idx = min(start_idx + max_items, total_clients)
|
||||
|
||||
# Display clients for current page
|
||||
display_clients = clients[start_idx:end_idx]
|
||||
|
||||
# Add client buttons (1 per row)
|
||||
for client in display_clients:
|
||||
client_name = client.get('name', 'N/A')
|
||||
balance = client.get('balance', 0)
|
||||
|
||||
# Format balance with thousands separator
|
||||
balance_str = f"{balance:,.0f}" if balance else "0"
|
||||
|
||||
button_text = f"{client_name} - {balance_str} RON"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
button_text,
|
||||
callback_data=f"details:client:{client_name}:0" # name:page
|
||||
)
|
||||
])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"clients_page:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Următor >", callback_data=f"clients_page:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Navigation row: Back and Refresh (2 buttons per row)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Înapoi", callback_data="action:menu"),
|
||||
InlineKeyboardButton("Refresh", callback_data="action:refresh:clienti")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
|
||||
|
||||
Layout: 1 column for suppliers, pagination controls, 2 columns for navigation
|
||||
|
||||
Args:
|
||||
suppliers: List of supplier dicts with keys: id, name, balance
|
||||
max_items: Maximum number of suppliers per page (default: 10)
|
||||
page: Current page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with supplier list buttons and pagination
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_suppliers = len(suppliers)
|
||||
total_pages = (total_suppliers + max_items - 1) // max_items # Ceiling division
|
||||
start_idx = page * max_items
|
||||
end_idx = min(start_idx + max_items, total_suppliers)
|
||||
|
||||
# Display suppliers for current page
|
||||
display_suppliers = suppliers[start_idx:end_idx]
|
||||
|
||||
# Add supplier buttons (1 per row)
|
||||
for supplier in display_suppliers:
|
||||
supplier_name = supplier.get('name', 'N/A')
|
||||
balance = supplier.get('balance', 0)
|
||||
|
||||
# Format balance with thousands separator
|
||||
balance_str = f"{balance:,.0f}" if balance else "0"
|
||||
|
||||
button_text = f"{supplier_name} - {balance_str} RON"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
button_text,
|
||||
callback_data=f"details:supplier:{supplier_name}:0" # name:page
|
||||
)
|
||||
])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"suppliers_page:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Următor >", callback_data=f"suppliers_page:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Navigation row: Back and Refresh (2 buttons per row)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Înapoi", callback_data="action:menu"),
|
||||
InlineKeyboardButton("Refresh", callback_data="action:refresh:furnizori")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_invoice_list_keyboard(
|
||||
invoices: List[Dict],
|
||||
partner_type: str,
|
||||
partner_name: str,
|
||||
max_items: int = 10,
|
||||
page: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create invoice list keyboard (Level 3) with invoice buttons and pagination.
|
||||
|
||||
Layout: 1 column for invoices, pagination controls, 2 columns for navigation
|
||||
|
||||
Args:
|
||||
invoices: List of invoice dicts with keys: id, number, amount, status
|
||||
partner_type: "CLIENTI" or "FURNIZORI"
|
||||
partner_name: Client/supplier name (for back navigation)
|
||||
max_items: Maximum number of invoices per page (default: 10)
|
||||
page: Current page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with invoice list buttons and pagination
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_invoices = len(invoices)
|
||||
total_pages = (total_invoices + max_items - 1) // max_items # Ceiling division
|
||||
start_idx = page * max_items
|
||||
end_idx = min(start_idx + max_items, total_invoices)
|
||||
|
||||
# Display invoices for current page
|
||||
display_invoices = invoices[start_idx:end_idx]
|
||||
|
||||
# Add invoice buttons (1 per row)
|
||||
for invoice in display_invoices:
|
||||
invoice_id = invoice.get('id', 0)
|
||||
invoice_number = invoice.get('number', 'N/A')
|
||||
amount = invoice.get('amount', 0)
|
||||
status = invoice.get('status', 'unknown')
|
||||
|
||||
# Format amount with thousands separator
|
||||
amount_str = f"{amount:,.0f}" if amount else "0"
|
||||
|
||||
# Status text indicator (no emojis)
|
||||
status_text = "[NEPLATIT]" if status in ['unpaid', 'overdue'] else "[PLATIT]"
|
||||
|
||||
button_text = f"{status_text} {invoice_number} - {amount_str} RON"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
button_text,
|
||||
callback_data=f"invoice:{partner_type}:{invoice_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"invoices_page:{partner_type}:{partner_name}:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Următor >", callback_data=f"invoices_page:{partner_type}:{partner_name}:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Navigation row: Back and Export (2 buttons per row)
|
||||
back_target = "clienti" if partner_type == "CLIENTI" else "furnizori"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Înapoi", callback_data=f"nav:back:{back_target}"),
|
||||
InlineKeyboardButton("Export", callback_data=f"action:export:{partner_type.lower()}")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_navigation_buttons(back_to: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create simple navigation buttons (just Back button).
|
||||
|
||||
Args:
|
||||
back_to: Target location identifier (e.g., "menu", "clienti", "furnizori")
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with navigation button
|
||||
"""
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
f"< Înapoi la {back_to}",
|
||||
callback_data=f"nav:back:{back_to}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
Reference in New Issue
Block a user