- Add A-Z alphabetical filter keyboard for clients and suppliers lists (same pattern as company selection, without emoji) - Increase clients/suppliers list pagination from 10 to 20 items per page - Remove emoji from company A-Z filter button for consistency - Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER, clients_alpha_page:PAGE:LETTER, and supplier equivalents - Dashboard service and models updates - Telegram bot: email handlers, auth, DB operations, internal API improvements - Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury) - Frontend: SolduriCompactCard and CollapsibleCard improvements - DashboardView enhancements - start.sh and run-with-restart.sh script updates - IIS web.config and service worker updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
878 lines
29 KiB
Python
878 lines
29 KiB
Python
"""
|
||
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'
|
||
]
|