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:
2025-10-25 14:55:08 +03:00
commit 6b13ffa183
237 changed files with 70035 additions and 0 deletions

View 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

File diff suppressed because it is too large Load Diff

View 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'
]

View 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)