feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,8 @@ async def get_dashboard_summary(
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_complete_summary(company, current_user.username, luna=luna, an=an, request=request)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_complete_summary(company, current_user.username, luna=luna, an=an, request=request, server_id=server_id)
|
||||
|
||||
# Convert Pydantic model to dict for JSON serialization
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
@@ -91,8 +92,9 @@ async def get_dashboard_trends(
|
||||
detail=f"Perioadă nevalidă: {period}. Valori permise: {', '.join(valid_periods)}"
|
||||
)
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
# Obține datele de trenduri
|
||||
result = await DashboardService.get_trends(int(company), period, luna=luna, an=an, request=request)
|
||||
result = await DashboardService.get_trends(int(company), period, luna=luna, an=an, request=request, server_id=server_id)
|
||||
|
||||
# Convert to dict if needed
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
@@ -120,6 +122,7 @@ async def get_dashboard_trends(
|
||||
|
||||
@router.get("/detailed-data")
|
||||
async def get_detailed_data(
|
||||
request: Request,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
data_type: str = Query(description="Tipul de date: clients, suppliers, treasury"),
|
||||
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
|
||||
@@ -137,6 +140,7 @@ async def get_detailed_data(
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
logger.info(f"[ROUTER] Calling DashboardService.get_detailed_data")
|
||||
result = await DashboardService.get_detailed_data(
|
||||
company=company,
|
||||
@@ -145,7 +149,8 @@ async def get_detailed_data(
|
||||
an=an,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search
|
||||
search=search,
|
||||
server_id=server_id
|
||||
)
|
||||
|
||||
logger.info(f"[ROUTER] Service returned: {len(result.get('data', []))} rows")
|
||||
@@ -157,13 +162,14 @@ async def get_detailed_data(
|
||||
|
||||
@router.get("/performance")
|
||||
async def get_performance(
|
||||
request: Request,
|
||||
company: int = Query(..., description="ID-ul firmei"),
|
||||
period: str = Query("7d", regex="^(7d|1m|3m|6m|ytd|12m)$", description="Perioada pentru analiză"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Returnează date performanță pentru perioada selectată
|
||||
|
||||
|
||||
- Necesită autentificare JWT
|
||||
- Returnează grafice încasări vs plăți pentru perioada selectată
|
||||
- Calculează indicatori: rata încasării, cash conversion, working capital
|
||||
@@ -172,8 +178,9 @@ async def get_performance(
|
||||
# Verifică dacă utilizatorul are acces la firma specificată
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_performance_data(company, period)
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_performance_data(company, period, server_id=server_id)
|
||||
|
||||
# Convert to Chart.js compatible format
|
||||
return {
|
||||
@@ -195,13 +202,14 @@ async def get_performance(
|
||||
|
||||
@router.get("/cashflow")
|
||||
async def get_cashflow(
|
||||
request: Request,
|
||||
company: int = Query(..., description="ID-ul firmei"),
|
||||
period: str = Query("7d", regex="^(7d|1m|3m|6m)$", description="Perioada pentru previziune"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Returnează previziune cash flow pentru perioada selectată
|
||||
|
||||
|
||||
- Necesită autentificare JWT
|
||||
- Analizează scadențele viitoare pentru calculul cash flow-ului
|
||||
- Identifică zilele critice cu deficit de cash
|
||||
@@ -210,8 +218,9 @@ async def get_cashflow(
|
||||
# Verifică dacă utilizatorul are acces la firma specificată
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_cashflow_forecast(company, period)
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_cashflow_forecast(company, period, server_id=server_id)
|
||||
|
||||
# Convert to Chart.js compatible format
|
||||
return {
|
||||
@@ -263,7 +272,8 @@ async def get_maturity_analysis(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_maturity_analysis(company, period, luna=luna, an=an, request=request)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_maturity_analysis(company, period, luna=luna, an=an, request=request, server_id=server_id)
|
||||
|
||||
# Convert to dict if needed
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
@@ -308,8 +318,9 @@ async def get_monthly_flows(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
# Apelăm serviciul cu request pentru cache metadata
|
||||
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an, request=request)
|
||||
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an, request=request, server_id=server_id)
|
||||
|
||||
# Convert to dict if needed
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
@@ -353,7 +364,8 @@ async def get_treasury_breakdown(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_treasury_breakdown(company, luna=luna, an=an, request=request)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_treasury_breakdown(company, luna=luna, an=an, request=request, server_id=server_id)
|
||||
|
||||
# Convert to dict if needed
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
@@ -398,7 +410,8 @@ async def get_net_balance_breakdown(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_net_balance_breakdown(company, luna=luna, an=an, request=request)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_net_balance_breakdown(company, luna=luna, an=an, request=request, server_id=server_id)
|
||||
|
||||
# Convert to dict if needed
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
@@ -424,6 +437,7 @@ async def get_net_balance_breakdown(
|
||||
|
||||
@router.get("/current-period")
|
||||
async def get_current_period(
|
||||
request: Request,
|
||||
company: int = Query(..., description="ID-ul firmei"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
@@ -439,7 +453,8 @@ async def get_current_period(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_current_period(company)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
result = await DashboardService.get_current_period(company, server_id=server_id)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
@@ -502,9 +517,11 @@ async def get_financial_indicators(
|
||||
resolved_luna: int
|
||||
resolved_an: int
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
if luna is None or an is None:
|
||||
try:
|
||||
current_period = await DashboardService.get_current_period(company)
|
||||
current_period = await DashboardService.get_current_period(company, server_id=server_id)
|
||||
resolved_luna = luna if luna is not None else current_period.get('luna', 12)
|
||||
resolved_an = an if an is not None else current_period.get('an', 2024)
|
||||
except Exception as e:
|
||||
@@ -519,13 +536,22 @@ async def get_financial_indicators(
|
||||
# Dacă include_sparklines este True, folosim metoda care include datele istorice
|
||||
if include_sparklines:
|
||||
response = await FinancialIndicatorsService.get_indicators_with_sparklines(
|
||||
company, resolved_luna, resolved_an, months=12, request=request
|
||||
company, resolved_luna, resolved_an, months=12, request=request, server_id=server_id
|
||||
)
|
||||
|
||||
# FIX: Cache poate returna dict în loc de obiect Pydantic
|
||||
# Extragem valorile pentru logging în mod compatibil cu ambele tipuri
|
||||
if isinstance(response, dict):
|
||||
zscore_val = response.get('altman_zscore', {}).get('zscore', {}).get('value')
|
||||
zscore_status = response.get('altman_zscore', {}).get('zscore', {}).get('status')
|
||||
else:
|
||||
zscore_val = response.altman_zscore.zscore.value
|
||||
zscore_status = response.altman_zscore.zscore.status
|
||||
|
||||
logger.info(
|
||||
f"Financial indicators with sparklines for company {company}, "
|
||||
f"luna={resolved_luna}, an={resolved_an}: "
|
||||
f"Z-Score={response.altman_zscore.zscore.value} ({response.altman_zscore.zscore.status}), "
|
||||
f"Z-Score={zscore_val} ({zscore_status}), "
|
||||
f"cache_hit={getattr(request.state, 'cache_hit', False)}, "
|
||||
f"response_time={getattr(request.state, 'response_time_ms', 0):.1f}ms"
|
||||
)
|
||||
@@ -545,28 +571,28 @@ async def get_financial_indicators(
|
||||
|
||||
# Apelăm serviciul pentru fiecare categorie de indicatori
|
||||
lichiditate_task = FinancialIndicatorsService.calculate_liquidity_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
eficienta_task = FinancialIndicatorsService.calculate_efficiency_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
risc_task = FinancialIndicatorsService.calculate_risk_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
cash_flow_task = FinancialIndicatorsService.calculate_cashflow_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
dinamica_task = FinancialIndicatorsService.calculate_dynamics_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
altman_task = FinancialIndicatorsService.calculate_altman_zscore(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
profitabilitate_task = FinancialIndicatorsService.calculate_profitability_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
solvabilitate_task = FinancialIndicatorsService.calculate_solvability_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
company, resolved_luna, resolved_an, server_id=server_id
|
||||
)
|
||||
|
||||
# Executăm toate calculele în paralel pentru performanță
|
||||
@@ -602,9 +628,17 @@ async def get_financial_indicators(
|
||||
solvabilitate=solvabilitate
|
||||
)
|
||||
|
||||
# FIX: Cache poate returna dict în loc de obiect Pydantic
|
||||
if isinstance(altman_zscore, dict):
|
||||
zscore_val = altman_zscore.get('zscore', {}).get('value')
|
||||
zscore_status = altman_zscore.get('zscore', {}).get('status')
|
||||
else:
|
||||
zscore_val = altman_zscore.zscore.value
|
||||
zscore_status = altman_zscore.zscore.status
|
||||
|
||||
logger.info(
|
||||
f"Financial indicators for company {company}, luna={resolved_luna}, an={resolved_an}: "
|
||||
f"Z-Score={altman_zscore.zscore.value} ({altman_zscore.zscore.status})"
|
||||
f"Z-Score={zscore_val} ({zscore_status})"
|
||||
)
|
||||
|
||||
# Add cache metadata if requested (for Telegram Bot / Dashboard)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
API Router pentru facturi
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
# import sys # Removed - no longer needed
|
||||
@@ -16,6 +16,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=InvoiceListResponse)
|
||||
async def get_invoices(
|
||||
request: Request,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
|
||||
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
|
||||
@@ -41,6 +42,8 @@ async def get_invoices(
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
filter_params = InvoiceFilter(
|
||||
company=company,
|
||||
partner_type=partner_type,
|
||||
@@ -55,7 +58,7 @@ async def get_invoices(
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
result = await InvoiceService.get_invoices(filter_params, current_user.username)
|
||||
result = await InvoiceService.get_invoices(filter_params, current_user.username, server_id=server_id)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
@@ -65,6 +68,7 @@ async def get_invoices(
|
||||
|
||||
@router.get("/summary", response_model=InvoiceSummary)
|
||||
async def get_invoices_summary(
|
||||
request: Request,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
@@ -75,7 +79,9 @@ async def get_invoices_summary(
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await InvoiceService.get_invoice_summary(company, partner_type, current_user.username)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
result = await InvoiceService.get_invoice_summary(company, partner_type, current_user.username, server_id=server_id)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -83,6 +89,7 @@ async def get_invoices_summary(
|
||||
|
||||
@router.get("/{invoice_number}")
|
||||
async def get_invoice_details(
|
||||
request: Request,
|
||||
invoice_number: str,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
@@ -92,8 +99,10 @@ async def get_invoice_details(
|
||||
# Verifică dacă utilizatorul are acces la firma specificată
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await InvoiceService.get_invoice_details(company, invoice_number, current_user.username)
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
result = await InvoiceService.get_invoice_details(company, invoice_number, current_user.username, server_id=server_id)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
@@ -103,6 +112,7 @@ async def get_invoice_details(
|
||||
|
||||
@router.get("/export/{format}")
|
||||
async def export_invoices(
|
||||
request: Request,
|
||||
format: str,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
|
||||
@@ -119,6 +129,8 @@ async def export_invoices(
|
||||
# Verifică dacă utilizatorul are acces la firma specificată
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None) # For future use
|
||||
|
||||
# Verifică formatul
|
||||
if format not in ["excel", "pdf", "csv"]:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
# import sys # Removed - no longer needed
|
||||
@@ -13,6 +13,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/bank-cash-register", response_model=RegisterListResponse)
|
||||
async def get_bank_cash_register(
|
||||
request: Request,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
register_type: Optional[str] = Query(None, description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate"),
|
||||
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
|
||||
@@ -37,6 +38,8 @@ async def get_bank_cash_register(
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
# Validează register_type dacă e specificat
|
||||
valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA']
|
||||
if register_type and register_type not in valid_types:
|
||||
@@ -74,7 +77,7 @@ async def get_bank_cash_register(
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username)
|
||||
result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username, server_id=server_id)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
@@ -85,6 +88,7 @@ async def get_bank_cash_register(
|
||||
|
||||
@router.get("/bank-cash-accounts", response_model=List[str])
|
||||
async def get_bank_cash_accounts(
|
||||
request: Request,
|
||||
company: str = Query(description="Codul firmei"),
|
||||
register_type: str = Query(description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
@@ -100,6 +104,8 @@ async def get_bank_cash_accounts(
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
# Validează register_type
|
||||
valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA']
|
||||
if register_type not in valid_types:
|
||||
@@ -108,7 +114,7 @@ async def get_bank_cash_accounts(
|
||||
detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}"
|
||||
)
|
||||
|
||||
result = await TreasuryService.get_bank_cash_accounts(int(company), register_type)
|
||||
result = await TreasuryService.get_bank_cash_accounts(int(company), register_type, server_id=server_id)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
API Router for Trial Balance (Balanță de Verificare)
|
||||
Refactored to use service layer with caching
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
# import sys # Removed - no longer needed
|
||||
@@ -20,6 +20,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=TrialBalanceResponse)
|
||||
async def get_trial_balance(
|
||||
request: Request,
|
||||
company: str = Query(description="Codul firmei (ID)"),
|
||||
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna (1-12), default: luna curentă"),
|
||||
an: Optional[int] = Query(None, ge=2000, le=2100, description="An, default: anul curent"),
|
||||
@@ -48,6 +49,8 @@ async def get_trial_balance(
|
||||
detail=f"Nu aveți acces la firma {company}"
|
||||
)
|
||||
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
# Setează valorile implicite pentru lună și an (luna și anul curent)
|
||||
current_date = date.today()
|
||||
if luna is None:
|
||||
@@ -69,7 +72,8 @@ async def get_trial_balance(
|
||||
sort_order=sort_order,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
username=current_user.username
|
||||
username=current_user.username,
|
||||
server_id=server_id
|
||||
)
|
||||
|
||||
return TrialBalanceResponse(
|
||||
|
||||
Reference in New Issue
Block a user