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:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View File

@@ -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)

View File

@@ -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"]:

View File

@@ -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:

View File

@@ -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(