diff --git a/reports-app/telegram-bot/app/api/client.py b/reports-app/telegram-bot/app/api/client.py index a802d7b..27f5e03 100644 --- a/reports-app/telegram-bot/app/api/client.py +++ b/reports-app/telegram-bot/app/api/client.py @@ -407,6 +407,38 @@ class BackendAPIClient: logger.error(f"Failed to get monthly flows for company {company_id}: {e}") return None + async def get_trends( + self, + company_id: int, + jwt_token: str, + period: str = "12m" + ) -> Optional[Dict[str, Any]]: + """ + Get trends data (12-month historical data for collections/payments). + + Args: + company_id: Company ID + jwt_token: JWT access token + period: Period for trends (e.g., "12m", "6m", "ytd") + + Returns: + Dict with trends data including periods, clienti_incasat, furnizori_achitat arrays + """ + try: + if not self.client: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + response = await self.client.get( + f"/api/dashboard/trends?company={company_id}&period={period}", + headers=self._get_auth_headers(jwt_token) + ) + + return await self._handle_response(response) + + except Exception as e: + logger.error(f"Failed to get trends for company {company_id}: {e}") + return None + # ========================================================================= # INVOICES ENDPOINTS # ========================================================================= diff --git a/reports-app/telegram-bot/app/bot/formatters.py b/reports-app/telegram-bot/app/bot/formatters.py index fd12ece..70c6774 100644 --- a/reports-app/telegram-bot/app/bot/formatters.py +++ b/reports-app/telegram-bot/app/bot/formatters.py @@ -203,9 +203,9 @@ def format_clients_balance_response( text += f" - In termen: {in_term:,} RON\n" text += f" - Restanta: {overdue:,} RON\n\n" - # Top clients + # Top 10 clients if clients: - text += f"**Top Clienti** ({len(clients)} total):\n" + text += f"**Top 10 Clienti** ({len(clients)} total):\n" # Sort by balance descending sorted_clients = sorted( clients, @@ -213,12 +213,12 @@ def format_clients_balance_response( reverse=True ) - for idx, client in enumerate(sorted_clients[:5], 1): + for idx, client in enumerate(sorted_clients[:10], 1): name = client.get('name', 'N/A') balance = round(client.get('balance', 0)) text += f"{idx}. {name}: {balance:,} RON\n" - if len(clients) > 5: + if len(clients) > 10: text += f"\nApasa butonul pentru lista completa" else: text += "Nu exista clienti cu solduri." @@ -260,9 +260,9 @@ def format_suppliers_balance_response( text += f" - In termen: {in_term:,} RON\n" text += f" - Restanta: {overdue:,} RON\n\n" - # Top suppliers + # Top 10 suppliers if suppliers: - text += f"**Top Furnizori** ({len(suppliers)} total):\n" + text += f"**Top 10 Furnizori** ({len(suppliers)} total):\n" # Sort by balance descending sorted_suppliers = sorted( suppliers, @@ -270,12 +270,12 @@ def format_suppliers_balance_response( reverse=True ) - for idx, supplier in enumerate(sorted_suppliers[:5], 1): + for idx, supplier in enumerate(sorted_suppliers[:10], 1): name = supplier.get('name', 'N/A') balance = round(supplier.get('balance', 0)) text += f"{idx}. {name}: {balance:,} RON\n" - if len(suppliers) > 5: + if len(suppliers) > 10: text += f"\nApasa butonul pentru lista completa" else: text += "Nu exista furnizori cu solduri." @@ -289,56 +289,117 @@ def format_cashflow_evolution_response( company_name: str = None ) -> str: """ - Format cash flow evolution data (content only, no header). + Format cash flow evolution data - Table format with mini-charts. Args: - performance_data: Dict with incasari_total, plati_total, net - monthly_data: Dict with months, incasari, plati arrays + performance_data: Dict with current_year and previous_year YTD data + monthly_data: Dict with months, incasari, plati arrays + prev year data company_name: Company name (kept for compatibility, not used) Returns: - Formatted Markdown string for Telegram + Formatted Markdown string for Telegram (monospace table) 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) + YTD 2024 vs 2023: + 2024 2023 Δ Trend + Inc: 500,000 480,000 +4.2% ████░ + Plt: 450,000 440,000 +2.3% ███░ + Net: 50,000 40,000 +25.0% █████ """ 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)) + # Helper functions + def calc_percent_change(current: float, previous: float) -> str: + """Calculate percentage change: +4.2% or -3.5%""" + if previous == 0: + return "+100%" if current > 0 else "0.0%" + change = ((current - previous) / previous) * 100 + sign = "+" if change >= 0 else "" + return f"{sign}{change:.1f}%" - 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" + def create_mini_chart(current: float, previous: float, width: int = 5) -> str: + """Create mini bar chart: ████░ (proportional bars)""" + if current == 0 and previous == 0: + return "─" * width - # Monthly breakdown + max_val = max(current, previous) + if max_val == 0: + return "─" * width + + curr_bars = int((current / max_val) * width) + prev_bars = int((previous / max_val) * width) + + # Use filled and light blocks + filled = "█" * curr_bars + light = "░" * (width - curr_bars) + return filled + light + + def get_trend_arrow(current: float, previous: float) -> str: + """Get trend arrow: ↑ or ↓ or →""" + if current > previous * 1.02: # More than 2% increase + return "↑" + elif current < previous * 0.98: # More than 2% decrease + return "↓" + else: + return "→" + + # Extract YTD data + current = performance_data.get('current_year', {}) + previous = performance_data.get('previous_year', {}) + + current_year = current.get('year', '2024') + previous_year = previous.get('year', '2023') + + inc_cur = round(current.get('incasari', 0)) + plt_cur = round(current.get('plati', 0)) + net_cur = round(current.get('net', 0)) + + inc_prev = round(previous.get('incasari', 0)) + plt_prev = round(previous.get('plati', 0)) + net_prev = round(previous.get('net', 0)) + + # YTD Table Header + text += f"**YTD {current_year} vs {previous_year}:**\n" + text += f"` {current_year:>10} {previous_year:>10} Δ `\n" + + # YTD Rows + inc_pct = calc_percent_change(inc_cur, inc_prev) + text += f"`Inc: {inc_cur:>10,} {inc_prev:>10,} {inc_pct:>6}`\n" + + plt_pct = calc_percent_change(plt_cur, plt_prev) + text += f"`Plt: {plt_cur:>10,} {plt_prev:>10,} {plt_pct:>6}`\n" + + net_pct = calc_percent_change(net_cur, net_prev) + text += f"`Net: {net_cur:>10,} {net_prev:>10,} {net_pct:>6}`\n\n" + + # Monthly Evolution Table - Simplified (Net only) months = monthly_data.get('months', []) incasari = monthly_data.get('incasari', []) plati = monthly_data.get('plati', []) + incasari_prev = monthly_data.get('incasari_prev', []) + plati_prev = monthly_data.get('plati_prev', []) if months and len(months) > 0: - text += "**Evolutie Lunara** (ultimele luni):\n" + text += "**Evolutie Net (12 luni):**\n" + text += f"` {current_year:>10} {previous_year:>10} Δ `\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 + for i, month in enumerate(months): + inc = incasari[i] if i < len(incasari) else 0 + plt = plati[i] if i < len(plati) else 0 + inc_p = incasari_prev[i] if i < len(incasari_prev) else 0 + plt_p = plati_prev[i] if i < len(plati_prev) else 0 - # Simple ASCII bar - net_indicator = "+" if net_month > 0 else "-" if net_month < 0 else "=" + net = inc - plt + net_p = inc_p - plt_p - 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" + # Extract short month name (first 3 chars before apostrophe) + month_short = month.split("'")[0][:3] if "'" in month else month[:3] + + # Calculate percentage change + net_pct = calc_percent_change(net, net_p) + + # Format row: Luna Net'current Net'prev Δ (aligned with YTD) + text += f"`{month_short:<4} {int(net):>10,} {int(net_p):>10,} {net_pct:>6}`\n" else: text += "Nu exista date lunare disponibile." diff --git a/reports-app/telegram-bot/app/bot/handlers.py b/reports-app/telegram-bot/app/bot/handlers.py index c4f58bf..945bf46 100644 --- a/reports-app/telegram-bot/app/bot/handlers.py +++ b/reports-app/telegram-bot/app/bot/handlers.py @@ -984,7 +984,7 @@ async def evolutie_command(update: Update, context: ContextTypes.DEFAULT_TYPE): evolution_data['monthly'] ) response = format_response_with_company(content, company['name']) - keyboard = create_action_buttons("evolutie", show_export=False) + keyboard = create_action_buttons("evolutie", show_export=False, show_refresh=False) await update.message.reply_text( response, @@ -1301,7 +1301,7 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) evolution_data['monthly'] ) response = format_response_with_company(content, company['name']) - keyboard = create_action_buttons("evolutie", show_export=False) + keyboard = create_action_buttons("evolutie", show_export=False, show_refresh=False) await query.edit_message_text( response, @@ -2149,7 +2149,7 @@ async def _handle_sold_view( content = format_dashboard_response(data) response = format_response_with_company(content, company['name']) - keyboard = create_action_buttons("sold", show_export=True) + keyboard = create_action_buttons("sold", show_export=False, show_refresh=False) if is_callback: await query_or_update.edit_message_text( diff --git a/reports-app/telegram-bot/app/bot/helpers.py b/reports-app/telegram-bot/app/bot/helpers.py index acd58e5..5e88298 100644 --- a/reports-app/telegram-bot/app/bot/helpers.py +++ b/reports-app/telegram-bot/app/bot/helpers.py @@ -524,61 +524,130 @@ async def get_cashflow_evolution_data( period: str = "12m" ) -> Optional[Dict[str, Any]]: """ - Get cash flow evolution data. + Get cash flow evolution data with YTD comparison. - Uses monthly flows endpoint which returns current month data. - Backend returns: {'inflows': float, 'outflows': float, 'period': str, 'currency': str} + 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 monthly data (default: "12m") + period: Period for trends data (default: "12m") Returns: Dict with: - - 'performance': Dict with incasari_total, plati_total, net - - 'monthly': Dict with months, incasari, plati arrays + - '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) - net = data['performance']['net'] # Net cash flow - months = data['monthly']['months'] # List of month names + ytd_2025 = data['performance']['current_year'] + ytd_2024 = data['performance']['previous_year'] """ try: client = get_backend_client() async with client: - # Get monthly flows (current month only from backend) - monthly_flows = await client.get_monthly_flows( + # Get trends data (12 months of historical data) + trends_data = await client.get_trends( company_id=company_id, jwt_token=jwt_token, - months=12 # Note: backend ignores this and returns only current month + period="12m" ) - if not monthly_flows: + if not trends_data: 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ă') + # 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', []) - # Calculate net - net = inflows - outflows + # 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', []) - # Build performance summary - performance = { - 'incasari_total': inflows, - 'plati_total': outflows, - 'net': net + 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' } - # Build monthly breakdown (single month from backend) + # 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': [period_name], - 'incasari': [inflows], - 'plati': [outflows] + 'months': formatted_months, + 'incasari': last_12_incasari, + 'plati': last_12_plati, + 'incasari_prev': last_12_incasari_prev, + 'plati_prev': last_12_plati_prev } return { diff --git a/reports-app/telegram-bot/app/bot/menus.py b/reports-app/telegram-bot/app/bot/menus.py index b613be4..56a547a 100644 --- a/reports-app/telegram-bot/app/bot/menus.py +++ b/reports-app/telegram-bot/app/bot/menus.py @@ -332,14 +332,17 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: """ keyboard = [] + # Sort clients alphabetically by name + sorted_clients = sorted(clients, key=lambda x: x.get('name', '').lower()) + # Calculate pagination - total_clients = len(clients) + total_clients = len(sorted_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] + display_clients = sorted_clients[start_idx:end_idx] # Add client buttons (1 per row) for client in display_clients: @@ -385,10 +388,9 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: keyboard.append(nav_buttons) - # Navigation row: Back and Refresh (2 buttons per row) + # Navigation row: Back button only keyboard.append([ - InlineKeyboardButton("< Înapoi", callback_data="action:menu"), - InlineKeyboardButton("Refresh", callback_data="action:refresh:clienti") + InlineKeyboardButton("< Înapoi", callback_data="action:menu") ]) return InlineKeyboardMarkup(keyboard) @@ -410,14 +412,17 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa """ keyboard = [] + # Sort suppliers alphabetically by name + sorted_suppliers = sorted(suppliers, key=lambda x: x.get('name', '').lower()) + # Calculate pagination - total_suppliers = len(suppliers) + total_suppliers = len(sorted_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] + display_suppliers = sorted_suppliers[start_idx:end_idx] # Add supplier buttons (1 per row) for supplier in display_suppliers: @@ -463,10 +468,9 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa keyboard.append(nav_buttons) - # Navigation row: Back and Refresh (2 buttons per row) + # Navigation row: Back button only keyboard.append([ - InlineKeyboardButton("< Înapoi", callback_data="action:menu"), - InlineKeyboardButton("Refresh", callback_data="action:refresh:furnizori") + InlineKeyboardButton("< Înapoi", callback_data="action:menu") ]) return InlineKeyboardMarkup(keyboard)