""" 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 backend.modules.telegram.api.client import get_backend_client from backend.modules.telegram.agent.session import SessionManager from backend.modules.telegram.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 backend.modules.telegram.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 = 20, back_callback: str = "action:menu", page_callback_prefix: str = "select_company_page", page_callback_suffix: str = "" ) -> 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: 20) back_callback: Callback data for the back button (default: "action:menu") page_callback_prefix: Prefix for pagination callbacks (default: "select_company_page") page_callback_suffix: Suffix appended after page number in pagination callbacks 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: prev_cb = f"{page_callback_prefix}:{page-1}{page_callback_suffix}" nav_buttons.append( InlineKeyboardButton("< Anterior", callback_data=prev_cb) ) # Page indicator (non-clickable) nav_buttons.append( InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop") ) # Next button if page < total_pages - 1: next_cb = f"{page_callback_prefix}:{page+1}{page_callback_suffix}" nav_buttons.append( InlineKeyboardButton("Urmator >", callback_data=next_cb) ) keyboard.append(nav_buttons) # A-Z filter + back button keyboard.append([ InlineKeyboardButton("Filtrare A-Z", callback_data="select_company_alpha_menu") ]) keyboard.append([ InlineKeyboardButton("« Înapoi", callback_data=back_callback) ]) return InlineKeyboardMarkup(keyboard) def create_alpha_filter_keyboard() -> InlineKeyboardMarkup: """ Create inline keyboard with A–Z letter buttons for filtering companies. Displays 26 letter buttons in rows of 6, plus a 'Toată lista' button that shows all companies without filtering. Returns: InlineKeyboardMarkup with letter buttons and navigation """ letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") keyboard = [] row_size = 6 for i in range(0, len(letters), row_size): row = [ InlineKeyboardButton(l, callback_data=f"select_company_alpha:{l}") for l in letters[i:i + row_size] ] keyboard.append(row) keyboard.append([ InlineKeyboardButton("Toată lista", callback_data="select_company_alpha:ALL"), InlineKeyboardButton("« Meniu", callback_data="action:menu") ]) return InlineKeyboardMarkup(keyboard) def create_alpha_filter_keyboard_partner(partner_type: str) -> InlineKeyboardMarkup: """ Create inline keyboard with A–Z letter buttons for filtering clients or suppliers. Args: partner_type: "clients" or "suppliers" Returns: InlineKeyboardMarkup with letter buttons and navigation """ letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") keyboard = [] row_size = 6 for i in range(0, len(letters), row_size): row = [ InlineKeyboardButton(l, callback_data=f"{partner_type}_alpha:{l}") for l in letters[i:i + row_size] ] keyboard.append(row) keyboard.append([ InlineKeyboardButton("Toata lista", callback_data=f"{partner_type}_alpha:ALL"), InlineKeyboardButton("« 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', []) ] result = { 'casa': { 'accounts': casa_accounts, 'total': float(casa_data.get('total', 0)) }, 'banca': { 'accounts': banca_accounts, 'total': float(banca_data.get('total', 0)) } } # Pass through cache metadata if present if 'cache_hit' in breakdown: result['cache_hit'] = breakdown['cache_hit'] if 'response_time_ms' in breakdown: result['response_time_ms'] = breakdown['response_time_ms'] if 'cache_source' in breakdown: result['cache_source'] = breakdown['cache_source'] return result 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 result = { 'clients': clients, 'maturity': { 'in_term': in_term, 'overdue': overdue, 'total': total } } # Pass through cache metadata if present if 'cache_hit' in maturity_response: result['cache_hit'] = maturity_response['cache_hit'] if 'response_time_ms' in maturity_response: result['response_time_ms'] = maturity_response['response_time_ms'] if 'cache_source' in maturity_response: result['cache_source'] = maturity_response['cache_source'] return result 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 result = { 'suppliers': suppliers, 'maturity': { 'in_term': in_term, 'overdue': overdue, 'total': total } } # Pass through cache metadata if present if 'cache_hit' in maturity_response: result['cache_hit'] = maturity_response['cache_hit'] if 'response_time_ms' in maturity_response: result['response_time_ms'] = maturity_response['response_time_ms'] if 'cache_source' in maturity_response: result['cache_source'] = maturity_response['cache_source'] return result 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 with YTD comparison. Uses trends endpoint which returns 12-month historical data for current and previous year. Calculates YTD for comparison and extracts last 12 months in reverse chronological order. Args: company_id: Company ID jwt_token: JWT authentication token period: Period for trends data (default: "12m") Returns: Dict with: - 'performance': Dict with YTD data for current and previous year - 'monthly': Dict with last 12 months data (reverse chronological) + prev year comparison None if request fails Example: data = await get_cashflow_evolution_data(1, token) ytd_2025 = data['performance']['current_year'] ytd_2024 = data['performance']['previous_year'] """ try: client = get_backend_client() async with client: # Get trends data (12 months of historical data) trends_data = await client.get_trends( company_id=company_id, jwt_token=jwt_token, period="12m" ) if not trends_data: return None # Extract current year data periods = trends_data.get('periods', []) # ["2024-01", "2024-02", ...] clienti_incasat = trends_data.get('clienti_incasat', []) furnizori_achitat = trends_data.get('furnizori_achitat', []) # Extract previous year data previous_periods = trends_data.get('previous_periods', []) clienti_incasat_prev = trends_data.get('clienti_incasat_prev', []) furnizori_achitat_prev = trends_data.get('furnizori_achitat_prev', []) if not periods or not clienti_incasat or not furnizori_achitat: logger.warning("Trends data missing required fields") return None # Calculate YTD (Year-To-Date) = sum of all available months incasari_ytd = sum(clienti_incasat) plati_ytd = sum(furnizori_achitat) net_ytd = incasari_ytd - plati_ytd incasari_ytd_prev = sum(clienti_incasat_prev) if clienti_incasat_prev else 0 plati_ytd_prev = sum(furnizori_achitat_prev) if furnizori_achitat_prev else 0 net_ytd_prev = incasari_ytd_prev - plati_ytd_prev # Extract years from periods current_year = periods[-1].split('-')[0] if periods else "2025" previous_year = previous_periods[-1].split('-')[0] if previous_periods else "2024" # Take last 12 months (current year) last_12_periods = periods[-12:] last_12_incasari = clienti_incasat[-12:] last_12_plati = furnizori_achitat[-12:] # Take corresponding previous year months last_12_periods_prev = previous_periods[-12:] if previous_periods else [] last_12_incasari_prev = clienti_incasat_prev[-12:] if clienti_incasat_prev else [0] * 12 last_12_plati_prev = furnizori_achitat_prev[-12:] if furnizori_achitat_prev else [0] * 12 # Month abbreviations (Romanian) month_abbr = { '01': 'Ian', '02': 'Feb', '03': 'Mar', '04': 'Apr', '05': 'Mai', '06': 'Iun', '07': 'Iul', '08': 'Aug', '09': 'Sep', '10': 'Oct', '11': 'Noi', '12': 'Dec' } # Format months as "Noi'25/'24" formatted_months = [] for i, period_str in enumerate(last_12_periods): if '-' in period_str: year = period_str.split('-')[0][-2:] # Last 2 digits: "25" month_num = period_str.split('-')[1] month_name = month_abbr.get(month_num, month_num) # Get previous year month prev_year = previous_year[-2:] if previous_year else "24" formatted_months.append(f"{month_name}'{year}/'{prev_year}") else: formatted_months.append(period_str) # Reverse chronological order (newest first) formatted_months.reverse() last_12_incasari.reverse() last_12_plati.reverse() last_12_incasari_prev.reverse() last_12_plati_prev.reverse() # Build performance summary (YTD) performance = { 'current_year': { 'year': current_year, 'incasari': incasari_ytd, 'plati': plati_ytd, 'net': net_ytd }, 'previous_year': { 'year': previous_year, 'incasari': incasari_ytd_prev, 'plati': plati_ytd_prev, 'net': net_ytd_prev } } # Build monthly breakdown (reverse chronological with prev year comparison) monthly = { 'months': formatted_months, 'incasari': last_12_incasari, 'plati': last_12_plati, 'incasari_prev': last_12_incasari_prev, 'plati_prev': last_12_plati_prev } result = { 'performance': performance, 'monthly': monthly } # Pass through cache metadata if present if 'cache_hit' in trends_data: result['cache_hit'] = trends_data['cache_hit'] if 'response_time_ms' in trends_data: result['response_time_ms'] = trends_data['response_time_ms'] if 'cache_source' in trends_data: result['cache_source'] = trends_data['cache_source'] return result 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' ]