- 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>
970 lines
31 KiB
Python
970 lines
31 KiB
Python
"""
|
|
API Client for ROA2WEB Backend Communication
|
|
|
|
This module provides an async HTTP client for communicating with the FastAPI backend.
|
|
Handles authentication, requests, error handling, and response parsing.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from typing import Optional, Dict, Any, List
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from httpx import AsyncClient, Response, HTTPError, ConnectError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Backend configuration from environment
|
|
# Default to port 8000 (production) instead of 8001 (development)
|
|
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
|
|
REQUEST_TIMEOUT = float(os.getenv("API_TIMEOUT", "30.0")) # 30 seconds default
|
|
|
|
|
|
class BackendAPIClient:
|
|
"""
|
|
Async HTTP client for ROA2WEB FastAPI backend.
|
|
|
|
Provides methods for all API endpoints used by the Telegram bot:
|
|
- Dashboard data
|
|
- Invoices search and retrieval
|
|
- Treasury/payment data
|
|
- Report exports
|
|
- Company listings
|
|
- User authentication and token management
|
|
"""
|
|
|
|
def __init__(self, base_url: str = BACKEND_URL):
|
|
"""
|
|
Initialize the API client.
|
|
|
|
Args:
|
|
base_url: Base URL of the FastAPI backend
|
|
"""
|
|
self.base_url = base_url.rstrip('/')
|
|
self.client: Optional[AsyncClient] = None
|
|
logger.info(f"Backend API client initialized with base URL: {self.base_url}")
|
|
|
|
async def __aenter__(self):
|
|
"""Async context manager entry."""
|
|
self.client = AsyncClient(
|
|
base_url=self.base_url,
|
|
timeout=REQUEST_TIMEOUT,
|
|
follow_redirects=True
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
"""Async context manager exit."""
|
|
if self.client:
|
|
await self.client.aclose()
|
|
|
|
def _get_auth_headers(self, jwt_token: str) -> Dict[str, str]:
|
|
"""
|
|
Generate authentication headers with JWT token.
|
|
|
|
Args:
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with Authorization header
|
|
"""
|
|
return {
|
|
"Authorization": f"Bearer {jwt_token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
async def _handle_response(self, response: Response) -> Dict[str, Any]:
|
|
"""
|
|
Handle API response and extract data.
|
|
|
|
Args:
|
|
response: HTTP response object
|
|
|
|
Returns:
|
|
Dict: Response JSON data
|
|
|
|
Raises:
|
|
HTTPError: If response status is not successful
|
|
"""
|
|
try:
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except HTTPError as e:
|
|
logger.error(f"API request failed: {e}")
|
|
logger.error(f"Response body: {response.text}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse response: {e}")
|
|
raise
|
|
|
|
# =========================================================================
|
|
# AUTHENTICATION & USER ENDPOINTS
|
|
# =========================================================================
|
|
|
|
async def verify_user(
|
|
self,
|
|
oracle_username: str,
|
|
linking_code: str,
|
|
server_id: Optional[str] = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Verify user exists in Oracle and get JWT token.
|
|
Called during Telegram linking process (auto-linking flow).
|
|
|
|
Args:
|
|
oracle_username: Oracle username extracted from linking code
|
|
linking_code: The 8-character linking code for validation
|
|
|
|
Returns:
|
|
Dict with:
|
|
- success: True if verification succeeded
|
|
- access_token: JWT access token
|
|
- refresh_token: JWT refresh token
|
|
- user: Dict with user_id, username, companies, permissions
|
|
- message: Status message
|
|
|
|
None if user not found or error
|
|
|
|
Example:
|
|
result = await client.verify_user("JOHN.DOE", "ABC12345")
|
|
if result and result['success']:
|
|
jwt_token = result['access_token']
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Flow A: Auto-linking (no password required)
|
|
response = await self.client.post(
|
|
"/api/telegram/auth/verify-user",
|
|
json={
|
|
"linking_code": linking_code,
|
|
"oracle_username": oracle_username,
|
|
"server_id": server_id
|
|
}
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except ConnectError as e:
|
|
logger.error(f"Cannot connect to backend at {self.base_url}: {e}")
|
|
logger.error("Verify that backend service is running and BACKEND_URL is correct")
|
|
return None
|
|
except HTTPError as e:
|
|
if e.response.status_code == 404:
|
|
logger.warning(f"User {oracle_username} not found in Oracle")
|
|
return None
|
|
logger.error(f"Failed to verify user {oracle_username}: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error verifying user: {e}")
|
|
return None
|
|
|
|
async def refresh_token(self, refresh_token: str) -> Optional[str]:
|
|
"""
|
|
Refresh JWT token for a user.
|
|
|
|
Args:
|
|
refresh_token: JWT refresh token
|
|
|
|
Returns:
|
|
str: New JWT access token, None if failed
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.post(
|
|
"/api/telegram/auth/refresh-token",
|
|
json={"refresh_token": refresh_token}
|
|
)
|
|
|
|
data = await self._handle_response(response)
|
|
return data.get('access_token')
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh token: {e}")
|
|
return None
|
|
|
|
async def verify_email(self, email: str, server_id: Optional[str] = None) -> dict:
|
|
"""
|
|
Verify if email exists in Oracle database
|
|
|
|
Args:
|
|
email: Email address to verify
|
|
server_id: Optional Oracle server ID (for multi-server mode)
|
|
|
|
Returns:
|
|
dict with 'success' (bool), 'username' (str or None), and 'message' (str)
|
|
|
|
Raises:
|
|
httpx.HTTPError: On network or HTTP errors
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.post(
|
|
"/api/telegram/auth/verify-email",
|
|
json={"email": email, "server_id": server_id}
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"HTTP error verifying email {email}: {e.response.status_code}")
|
|
return {
|
|
"success": False,
|
|
"username": None,
|
|
"message": "Eroare la verificarea email-ului"
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to verify email {email}: {e}")
|
|
return {
|
|
"success": False,
|
|
"username": None,
|
|
"message": "Eroare la verificarea email-ului"
|
|
}
|
|
|
|
async def login_with_email(
|
|
self,
|
|
email: str,
|
|
password: str,
|
|
telegram_user_id: int,
|
|
session_token: str,
|
|
server_id: Optional[str] = None
|
|
) -> dict:
|
|
"""
|
|
Login via email + password with session token
|
|
|
|
Args:
|
|
email: User email address
|
|
password: Oracle password
|
|
telegram_user_id: Telegram user ID
|
|
session_token: Signed token from code validation
|
|
server_id: Optional Oracle server ID (for multi-server mode)
|
|
|
|
Returns:
|
|
Login response with JWT tokens and user data
|
|
|
|
Raises:
|
|
httpx.HTTPError: On network or HTTP errors
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.post(
|
|
"/api/telegram/auth/login-with-email",
|
|
json={
|
|
"email": email,
|
|
"password": password,
|
|
"telegram_user_id": telegram_user_id,
|
|
"session_token": session_token,
|
|
"server_id": server_id
|
|
},
|
|
timeout=30.0 # 30 seconds timeout
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
logger.info(f"Email login successful for user {telegram_user_id}")
|
|
|
|
return data
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"Email login HTTP error: {e.response.status_code} - {e.response.text}")
|
|
|
|
# Parse error detail if available
|
|
try:
|
|
error_data = e.response.json()
|
|
return {
|
|
"success": False,
|
|
"message": error_data.get("detail", "Autentificare eșuată")
|
|
}
|
|
except:
|
|
return {
|
|
"success": False,
|
|
"message": "Autentificare eșuată"
|
|
}
|
|
|
|
except httpx.TimeoutException:
|
|
logger.error("Email login timeout")
|
|
return {
|
|
"success": False,
|
|
"message": "Timeout. Te rugăm să încerci din nou."
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Email login error: {e}", exc_info=True)
|
|
return {
|
|
"success": False,
|
|
"message": "Eroare de conexiune"
|
|
}
|
|
|
|
async def switch_server(
|
|
self,
|
|
jwt_token: str,
|
|
oracle_username: str,
|
|
new_server_id: str,
|
|
oracle_password: str = None
|
|
) -> dict:
|
|
"""
|
|
Switch the active Oracle server for the authenticated user.
|
|
|
|
Args:
|
|
jwt_token: Current JWT access token (used for authentication)
|
|
oracle_username: Oracle username of the current user
|
|
new_server_id: Target Oracle server ID
|
|
oracle_password: Oracle password on the new server (required if servers have different passwords)
|
|
|
|
Returns:
|
|
Dict with success, access_token, refresh_token, message
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
payload = {"oracle_username": oracle_username, "new_server_id": new_server_id}
|
|
if oracle_password:
|
|
payload["oracle_password"] = oracle_password
|
|
|
|
response = await self.client.post(
|
|
"/api/telegram/auth/switch-server",
|
|
json=payload,
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"Switch server HTTP error: {e.response.status_code}")
|
|
try:
|
|
return {"success": False, "message": e.response.json().get("detail", "Eroare")}
|
|
except Exception:
|
|
return {"success": False, "message": "Eroare la schimbarea serverului"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Switch server error: {e}")
|
|
return {"success": False, "message": "Eroare de conexiune"}
|
|
|
|
async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get list of companies the user has access to.
|
|
|
|
Args:
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
List of company dicts with id, nume_firma, cui, etc.
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.get(
|
|
"/api/companies",
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
data = await self._handle_response(response)
|
|
|
|
# Backend returns {"companies": [...], "total_count": N}
|
|
if isinstance(data, dict) and "companies" in data:
|
|
return data["companies"]
|
|
|
|
return data if isinstance(data, list) else []
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get companies: {e}")
|
|
return []
|
|
|
|
# =========================================================================
|
|
# DASHBOARD ENDPOINTS
|
|
# =========================================================================
|
|
|
|
async def get_dashboard_data(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get dashboard statistics for a company.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with dashboard data (sold_total, facturi, plati, etc.)
|
|
Includes _cache_hit and _response_time_ms metadata
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
"/api/reports/dashboard/summary",
|
|
params={"company": str(company_id)},
|
|
headers=headers
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get dashboard data for company {company_id}: {e}")
|
|
return None
|
|
|
|
async def get_treasury_breakdown(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get detailed treasury breakdown (casa + banca accounts).
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with treasury breakdown data (accounts by type)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
f"/api/reports/dashboard/treasury-breakdown?company={company_id}",
|
|
headers=headers
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get treasury breakdown for company {company_id}: {e}")
|
|
return None
|
|
|
|
async def get_detailed_data(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str,
|
|
data_type: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get detailed data for clients or suppliers.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
data_type: Type of data ('clients' or 'suppliers')
|
|
|
|
Returns:
|
|
Dict with detailed data (list of clients/suppliers with balances)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
f"/api/reports/dashboard/detailed-data?company={company_id}&data_type={data_type}",
|
|
headers=headers
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get detailed data ({data_type}) for company {company_id}: {e}")
|
|
return None
|
|
|
|
async def get_maturity_data(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str,
|
|
period: str = "all"
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get maturity data (in term/overdue breakdown).
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
period: Period filter ('all', '30', '60', '90')
|
|
|
|
Returns:
|
|
Dict with maturity data (in_term, overdue, total)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
f"/api/reports/dashboard/maturity?company={company_id}&period={period}",
|
|
headers=headers
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get maturity data for company {company_id}: {e}")
|
|
return None
|
|
|
|
async def get_performance_data(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get performance data (incasari/plati totals).
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with performance data (incasari_total, plati_total, net)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
f"/api/reports/dashboard/performance?company={company_id}",
|
|
headers=headers
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get performance data for company {company_id}: {e}")
|
|
return None
|
|
|
|
async def get_monthly_flows(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str,
|
|
months: int = 12
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get monthly cash flows data.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
months: Number of months to retrieve
|
|
|
|
Returns:
|
|
Dict with monthly flows (months, incasari, plati arrays)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
f"/api/reports/dashboard/monthly-flows?company={company_id}&months={months}",
|
|
headers=headers
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
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 or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
# Add cache metadata header for Telegram Bot
|
|
headers = self._get_auth_headers(jwt_token)
|
|
headers['X-Include-Cache-Metadata'] = 'true'
|
|
|
|
response = await self.client.get(
|
|
f"/api/reports/dashboard/trends?company={company_id}&period={period}",
|
|
headers=headers
|
|
)
|
|
|
|
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
|
|
# =========================================================================
|
|
|
|
async def search_invoices(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str,
|
|
filters: Optional[Dict[str, Any]] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search invoices with optional filters.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
filters: Optional filters dict:
|
|
- date_from: str (YYYY-MM-DD)
|
|
- date_to: str (YYYY-MM-DD)
|
|
- status: str (paid, unpaid, overdue)
|
|
- client_name: str
|
|
- partner_type: str (CLIENTI, FURNIZORI)
|
|
- partner_name: str
|
|
- series: str
|
|
- number: str
|
|
|
|
Returns:
|
|
List of invoice dicts
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
params = {"company": company_id}
|
|
if filters:
|
|
params.update(filters)
|
|
|
|
response = await self.client.get(
|
|
"/api/reports/invoices/",
|
|
params=params,
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
data = await self._handle_response(response)
|
|
|
|
if isinstance(data, dict) and 'invoices' in data:
|
|
invoice_list = data['invoices']
|
|
return invoice_list
|
|
elif isinstance(data, list):
|
|
return data
|
|
else:
|
|
logger.warning(f"📥 Unexpected response format: {type(data)}")
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to search invoices for company {company_id}: {e}")
|
|
return []
|
|
|
|
async def get_invoice_summary(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str,
|
|
partner_type: str = "CLIENTI"
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get invoice summary statistics.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with summary (total_count, total_amount, paid, unpaid, etc.)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.get(
|
|
"/api/reports/invoices/summary",
|
|
params={
|
|
"company": str(company_id),
|
|
"partner_type": partner_type
|
|
},
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get invoice summary for company {company_id}: {e}")
|
|
return None
|
|
|
|
# =========================================================================
|
|
# TREASURY ENDPOINTS
|
|
# =========================================================================
|
|
|
|
async def get_treasury_data(
|
|
self,
|
|
company_id: int,
|
|
jwt_token: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get treasury/cash flow data for a company.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with treasury data (cash_balance, incoming, outgoing, etc.)
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.get(
|
|
"/api/reports/treasury/bank-cash-register",
|
|
params={
|
|
"company": str(company_id),
|
|
"page": 1,
|
|
"page_size": 1000
|
|
},
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
return await self._handle_response(response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get treasury data for company {company_id}: {e}")
|
|
return None
|
|
|
|
# =========================================================================
|
|
# EXPORT ENDPOINTS
|
|
# =========================================================================
|
|
|
|
async def export_report(
|
|
self,
|
|
jwt_token: str,
|
|
report_type: str,
|
|
company_id: int,
|
|
format: str = "xlsx",
|
|
filters: Optional[Dict[str, Any]] = None
|
|
) -> Optional[bytes]:
|
|
"""
|
|
Generate and export a report.
|
|
|
|
Args:
|
|
jwt_token: JWT access token
|
|
report_type: Type of report ('dashboard', 'invoices', 'treasury')
|
|
company_id: Company ID
|
|
format: Export format ('xlsx', 'csv', 'pdf')
|
|
filters: Optional filters for data
|
|
|
|
Returns:
|
|
bytes: File content, None if failed
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
request_data = {
|
|
"type": report_type,
|
|
"company_id": company_id,
|
|
"format": format,
|
|
"filters": filters or {}
|
|
}
|
|
|
|
response = await self.client.post(
|
|
"/api/telegram/export",
|
|
json=request_data,
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
response.raise_for_status()
|
|
return response.content
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to export report: {e}")
|
|
return None
|
|
|
|
# =========================================================================
|
|
# CACHE MANAGEMENT
|
|
# =========================================================================
|
|
|
|
async def invalidate_cache(
|
|
self,
|
|
jwt_token: str,
|
|
company_id: Optional[int] = None,
|
|
cache_type: Optional[str] = None
|
|
) -> bool:
|
|
"""
|
|
Invalidate cache entries.
|
|
|
|
Args:
|
|
jwt_token: JWT access token
|
|
company_id: Optional company ID (None = all companies)
|
|
cache_type: Optional cache type (None = all types)
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
request_data = {}
|
|
if company_id is not None:
|
|
request_data['company_id'] = company_id
|
|
if cache_type is not None:
|
|
request_data['cache_type'] = cache_type
|
|
|
|
response = await self.client.post(
|
|
"/api/reports/cache/invalidate",
|
|
json=request_data,
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
response.raise_for_status()
|
|
logger.info(f"Cache invalidated: company_id={company_id}, cache_type={cache_type}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to invalidate cache: {e}")
|
|
return False
|
|
|
|
async def toggle_user_cache(
|
|
self,
|
|
jwt_token: str,
|
|
enabled: bool
|
|
) -> bool:
|
|
"""
|
|
Toggle cache for current user.
|
|
|
|
Args:
|
|
jwt_token: JWT access token
|
|
enabled: True to enable cache, False to disable
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.post(
|
|
"/api/reports/cache/toggle-user",
|
|
json={"enabled": enabled},
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
response.raise_for_status()
|
|
logger.info(f"User cache toggled: enabled={enabled}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to toggle user cache: {e}")
|
|
return False
|
|
|
|
async def get_cache_stats(
|
|
self,
|
|
jwt_token: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get cache statistics including user-specific settings.
|
|
|
|
Args:
|
|
jwt_token: JWT access token
|
|
|
|
Returns:
|
|
Dict with cache stats including 'user_enabled' field
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.get(
|
|
"/api/reports/cache/stats",
|
|
headers=self._get_auth_headers(jwt_token)
|
|
)
|
|
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get cache stats: {e}")
|
|
return None
|
|
|
|
# =========================================================================
|
|
# HEALTH CHECK
|
|
# =========================================================================
|
|
|
|
async def health_check(self) -> bool:
|
|
"""
|
|
Check if backend is healthy and reachable.
|
|
|
|
Returns:
|
|
bool: True if backend is healthy
|
|
"""
|
|
try:
|
|
if not self.client or self.client.is_closed:
|
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
|
|
|
response = await self.client.get("/api/telegram/health")
|
|
return response.status_code == 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Backend health check failed: {e}")
|
|
return False
|
|
|
|
|
|
# Singleton instance for global use
|
|
_backend_client_instance: Optional[BackendAPIClient] = None
|
|
|
|
|
|
def get_backend_client() -> BackendAPIClient:
|
|
"""
|
|
Get or create the singleton BackendAPIClient instance.
|
|
|
|
Returns:
|
|
BackendAPIClient: Singleton instance
|
|
"""
|
|
global _backend_client_instance
|
|
if _backend_client_instance is None:
|
|
_backend_client_instance = BackendAPIClient()
|
|
return _backend_client_instance
|
|
|
|
|
|
# Export main classes and functions
|
|
__all__ = [
|
|
'BackendAPIClient',
|
|
'get_backend_client',
|
|
'BACKEND_URL'
|
|
]
|