feat: Add A-Z filter for clients/suppliers in Telegram bot

- 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>
This commit is contained in:
Claude Agent
2026-02-21 14:34:15 +00:00
parent 1366dbc11c
commit 30f55cf18b
28 changed files with 1671 additions and 520 deletions

View File

@@ -1,69 +0,0 @@
# Handover: Curățare Cod ROA2WEB
## Context
Am analizat arhitectura ROA2WEB cu 3 agenți de explorare și am identificat 4 acțiuni de curățare cu randament mare și risc minim.
**Plan detaliat**: `/home/claude/.claude/plans/immutable-chasing-flute.md`
## Prompt pentru /ralph:prd
```
Implementează curățarea codului ROA2WEB conform planului din /home/claude/.claude/plans/immutable-chasing-flute.md
Obiective:
1. Elimină store duplication - șterge src/modules/reports/stores/sharedStores.js și src/modules/data-entry/stores/sharedStores.js, mută instantierea în App.vue
2. Creează factory pentru API services - src/shared/services/createApiService.js și simplifică api.js din module
3. Fă dependențele OCR opționale - adaugă variabile în .env (OCR_ENABLE_PADDLEOCR, OCR_ENABLE_TESSERACT), implementează lazy loading în OCR service, mută deps în requirements opțional
4. Consolidează CSS design tokens - unifică variables.css, tokens.css, md3-tokens.css într-un singur design-tokens.css
Constrângeri:
- NU schimba arhitectura (layered architecture rămâne)
- NU modifica auth middleware, cache decorator, oracle pool
- Testează după fiecare modificare: app pornește, login funcționează, dark mode arată corect
Ordinea execuției:
1. [15 min] Store duplication
2. [30 min] API factory
3. [30 min] OCR dependencies opționale (lazy loading via .env)
4. [1 oră] CSS tokens
Impact așteptat: -150 linii cod duplicat, OCR deps opționale via .env, -2 fișiere CSS, startup mai rapid
```
## Fișiere Cheie de Referință
### De Șters
- `src/modules/reports/stores/sharedStores.js`
- `src/modules/data-entry/stores/sharedStores.js`
### De Creat
- `src/shared/services/createApiService.js`
- `src/assets/css/core/design-tokens.css` (consolidat)
### De Modificat
- `src/App.vue` - adaugă instantiere stores
- `src/modules/reports/services/api.js` - simplifică cu factory
- `src/modules/data-entry/services/api.js` - simplifică cu factory
- `backend/requirements.txt` - mută paddleocr/tesseract în secțiune opțională (comentate)
- `backend/.env` - adaugă `OCR_ENABLE_PADDLEOCR=false` și `OCR_ENABLE_TESSERACT=false`
- `backend/modules/data_entry/services/ocr_service.py` - implementează lazy loading pentru OCR engines
- `src/assets/css/main.css` - actualizează importuri CSS
## Verificare Finală
După implementare, verifică:
- [ ] `./start.sh prod` pornește fără erori
- [ ] Login funcționează
- [ ] Un raport se încarcă corect
- [ ] O chitanță se poate crea
- [ ] Dark mode arată corect pe toate paginile
- [ ] `pip install -r backend/requirements.txt` reușește
- [ ] Cu `OCR_ENABLE_PADDLEOCR=false` - app pornește fără PaddleOCR instalat
- [ ] Cu `OCR_ENABLE_PADDLEOCR=true` - OCR fallback funcționează (dacă e instalat)
## Note
- Arhitectura actuală (Layered) este potrivită pentru echipa de 1-2 developeri
- Nu este nevoie de Vertical Slice sau Feature-Sliced Design
- Acțiunile opționale (split receiptStore, simplificare cache) pot fi făcute ulterior

View File

@@ -2,6 +2,22 @@ from pydantic import BaseModel
from decimal import Decimal
from typing import List, Dict, Optional, Any
class BudgetDebtSubAccount(BaseModel):
"""Cont individual din cadrul unui grup de datorii buget"""
cont: str # ex: "4311"
label: str # ex: "4311 - CAS angajat"
precedent: Decimal # sold luna precedentă (pozitiv=datorie, negativ=creanță)
curent: Decimal # sold luna curentă (pozitiv=datorie, negativ=creanță)
class BudgetDebtGroup(BaseModel):
"""Grup de datorii la buget (TVA / BASS / CAM)"""
key: str # 'TVA', 'BASS', 'CAM'
label: str # 'TVA', 'BASS', 'CAM'
precedent: Decimal # total grup luna prec (semn ±)
curent: Decimal # total grup luna crt (semn ±)
sub_accounts: List[BudgetDebtSubAccount] = []
class TreasuryAccount(BaseModel):
"""Cont de trezorerie (bancă/casă)"""
cont: str # 5121, 5124, 5311, 5314
@@ -126,4 +142,8 @@ class DashboardSummary(BaseModel):
tva_plata_precedent: Decimal = Decimal('0')
tva_recuperat_precedent: Decimal = Decimal('0')
tva_plata_curent: Decimal = Decimal('0')
tva_recuperat_curent: Decimal = Decimal('0')
tva_recuperat_curent: Decimal = Decimal('0')
# DATORII LA BUGET - breakdown pe grupe (TVA / BASS / CAM) cu sub-conturi
budget_debt_breakdown: List[BudgetDebtGroup] = []
budget_debt_total_precedent: Decimal = Decimal('0') # suma tuturor grupurilor luna prec

View File

@@ -2,7 +2,7 @@
import os
from shared.database.oracle_pool import oracle_pool
from ..models.dashboard import DashboardSummary, TreasuryAccount, TrendData
from ..models.dashboard import DashboardSummary, TreasuryAccount, TrendData, BudgetDebtGroup, BudgetDebtSubAccount
from ..cache.decorators import cached
from decimal import Decimal
from typing import Dict, Any, List, Optional
@@ -12,6 +12,45 @@ import logging
logger = logging.getLogger(__name__)
# Grupuri de conturi pentru Datorii la Buget (3 grupe: TVA / BASS / CAM)
BUDGET_GROUPS = [
{
'key': 'TVA',
'label': 'TVA',
'accounts': ['4423', '4424'],
},
{
'key': 'BASS',
'label': 'BASS',
'accounts': ['431', '4311', '4313', '4315', '4316', '437', '444', '4411', '4418', '446', '447'],
},
{
'key': 'CAM',
'label': 'CAM',
'accounts': ['436'],
},
]
ACCOUNT_LABELS = {
'4311': '4311 - CAS angajat',
'4313': '4313 - CAS accidente muncă',
'4315': '4315 - CAS suplimentar',
'4316': '4316 - CASS angajat',
'431': '431 - CAS (cont total)',
'437': '437 - CASS (cont total)',
'444': '444 - Impozit salarii',
'4411': '4411 - Impozit pe profit',
'4418': '4418 - Impozit amânat',
'446': '446 - Alte impozite și taxe',
'447': '447 - Fonduri speciale',
'4423': '4423 - TVA de plată',
'4424': '4424 - TVA de recuperat',
'4426': '4426 - TVA deductibilă',
'4427': '4427 - TVA colectată',
'436': '436 - CAM',
}
class DashboardService:
"""Service pentru dashboard - date agregate"""
@@ -412,13 +451,17 @@ class DashboardService:
AND ((cont IN ('5121','5311') AND soldcred - solddeb != 0)
OR (cont IN ('5124','5314') AND soldvalcred - soldvaldeb != 0))
GROUP BY cont, nume, nume_val
ORDER BY cont, nume
ORDER BY
CASE WHEN cont IN ('5121','5311') THEN 0 ELSE 1 END,
cont,
UPPER(nume)
"""
cursor.execute(treasury_query, period_params)
treasury_rows = cursor.fetchall()
# Query 3: Solduri TVA din tabelul vbal (folosește același period_cte)
# Query 3: Solduri datorii buget din tabelul vbal (43xx + 44xx)
# Extins față de TVA-only pentru a include CAS, CASS, impozite, etc.
tva_query = f"""
{period_cte}
SELECT
@@ -426,34 +469,42 @@ class DashboardService:
precdeb,
preccred,
ruldeb,
rulcred
rulcred,
solddeb,
soldcred
FROM {schema}.vbal
WHERE an = (SELECT anul FROM luna_curenta)
AND luna = (SELECT luna FROM luna_curenta)
AND cont IN ('4423', '4424', '4426', '4427')
AND (cont LIKE '43%' OR cont LIKE '44%')
ORDER BY cont
"""
cursor.execute(tva_query, period_params)
tva_rows = cursor.fetchall()
# Procesare solduri TVA
tva_data = {
'4423': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')},
'4424': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')},
'4426': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')},
'4427': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')}
}
# Procesare solduri: dict generic pentru toate conturile 43xx/44xx
_zero = {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0'), 'solddeb': Decimal('0'), 'soldcred': Decimal('0')}
all_budget_data: Dict[str, Any] = {}
for row in tva_rows:
cont = row[0]
if cont in tva_data:
tva_data[cont]['precdeb'] = Decimal(str(row[1] or 0))
tva_data[cont]['preccred'] = Decimal(str(row[2] or 0))
tva_data[cont]['ruldeb'] = Decimal(str(row[3] or 0))
tva_data[cont]['rulcred'] = Decimal(str(row[4] or 0))
all_budget_data[cont] = {
'precdeb': Decimal(str(row[1] or 0)),
'preccred': Decimal(str(row[2] or 0)),
'ruldeb': Decimal(str(row[3] or 0)),
'rulcred': Decimal(str(row[4] or 0)),
'solddeb': Decimal(str(row[5] or 0)),
'soldcred': Decimal(str(row[6] or 0)),
}
# Backward compat: tva_data dict cu doar conturile TVA clasice
tva_data = {
k: all_budget_data.get(k, dict(_zero))
for k in ['4423', '4424', '4426', '4427']
}
# Calcul TVA Luna Precedentă - FIE de plată (4423) FIE de recuperat (4424)
# Primary: folosim soldurile conturilor de regularizare TVA (luna anterioară închisă)
sold_4423 = tva_data['4423']['preccred'] - tva_data['4423']['precdeb']
sold_4424 = tva_data['4424']['precdeb'] - tva_data['4424']['preccred']
@@ -464,18 +515,97 @@ class DashboardService:
tva_recuperat_precedent = sold_4424
tva_plata_precedent = Decimal('0')
else:
tva_plata_precedent = Decimal('0')
tva_recuperat_precedent = Decimal('0')
# Fallback: când luna anterioară nu e închisă (4423/4424 = 0),
# calculăm soldul lunar net al conturilor 4427/4426
sold_4427_prec = tva_data['4427']['preccred'] - tva_data['4427']['precdeb']
sold_4426_prec = tva_data['4426']['precdeb'] - tva_data['4426']['preccred']
diferenta_prec = sold_4427_prec - sold_4426_prec
if diferenta_prec > 0:
tva_plata_precedent = diferenta_prec
tva_recuperat_precedent = Decimal('0')
else:
tva_recuperat_precedent = -diferenta_prec
tva_plata_precedent = Decimal('0')
# Calcul TVA Luna Curentă - FIE de plată FIE de recuperat
diferenta_curent = tva_data['4427']['rulcred'] - tva_data['4426']['ruldeb']
# Calcul TVA Luna Curentă - rulaj lunar 4423/4424 (nu sold cumulat)
sold_4423_cur = tva_data['4423']['rulcred'] - tva_data['4423']['ruldeb']
sold_4424_cur = tva_data['4424']['ruldeb'] - tva_data['4424']['rulcred']
if diferenta_curent > 0:
tva_plata_curent = diferenta_curent
if sold_4423_cur > 0:
tva_plata_curent = sold_4423_cur
tva_recuperat_curent = Decimal('0')
else:
tva_recuperat_curent = -diferenta_curent
elif sold_4424_cur > 0:
tva_recuperat_curent = sold_4424_cur
tva_plata_curent = Decimal('0')
else:
# Fallback: rulaj lunar net 4427/4426 (ruldeb/rulcred = doar luna curentă)
sold_4427_cur = tva_data['4427']['rulcred'] - tva_data['4427']['ruldeb']
sold_4426_cur = tva_data['4426']['ruldeb'] - tva_data['4426']['rulcred']
diferenta_curent = sold_4427_cur - sold_4426_cur
if diferenta_curent > 0:
tva_plata_curent = diferenta_curent
tva_recuperat_curent = Decimal('0')
else:
tva_recuperat_curent = -diferenta_curent
tva_plata_curent = Decimal('0')
# Calcul Datorii la Buget - 3 grupe (TVA / BASS / CAM) cu sub-conturi
budget_debt_breakdown = []
for group_def in BUDGET_GROUPS:
sub_accounts = []
group_prec = Decimal('0')
group_cur = Decimal('0')
if group_def['key'] == 'TVA':
# TVA special: valorile deja calculate (semn ±)
tva_prec = tva_plata_precedent - tva_recuperat_precedent
tva_cur = tva_plata_curent - tva_recuperat_curent
group_prec = tva_prec
group_cur = tva_cur
# Sub-conturi TVA (doar cele cu sold non-zero)
for cont in ['4423', '4424', '4426', '4427']:
if cont in all_budget_data:
d = all_budget_data[cont]
if cont == '4424': # creanță → semn negativ
val_prec = -(d['precdeb'] - d['preccred'])
val_cur = -(d['ruldeb'] - d['rulcred'])
else: # 4423, 4426, 4427: rulcred - ruldeb (lunar, nu cumulat)
val_prec = d['preccred'] - d['precdeb']
val_cur = d['rulcred'] - d['ruldeb']
if val_prec != 0 or val_cur != 0:
sub_accounts.append(BudgetDebtSubAccount(
cont=cont,
label=ACCOUNT_LABELS[cont],
precedent=val_prec,
curent=val_cur,
))
else:
# BASS și CAM: matching exact pe codul contului
for account_code in group_def['accounts']:
if account_code in all_budget_data:
d = all_budget_data[account_code]
val_prec = d['preccred'] - d['precdeb'] # pozitiv = datorie
val_cur = d['rulcred'] - d['ruldeb'] # rulaj luna curentă (consistent cu TVA)
if val_prec != 0 or val_cur != 0:
sub_accounts.append(BudgetDebtSubAccount(
cont=account_code,
label=ACCOUNT_LABELS.get(account_code, account_code),
precedent=val_prec,
curent=val_cur,
))
group_prec += val_prec
group_cur += val_cur
if group_prec != 0 or group_cur != 0 or sub_accounts:
budget_debt_breakdown.append(BudgetDebtGroup(
key=group_def['key'],
label=group_def['label'],
precedent=group_prec,
curent=group_cur,
sub_accounts=sub_accounts,
))
budget_debt_total_precedent = sum(g.precedent for g in budget_debt_breakdown)
# Procesare trezorerie
treasury_accounts = []
@@ -562,7 +692,11 @@ class DashboardService:
tva_plata_precedent=tva_plata_precedent,
tva_recuperat_precedent=tva_recuperat_precedent,
tva_plata_curent=tva_plata_curent,
tva_recuperat_curent=tva_recuperat_curent
tva_recuperat_curent=tva_recuperat_curent,
# Datorii la buget pe grupe cu sub-conturi
budget_debt_breakdown=budget_debt_breakdown,
budget_debt_total_precedent=budget_debt_total_precedent,
)
@staticmethod
@@ -1691,7 +1825,9 @@ class DashboardService:
AND vb.luna = lc.luna
AND vb.cont IN ('5311', '5314', '5328', '5121', '5124')
AND (vb.solddeb - vb.soldcred) <> 0
ORDER BY tip, vb.cont
ORDER BY tip,
CASE WHEN vb.cont IN ('5121','5311') THEN 0 ELSE 1 END,
UPPER(vb.nume)
"""
cursor.execute(treasury_query, period_params)

View File

@@ -105,7 +105,8 @@ class BackendAPIClient:
async def verify_user(
self,
oracle_username: str,
linking_code: str
linking_code: str,
server_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Verify user exists in Oracle and get JWT token.
@@ -139,7 +140,8 @@ class BackendAPIClient:
"/api/telegram/auth/verify-user",
json={
"linking_code": linking_code,
"oracle_username": oracle_username
"oracle_username": oracle_username,
"server_id": server_id
}
)
@@ -185,12 +187,13 @@ class BackendAPIClient:
logger.error(f"Failed to refresh token: {e}")
return None
async def verify_email(self, email: str) -> dict:
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)
@@ -204,7 +207,7 @@ class BackendAPIClient:
response = await self.client.post(
"/api/telegram/auth/verify-email",
json={"email": email}
json={"email": email, "server_id": server_id}
)
response.raise_for_status()
return response.json()
@@ -229,7 +232,8 @@ class BackendAPIClient:
email: str,
password: str,
telegram_user_id: int,
session_token: str
session_token: str,
server_id: Optional[str] = None
) -> dict:
"""
Login via email + password with session token
@@ -239,6 +243,7 @@ class BackendAPIClient:
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
@@ -256,7 +261,8 @@ class BackendAPIClient:
"email": email,
"password": password,
"telegram_user_id": telegram_user_id,
"session_token": session_token
"session_token": session_token,
"server_id": server_id
},
timeout=30.0 # 30 seconds timeout
)
@@ -298,6 +304,52 @@ class BackendAPIClient:
"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.

View File

@@ -101,12 +101,13 @@ def is_valid_email_format(email: str) -> bool:
return bool(re.match(pattern, email))
async def verify_email_in_oracle(email: str) -> Optional[str]:
async def verify_email_in_oracle(email: str, server_id: Optional[str] = None) -> Optional[str]:
"""
Verify email exists in Oracle UTILIZATORI table via backend API
Args:
email: Email address to check
server_id: Optional Oracle server ID (for multi-server mode)
Returns:
Oracle username if found and active, None otherwise
@@ -118,8 +119,8 @@ async def verify_email_in_oracle(email: str) -> Optional[str]:
backend_client = get_backend_client()
# Call backend API to verify email
response = await backend_client.verify_email(email)
# Call backend API to verify email (on specific server if provided)
response = await backend_client.verify_email(email, server_id=server_id)
if response.get('success'):
username = response.get('username')

View File

@@ -78,7 +78,8 @@ async def link_telegram_account(
return None
oracle_username = code_data.get('oracle_username')
logger.info(f"Auth code valid for Oracle user: {oracle_username}")
server_id = code_data.get('server_id') # Extract server_id from the stored code
logger.info(f"Auth code valid for Oracle user: {oracle_username} (server_id={server_id})")
# Step 2: Create/update Telegram user record (basic info)
user_created = await create_or_update_user(
@@ -97,7 +98,8 @@ async def link_telegram_account(
async with backend_client:
user_data = await backend_client.verify_user(
oracle_username=oracle_username,
linking_code=auth_code
linking_code=auth_code,
server_id=server_id
)
if not user_data or not user_data.get('success'):

View File

@@ -14,6 +14,8 @@ import logging
from datetime import datetime
import asyncio
from typing import Optional
from backend.modules.telegram.auth.email_auth import (
is_valid_email_format,
verify_email_in_oracle,
@@ -22,6 +24,7 @@ from backend.modules.telegram.auth.email_auth import (
check_rate_limit,
clear_rate_limit
)
from shared.auth.email_server_cache import email_server_cache
from backend.modules.telegram.utils.email_service import get_email_service
from backend.modules.telegram.db.operations import (
create_email_auth_code,
@@ -39,7 +42,7 @@ from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
# Conversation states
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3)
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD, AWAITING_SERVER_SELECTION = range(4)
# Constants
MAX_CODE_ATTEMPTS = 3
@@ -261,57 +264,25 @@ async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_
# STATE: AWAITING_EMAIL
# ============================================================================
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
async def _send_email_code(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
email: str,
server_id: Optional[str],
user_id: int
) -> int:
"""
Generate and send email verification code on the specified server.
Returns AWAITING_CODE on success or ConversationHandler.END on failure.
"""
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
# Show error in main message
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Check for existing pending code
existing_code = await get_pending_email_code(user_id)
if existing_code:
# Delete old pending code
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare email...",
reply_markup=None
)
try:
# Verifică email în Oracle
username = await verify_email_in_oracle(email)
# Verify email in Oracle (on specific server if known)
username = await verify_email_in_oracle(email, server_id=server_id)
# IMPORTANT: Generic response to prevent email enumeration
# We always say "code sent" even if email doesn't exist
if username:
# Email exists - generate and send code
code = generate_email_code()
# Save code in database
code_saved = await create_email_auth_code(
code=code,
email=email,
@@ -323,27 +294,26 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not code_saved:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Send email (async with retry)
email_service = get_email_service()
email_sent = await email_service.send_auth_code(email, code, username)
if not email_sent:
logger.error(f"Failed to send email to {email}")
# Don't reveal this to user - they'll timeout naturally
if email_sent:
logger.info(f"[EMAIL-AUTH] ✅ Code sent for {email[:3]}***@*** (user {user_id}, server={server_id})")
else:
logger.error(f"[EMAIL-AUTH] ❌ Failed to send code (user {user_id}, server={server_id})")
# Wait 1 second for better UX (looks like verification happened)
# Wait 1 second for UX (looks like verification happened)
await asyncio.sleep(1)
# ALWAYS show this message (prevent enumeration)
# EDIT same message with success + buttons
# ALWAYS show success (prevent enumeration)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
@@ -351,22 +321,135 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
])
)
# Save email in context for resend functionality
context.user_data['pending_email'] = email
context.user_data['pending_username'] = username
return AWAITING_CODE
except Exception as e:
logger.error(f"Error in receive_email: {e}", exc_info=True)
logger.error(f"Error sending email code: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text="Eroare internă.\n\nIncearcă din nou mai târziu."
)
return ConversationHandler.END
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
chat_id = update.effective_chat.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
await edit_login_message(
context=context,
chat_id=chat_id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Clean up old pending codes
existing_code = await get_pending_email_code(user_id)
if existing_code:
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# Show loading
await edit_login_message(
context=context,
chat_id=chat_id,
text="Verificare email...",
reply_markup=None
)
# Check server cache for multi-server routing
try:
await email_server_cache.refresh_if_needed()
servers = email_server_cache.get_servers_for_email(email)
except Exception as e:
logger.warning(f"Could not check email server cache: {e}")
servers = []
if len(servers) > 1:
# Multiple servers — ask user to select before sending code
context.user_data['pending_email'] = email
try:
from backend.config import settings
keyboard = []
for srv_id in servers:
srv = settings.get_oracle_server(srv_id)
srv_name = srv.name if srv else srv_id
keyboard.append([InlineKeyboardButton(srv_name, callback_data=f"select_server:{srv_id}")])
except Exception:
# Fallback: use server IDs as labels
keyboard = [
[InlineKeyboardButton(srv_id, callback_data=f"select_server:{srv_id}")]
for srv_id in servers
]
keyboard.append([InlineKeyboardButton("Anulează", callback_data="cancel")])
await edit_login_message(
context=context,
chat_id=chat_id,
text="Email identificat pe mai multe servere.\n\nSelectează serverul pentru autentificare:",
reply_markup=InlineKeyboardMarkup(keyboard)
)
return AWAITING_SERVER_SELECTION
# Single server or no cache hit — proceed directly
server_id = servers[0] if servers else None
context.user_data['server_id'] = server_id
return await _send_email_code(context, chat_id, email, server_id, user_id)
# ============================================================================
# STATE: AWAITING_SERVER_SELECTION
# ============================================================================
async def handle_server_selected(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback pentru selectarea serverului Oracle (mod multi-server)"""
query = update.callback_query
user_id = update.effective_user.id
chat_id = update.effective_chat.id
await query.answer()
# Extract server_id from callback data: "select_server:<id>"
server_id = query.data.split(":", 1)[1]
email = context.user_data.get('pending_email')
if not email:
await edit_login_message(
context=context,
chat_id=chat_id,
text="Sesiune expirată\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Save selected server to context
context.user_data['server_id'] = server_id
logger.info(f"[EMAIL-AUTH] User {user_id} selected server '{server_id}' for {email[:3]}***")
# Clean up old pending codes then send code on selected server
await delete_user_email_codes(user_id)
return await _send_email_code(context, chat_id, email, server_id, user_id)
# ============================================================================
# STATE: AWAITING_CODE
# ============================================================================
@@ -593,12 +676,14 @@ async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
# Call backend endpoint pentru verificare parolă + JWT
backend_client = get_backend_client()
server_id = context.user_data.get('server_id')
response = await backend_client.login_with_email(
email=email,
password=password,
telegram_user_id=user_id,
session_token=session_token
session_token=session_token,
server_id=server_id
)
if not response.get('success'):
@@ -749,6 +834,9 @@ email_login_handler = ConversationHandler(
AWAITING_EMAIL: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
],
AWAITING_SERVER_SELECTION: [
CallbackQueryHandler(handle_server_selected, pattern='^select_server:')
],
AWAITING_CODE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
CallbackQueryHandler(resend_code_callback, pattern='^resend:')

View File

@@ -20,7 +20,7 @@ from backend.modules.telegram.auth.linking import (
get_user_companies
)
from backend.modules.telegram.agent.session import get_session_manager
from backend.modules.telegram.db.operations import update_user_last_active
from backend.modules.telegram.db.operations import update_user_last_active, link_user_to_oracle
from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
@@ -1279,9 +1279,82 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
try:
telegram_user = update.effective_user
telegram_user_id = telegram_user.id
text = update.message.text.strip().upper()
text = update.message.text.strip()
logger.info(f"Text message from user {telegram_user_id}: {text}")
logger.info(f"Text message from user {telegram_user_id}")
# Check if user is awaiting password for server switch
pending_server_id = context.user_data.get('pending_switch_server_id')
if pending_server_id:
# Șterge IMEDIAT mesajul cu parola (securitate)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete password message: {e}")
oracle_password = text
jwt_token = context.user_data.pop('pending_switch_jwt_token', None)
username = context.user_data.pop('pending_switch_username', None)
context.user_data.pop('pending_switch_server_id', None)
if not jwt_token or not username:
await update.effective_chat.send_message("Sesiune expirată. Încearcă din nou.")
return
await update.effective_chat.send_message("Se verifică parola și se schimbă serverul...")
client = get_backend_client()
async with client:
result = await client.switch_server(
jwt_token=jwt_token,
oracle_username=username,
new_server_id=pending_server_id,
oracle_password=oracle_password
)
if not result.get('success'):
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
await update.effective_chat.send_message(
f"{result.get('message', 'Eroare la schimbarea serverului')}\n\nReîncearcă cu /menu → Schimbă server.",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("« Meniu", callback_data="action:menu")]])
)
return
# Salvează noul JWT în SQLite
from datetime import datetime, timedelta
token_expires_at = datetime.now() + timedelta(minutes=30)
await link_user_to_oracle(
telegram_user_id=telegram_user_id,
oracle_username=result.get('username', username),
jwt_token=result['access_token'],
jwt_refresh_token=result['refresh_token'],
token_expires_at=token_expires_at
)
# Curăță compania din sesiune — aparținea serverului vechi
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
session.clear_active_company()
await session_manager.save_session(telegram_user_id)
try:
from backend.config import settings
srv = settings.get_oracle_server(pending_server_id)
srv_display = srv.name if srv else pending_server_id
except Exception:
srv_display = pending_server_id
await update.effective_chat.send_message(f"✅ Server schimbat: **{srv_display}**\nSelectează firma...", parse_mode=ParseMode.MARKDOWN)
await _handle_selectcompany_view(
query_or_update=update,
telegram_user_id=telegram_user_id,
jwt_token=result['access_token'],
is_callback=False,
page=0,
search_term=""
)
return
# Check if user is already linked
is_linked = await check_user_linked(telegram_user_id)
@@ -1291,6 +1364,8 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
# (could add natural language processing here in the future)
return
text = text.upper() # Only uppercase for linking code check
# User is NOT linked - check if text looks like a linking code
# Linking codes are exactly 8 alphanumeric characters
if len(text) == 8 and text.isalnum():
@@ -1740,6 +1815,43 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str)
search_term=""
)
elif action == "switch_server":
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from shared.auth.email_server_cache import email_server_cache
from backend.modules.telegram.bot.menus import pad_message_for_wide_buttons
username = auth_data['username']
try:
servers = await email_server_cache.get_servers_for_username(username)
except Exception as e:
logger.error(f"Could not get servers for {username}: {e}")
await query.answer("Eroare la obținerea serverelor.", show_alert=True)
return
if len(servers) <= 1:
await query.answer("Ești pe singurul server disponibil.", show_alert=True)
return
# Build server selection keyboard
try:
from backend.config import settings
keyboard_rows = []
for srv_id in servers:
srv = settings.get_oracle_server(srv_id)
srv_name = srv.name if srv else srv_id
keyboard_rows.append([InlineKeyboardButton(srv_name, callback_data=f"switch_server_confirm:{srv_id}")])
except Exception:
keyboard_rows = [[InlineKeyboardButton(s, callback_data=f"switch_server_confirm:{s}")] for s in servers]
keyboard_rows.append([InlineKeyboardButton("« Înapoi", callback_data="action:menu")])
await query.edit_message_text(
pad_message_for_wide_buttons(f"Selectează serverul Oracle:\n\nUtilizator: {username}"),
reply_markup=InlineKeyboardMarkup(keyboard_rows),
parse_mode=ParseMode.MARKDOWN
)
async def handle_action_callback(query, telegram_user_id: int, callback_data: str):
"""
@@ -2099,6 +2211,241 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
parse_mode=ParseMode.MARKDOWN
)
elif callback_data == "select_company_alpha_menu":
# Show A-Z letter filter keyboard
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard
await query.edit_message_text(
"**Selectează litera**\n\nAlege prima literă a firmei:",
reply_markup=create_alpha_filter_keyboard(),
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("select_company_alpha:"):
# Filter companies by starting letter and show page 0
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
client = get_backend_client()
async with client:
all_companies = await client.get_user_companies(jwt_token=jwt_token)
if letter == "ALL":
filtered = all_companies
else:
filtered = [
c for c in all_companies
if c.get('name', c.get('nume_firma', '')).upper().startswith(letter)
]
if not filtered:
await query.answer(f"Nicio firmă cu litera {letter}.", show_alert=True)
return
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
keyboard = create_company_selection_keyboard_paginated(
filtered, page=0,
back_callback="select_company_alpha_menu",
page_callback_prefix="select_company_alpha_page",
page_callback_suffix=f":{letter}"
)
label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele"
await query.edit_message_text(
f"**{label}** ({len(filtered)}):",
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("select_company_alpha_page:"):
# Paginate within an alpha-filtered company list
# Callback format: select_company_alpha_page:PAGE:LETTER
parts = callback_data.split(":")
page = int(parts[1])
letter = parts[2] # "A""Z" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
client = get_backend_client()
async with client:
all_companies = await client.get_user_companies(jwt_token=jwt_token)
if letter == "ALL":
filtered = all_companies
else:
filtered = [
c for c in all_companies
if c.get('name', c.get('nume_firma', '')).upper().startswith(letter)
]
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
keyboard = create_company_selection_keyboard_paginated(
filtered, page=page,
back_callback="select_company_alpha_menu",
page_callback_prefix="select_company_alpha_page",
page_callback_suffix=f":{letter}"
)
label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele"
await query.edit_message_text(
f"**{label}** ({len(filtered)}):",
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
elif callback_data == "clients_alpha_menu":
# Show A-Z letter filter keyboard for clients
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner
await query.edit_message_text(
"**Selecteaza litera**\n\nAlege prima litera a clientului:",
reply_markup=create_alpha_filter_keyboard_partner("clients"),
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("clients_alpha:"):
# Filter clients by starting letter and show page 0
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_clients_with_maturity
clients_data = await get_clients_with_maturity(company['id'], jwt_token)
all_clients = clients_data['clients']
if letter == "ALL":
filtered = all_clients
else:
filtered = [
c for c in all_clients
if c.get('name', '').upper().startswith(letter)
]
if not filtered:
await query.answer(f"Niciun client cu litera {letter}.", show_alert=True)
return
from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_clients_balance_response
content = format_clients_balance_response(filtered, clients_data['maturity'])
label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_client_list_keyboard(filtered, page=0, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data.startswith("clients_alpha_page:"):
# Paginate within an alpha-filtered client list
# Callback format: clients_alpha_page:PAGE:LETTER
parts = callback_data.split(":")
page = int(parts[1])
letter = parts[2] # "A""Z" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_clients_with_maturity
clients_data = await get_clients_with_maturity(company['id'], jwt_token)
all_clients = clients_data['clients']
if letter == "ALL":
filtered = all_clients
else:
filtered = [
c for c in all_clients
if c.get('name', '').upper().startswith(letter)
]
from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_clients_balance_response
content = format_clients_balance_response(filtered, clients_data['maturity'])
label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_client_list_keyboard(filtered, page=page, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data == "suppliers_alpha_menu":
# Show A-Z letter filter keyboard for suppliers
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner
await query.edit_message_text(
"**Selecteaza litera**\n\nAlege prima litera a furnizorului:",
reply_markup=create_alpha_filter_keyboard_partner("suppliers"),
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("suppliers_alpha:"):
# Filter suppliers by starting letter and show page 0
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity
suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token)
all_suppliers = suppliers_data['suppliers']
if letter == "ALL":
filtered = all_suppliers
else:
filtered = [
s for s in all_suppliers
if s.get('name', '').upper().startswith(letter)
]
if not filtered:
await query.answer(f"Niciun furnizor cu litera {letter}.", show_alert=True)
return
from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_suppliers_balance_response
content = format_suppliers_balance_response(filtered, suppliers_data['maturity'])
label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_supplier_list_keyboard(filtered, page=0, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data.startswith("suppliers_alpha_page:"):
# Paginate within an alpha-filtered supplier list
# Callback format: suppliers_alpha_page:PAGE:LETTER
parts = callback_data.split(":")
page = int(parts[1])
letter = parts[2] # "A""Z" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity
suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token)
all_suppliers = suppliers_data['suppliers']
if letter == "ALL":
filtered = all_suppliers
else:
filtered = [
s for s in all_suppliers
if s.get('name', '').upper().startswith(letter)
]
from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_suppliers_balance_response
content = format_suppliers_balance_response(filtered, suppliers_data['maturity'])
label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_supplier_list_keyboard(filtered, page=page, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data.startswith("select_company:"):
# Handle company selection
company_id = int(callback_data.split(":")[1])
@@ -2151,6 +2498,44 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"Companie negasita sau nu ai acces la ea."
)
# ========== SWITCH SERVER CALLBACKS ==========
elif callback_data.startswith("switch_server_confirm:"):
new_server_id = callback_data.split(":", 1)[1]
telegram_user_id = update.effective_user.id
auth_data = await get_user_auth_data(telegram_user_id)
if not auth_data:
await query.edit_message_text("Sesiune expirată. Re-autentifică-te cu /login")
return
# Stochează serverul țintă și cere parola — servere diferite pot avea parole diferite
context.user_data['pending_switch_server_id'] = new_server_id
context.user_data['pending_switch_jwt_token'] = auth_data['jwt_token']
context.user_data['pending_switch_username'] = auth_data['username']
try:
from backend.config import settings
srv = settings.get_oracle_server(new_server_id)
srv_display = srv.name if srv else new_server_id
except Exception:
srv_display = new_server_id
from telegram import ForceReply
await query.edit_message_text(
f"🔐 **Schimbare server: {srv_display}**\n\n"
f"Introdu parola Oracle pentru acest server:\n"
f"_(Mesajul cu parola va fi șters automat)_",
parse_mode=ParseMode.MARKDOWN
)
# Trimite un mesaj separat cu ForceReply pentru a forța input-ul
await context.bot.send_message(
chat_id=update.effective_chat.id,
text="Parolă:",
reply_markup=ForceReply(selective=True, input_field_placeholder="Parola Oracle...")
)
return
# ========== LOGOUT CALLBACKS ==========
elif callback_data == "logout_confirm":
@@ -2741,6 +3126,40 @@ async def _handle_selectcompany_view(
)
return
# Auto-selectează dacă există exact o singură firmă
if len(companies) == 1:
selected = companies[0]
company_id = selected.get('id_firma', selected.get('id'))
company_name = selected.get('name', selected.get('nume_firma', 'N/A'))
company_cui = selected.get('fiscal_code', selected.get('cui'))
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
session.set_active_company(
company_id=company_id,
company_name=company_name,
company_cui=company_cui
)
await session_manager.save_session(telegram_user_id)
from backend.modules.telegram.bot.menus import create_main_menu, get_menu_message
keyboard = create_main_menu(company_name=company_name, company_cui=company_cui)
menu_text = f"✅ Firmă selectată automat: **{company_name}**\n\n" + get_menu_message(company_name, company_cui)
if is_callback:
await query_or_update.edit_message_text(
menu_text,
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
else:
await query_or_update.message.reply_text(
menu_text,
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
return
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
keyboard = create_company_selection_keyboard_paginated(companies, page=page)

View File

@@ -169,7 +169,10 @@ def create_company_selection_keyboard(
def create_company_selection_keyboard_paginated(
companies: List[Dict[str, Any]],
page: int = 0,
per_page: int = 10
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.
@@ -180,7 +183,10 @@ def create_company_selection_keyboard_paginated(
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: 10)
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
@@ -221,8 +227,9 @@ def create_company_selection_keyboard_paginated(
# Previous button
if page > 0:
prev_cb = f"{page_callback_prefix}:{page-1}{page_callback_suffix}"
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"select_company_page:{page-1}")
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
@@ -232,20 +239,76 @@ def create_company_selection_keyboard_paginated(
# 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=f"select_company_page:{page+1}")
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Back to menu button
# A-Z filter + back button
keyboard.append([
InlineKeyboardButton("< Inapoi la Meniu", callback_data="action:menu")
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 AZ 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 AZ 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.

View File

@@ -260,7 +260,13 @@ def create_main_menu(
InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache")
])
# Row 6: Help/Logout buttons (authenticated) or Login button (non-authenticated)
# Row 6: Switch Server button (authenticated only)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Schimba Server", callback_data="menu:switch_server"),
])
# Row 7: Help/Logout buttons (authenticated) or Login button (non-authenticated)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Help", callback_data="action:help"),
@@ -334,7 +340,7 @@ def create_action_buttons(current_view: str, show_export: bool = True, show_back
return InlineKeyboardMarkup(keyboard)
def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
def create_client_list_keyboard(clients: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
"""
Create client list keyboard (Level 2) with client buttons and pagination.
@@ -344,6 +350,7 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
clients: List of client dicts with keys: id, name, balance
max_items: Maximum number of clients per page (default: 10)
page: Current page number (0-indexed)
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
Returns:
InlineKeyboardMarkup with client list buttons and pagination
@@ -387,10 +394,18 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
if total_pages > 1:
nav_buttons = []
# Choose pagination callback based on whether letter filter is active
if letter:
prev_cb = f"clients_alpha_page:{page-1}:{letter}"
next_cb = f"clients_alpha_page:{page+1}:{letter}"
else:
prev_cb = f"clients_page:{page-1}"
next_cb = f"clients_page:{page+1}"
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"clients_page:{page-1}")
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
@@ -401,20 +416,26 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Următor >", callback_data=f"clients_page:{page+1}")
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Navigation row: Back button only
# Filtrare A-Z button
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
InlineKeyboardButton("Filtrare A-Z", callback_data="clients_alpha_menu")
])
# Back button: to A-Z menu if filtering, otherwise to main menu
back_callback = "clients_alpha_menu" if letter else "action:menu"
keyboard.append([
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
"""
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
@@ -424,6 +445,7 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
suppliers: List of supplier dicts with keys: id, name, balance
max_items: Maximum number of suppliers per page (default: 10)
page: Current page number (0-indexed)
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
Returns:
InlineKeyboardMarkup with supplier list buttons and pagination
@@ -467,10 +489,18 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
if total_pages > 1:
nav_buttons = []
# Choose pagination callback based on whether letter filter is active
if letter:
prev_cb = f"suppliers_alpha_page:{page-1}:{letter}"
next_cb = f"suppliers_alpha_page:{page+1}:{letter}"
else:
prev_cb = f"suppliers_page:{page-1}"
next_cb = f"suppliers_page:{page+1}"
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"suppliers_page:{page-1}")
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
@@ -481,14 +511,20 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Următor >", callback_data=f"suppliers_page:{page+1}")
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Navigation row: Back button only
# Filtrare A-Z button
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
InlineKeyboardButton("Filtrare A-Z", callback_data="suppliers_alpha_menu")
])
# Back button: to A-Z menu if filtering, otherwise to main menu
back_callback = "suppliers_alpha_menu" if letter else "action:menu"
keyboard.append([
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)

View File

@@ -148,6 +148,14 @@ async def init_database() -> None:
ON email_auth_codes(expires_at)
""")
# Migration: add server_id column to telegram_auth_codes if missing
try:
await db.execute("ALTER TABLE telegram_auth_codes ADD COLUMN server_id TEXT")
await db.commit()
logger.info("Migration: added server_id column to telegram_auth_codes")
except Exception:
pass # Column already exists
await db.commit()
logger.info("Database initialized successfully")

View File

@@ -288,7 +288,8 @@ async def create_auth_code(
code: str,
telegram_user_id: int,
oracle_username: str,
expires_in_minutes: int = 5
expires_in_minutes: int = 5,
server_id: Optional[str] = None
) -> bool:
"""
Create a new authentication code for linking.
@@ -298,6 +299,7 @@ async def create_auth_code(
telegram_user_id: Telegram user ID
oracle_username: Oracle username to link
expires_in_minutes: Code expiration time in minutes (default: 5)
server_id: Oracle server ID (for multi-server mode)
Returns:
bool: True if successful
@@ -310,13 +312,13 @@ async def create_auth_code(
db.row_factory = aiosqlite.Row
await db.execute("""
INSERT INTO telegram_auth_codes (
code, telegram_user_id, oracle_username, expires_at
code, telegram_user_id, oracle_username, expires_at, server_id
)
VALUES (?, ?, ?, ?)
""", (code, telegram_user_id, oracle_username, expires_at))
VALUES (?, ?, ?, ?, ?)
""", (code, telegram_user_id, oracle_username, expires_at, server_id))
await db.commit()
logger.info(f"Auth code created for user {telegram_user_id}")
logger.info(f"Auth code created for user {telegram_user_id} (server_id={server_id})")
return True
except Exception as e:

View File

@@ -2,7 +2,7 @@
API Router pentru Telegram Bot Integration
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from typing import List, Optional, Dict, Any
# import sys # Removed - no longer needed
import os
@@ -57,6 +57,7 @@ class VerifyUserRequest(BaseModel):
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
class VerifyUserResponse(BaseModel):
@@ -100,6 +101,7 @@ class ExportReportResponse(BaseModel):
class VerifyEmailRequest(BaseModel):
"""Request pentru verificarea email-ului în Oracle"""
email: str = Field(description="Adresa de email Oracle")
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
class VerifyEmailResponse(BaseModel):
@@ -115,6 +117,7 @@ class TelegramEmailLoginRequest(BaseModel):
password: str = Field(description="Parola Oracle")
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
session_token: str = Field(description="Token de sesiune pentru preveni spoofing")
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
class TelegramEmailLoginResponse(BaseModel):
@@ -129,6 +132,21 @@ class TelegramEmailLoginResponse(BaseModel):
message: str = Field(description="Mesaj de status")
class SwitchServerRequest(BaseModel):
"""Request pentru schimbarea serverului Oracle"""
oracle_username: str = Field(description="Username Oracle al utilizatorului curent")
new_server_id: str = Field(description="ID-ul noului server Oracle")
oracle_password: Optional[str] = Field(default=None, description="Parola Oracle pe noul server (obligatorie dacă servere diferite au parole diferite)")
class SwitchServerResponse(BaseModel):
"""Response pentru schimbarea serverului Oracle"""
success: bool = Field(description="True dacă schimbarea a reușit")
access_token: Optional[str] = Field(default=None, description="Noul JWT access token")
refresh_token: Optional[str] = Field(default=None, description="Noul JWT refresh token")
message: str = Field(description="Mesaj de status")
# ==================== Helper Functions ====================
# Rate limiting storage (in-memory)
@@ -212,7 +230,7 @@ def generate_linking_code(length: int = 8) -> str:
return ''.join(secrets.choice(alphabet) for _ in range(length))
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
async def get_oracle_user_by_username(username: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
@@ -221,12 +239,13 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
Args:
username: Username-ul utilizatorului Oracle
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Dict cu informații despre utilizator sau None dacă nu există
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Obține detalii utilizator
cursor.execute("""
@@ -270,19 +289,20 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
return None
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
async def verify_oracle_user(username: str, password: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
Args:
username: Username-ul utilizatorului
password: Parola utilizatorului
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Dict cu informații despre utilizator sau None dacă verificarea eșuează
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Verifică autentificarea
cursor.execute("""
@@ -344,6 +364,7 @@ async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str,
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
async def generate_linking_code_endpoint(
request: Request,
current_user: CurrentUser = Depends(get_current_user)
):
"""
@@ -360,8 +381,12 @@ async def generate_linking_code_endpoint(
- Codul expiră după 15 minute
- Fiecare request generează un cod nou (codurile vechi devin invalide)
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
- server_id este extras automat din JWT (setat de auth middleware în request.state)
"""
try:
# Extrage server_id din JWT (setat de auth middleware)
server_id = getattr(request.state, 'server_id', None)
# Generează cod unic
linking_code = generate_linking_code()
@@ -378,7 +403,8 @@ async def generate_linking_code_endpoint(
"code": linking_code,
"telegram_user_id": 0, # Not known yet (user hasn't linked)
"oracle_username": current_user.username,
"expires_in_minutes": expires_in_minutes
"expires_in_minutes": expires_in_minutes,
"server_id": server_id
}
)
@@ -442,7 +468,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
try:
# Flow A: Auto-linking (oracle_username provided, no password)
if request.oracle_username and not request.password:
user_data = await get_oracle_user_by_username(request.oracle_username)
user_data = await get_oracle_user_by_username(request.oracle_username, server_id=request.server_id)
if not user_data:
return VerifyUserResponse(
@@ -452,7 +478,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
# Flow B: Full verification (username + password provided)
elif request.username and request.password:
user_data = await verify_oracle_user(request.username, request.password)
user_data = await verify_oracle_user(request.username, request.password, server_id=request.server_id)
if not user_data:
return VerifyUserResponse(
@@ -467,17 +493,19 @@ async def verify_user_endpoint(request: VerifyUserRequest):
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
)
# Generează JWT tokens
# Generează JWT tokens (cu server_id pentru multi-server mode)
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions']
permissions=user_data['permissions'],
server_id=request.server_id
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id']
user_id=user_data['user_id'],
server_id=request.server_id
)
return VerifyUserResponse(
@@ -528,8 +556,8 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
detail="Refresh token invalid sau expirat"
)
# Obține companiile actualizate din Oracle
async with oracle_pool.get_connection() as connection:
# Obține companiile actualizate din Oracle (folosind server_id din refresh token)
async with oracle_pool.get_connection(token_data.server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT A.ID_FIRMA
@@ -546,12 +574,13 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
# Generează nou access token
# Generează nou access token (păstrează server_id din refresh token)
new_access_token = jwt_handler.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=token_data.permissions
permissions=token_data.permissions,
server_id=token_data.server_id
)
return RefreshTokenResponse(
@@ -580,7 +609,7 @@ async def verify_email_endpoint(request: VerifyEmailRequest):
Security: Generic error messages to prevent email enumeration.
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(request.server_id) as connection:
with connection.cursor() as cursor:
# Query to find username by email
cursor.execute("""
@@ -649,7 +678,7 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
)
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(request.server_id) as connection:
with connection.cursor() as cursor:
# 3. Find username by email
cursor.execute("""
@@ -719,18 +748,18 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
# 6. Get user permissions (default for Telegram)
permissions = ['read', 'reports']
# 7. Generate JWT tokens
token_data = {
"username": username,
"user_id": user_id,
"companies": company_ids,
"permissions": permissions
}
access_token = jwt_handler.create_access_token(**token_data)
# 7. Generate JWT tokens (with server_id for multi-server routing)
access_token = jwt_handler.create_access_token(
username=username,
user_id=user_id,
companies=company_ids,
permissions=permissions,
server_id=request.server_id
)
refresh_token = jwt_handler.create_refresh_token(
username=username,
user_id=user_id
user_id=user_id,
server_id=request.server_id
)
return TelegramEmailLoginResponse(
@@ -754,6 +783,82 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
)
@router.post("/auth/switch-server", response_model=SwitchServerResponse)
async def switch_server_endpoint(
request: SwitchServerRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Schimbă serverul Oracle activ fără re-autentificare.
Flux:
1. Verifică că oracle_username din request == username din JWT (anti-impersonare)
2. Verifică că utilizatorul există pe noul server (fără parolă, prin get_oracle_user_by_username)
3. Generează JWT nou cu noul server_id
4. Returnează tokenurile noi
Securitate: endpoint protejat cu Bearer JWT valid (Depends(get_current_user)).
"""
# Anti-impersonare: utilizatorul poate schimba serverul doar pentru propriul cont
if request.oracle_username.upper() != current_user.username.upper():
raise HTTPException(status_code=403, detail="Acces interzis: username nepotrivit")
# Verifică că utilizatorul există pe noul server
user_data = await get_oracle_user_by_username(request.oracle_username, request.new_server_id)
if not user_data:
return SwitchServerResponse(
success=False,
message=f"Utilizatorul nu există pe serverul {request.new_server_id}"
)
# Dacă parola e furnizată, verifică-o pe noul server înainte de a emite JWT
if request.oracle_password:
try:
async with oracle_pool.get_connection(request.new_server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
"username": request.oracle_username.upper(),
"password": request.oracle_password
})
verification_result = cursor.fetchone()[0]
if verification_result == -1:
return SwitchServerResponse(
success=False,
message="Parolă incorectă pentru acest server"
)
except Exception as e:
logger.error(f"Password verification error during server switch: {e}")
return SwitchServerResponse(
success=False,
message="Eroare la verificarea parolei pe noul server"
)
# Generează JWT nou cu noul server_id
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions'],
server_id=request.new_server_id
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id'],
server_id=request.new_server_id
)
return SwitchServerResponse(
success=True,
access_token=access_token,
refresh_token=refresh_token,
message="Server schimbat cu succes"
)
@router.post("/export", response_model=ExportReportResponse)
async def export_report_endpoint(
request: ExportReportRequest,

View File

@@ -56,6 +56,10 @@ class SaveAuthCodeRequest(BaseModel):
ge=1,
le=60
)
server_id: Optional[str] = Field(
default=None,
description="Oracle server ID (for multi-server mode)"
)
class SaveAuthCodeResponse(BaseModel):
@@ -151,7 +155,8 @@ async def save_auth_code(request: SaveAuthCodeRequest):
code=request.code,
telegram_user_id=request.telegram_user_id,
oracle_username=request.oracle_username,
expires_in_minutes=request.expires_in_minutes
expires_in_minutes=request.expires_in_minutes,
server_id=request.server_id
)
if not success:

View File

@@ -2,8 +2,7 @@
Async SMTP Email Service with retry logic and proper error handling
"""
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.message import EmailMessage
import os
import logging
from typing import Optional
@@ -52,32 +51,33 @@ class EmailService:
Raises:
No exceptions - returns False on all failures
"""
subject = "Codul tău de autentificare ROA2WEB"
html_body = self._create_email_template(code, username)
subject = "Autentificare ROA2WEB"
text_body = self._create_email_template(code, username)
for attempt in range(1, self.max_retries + 1):
try:
await self._send_email(to_email, subject, html_body)
await self._send_email(to_email, subject, text_body)
logger.info(
f"Email sent successfully to {to_email} "
f"(attempt {attempt}/{self.max_retries})"
f"[EMAIL] ✅ Sent auth code to {to_email} "
f"(attempt {attempt}/{self.max_retries}) via {self.smtp_host}:{self.smtp_port}"
)
return True
except aiosmtplib.SMTPException as e:
logger.warning(
f"SMTP error on attempt {attempt}/{self.max_retries}: {e}"
logger.error(
f"[EMAIL] ❌ Attempt {attempt}/{self.max_retries} failed for {to_email}: "
f"{type(e).__name__}: {e}"
)
if attempt < self.max_retries:
# Exponential backoff: 2s, 4s, 8s
delay = self.retry_delay * (2 ** (attempt - 1))
logger.info(f"Retrying in {delay}s...")
logger.info(f"[EMAIL] Retrying in {delay}s...")
await asyncio.sleep(delay)
else:
logger.error(f"Failed to send email to {to_email} after {self.max_retries} attempts")
logger.error(f"[EMAIL] ❌ All {self.max_retries} attempts failed for {to_email}")
except Exception as e:
logger.error(f"Unexpected error sending email: {e}", exc_info=True)
logger.error(f"[EMAIL] ❌ Unexpected error on attempt {attempt}/{self.max_retries} for {to_email}: {type(e).__name__}: {e}", exc_info=True)
return False
return False
@@ -86,29 +86,24 @@ class EmailService:
self,
to_email: str,
subject: str,
html_body: str
text_body: str
) -> None:
"""
Internal async SMTP sender
Internal async SMTP sender (plain text to avoid spam filters)
Raises:
aiosmtplib.SMTPException: On SMTP errors
"""
message = MIMEMultipart("alternative")
message = EmailMessage()
message["From"] = f"{self.from_name} <{self.from_email}>"
message["To"] = to_email
message["Subject"] = subject
message.set_content(text_body)
# Attach HTML body
html_part = MIMEText(html_body, "html", "utf-8")
message.attach(html_part)
# Send via async SMTP with STARTTLS
# Using start_tls parameter for automatic STARTTLS handling
smtp = aiosmtplib.SMTP(
hostname=self.smtp_host,
port=self.smtp_port,
start_tls=self.use_tls, # Use start_tls instead of use_tls
start_tls=self.use_tls,
timeout=30
)
@@ -123,132 +118,15 @@ class EmailService:
pass
def _create_email_template(self, code: str, username: str) -> str:
"""Generate HTML email template"""
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}}
.container {{
max-width: 600px;
margin: 0 auto;
background: white;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 20px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 28px;
}}
.content {{
padding: 40px 20px;
}}
.code-box {{
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border: 3px solid #667eea;
border-radius: 12px;
padding: 30px;
margin: 30px 0;
text-align: center;
}}
.code {{
font-size: 42px;
font-weight: bold;
letter-spacing: 12px;
color: #667eea;
font-family: 'Courier New', monospace;
display: block;
margin: 15px 0;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}}
.footer {{
text-align: center;
color: #666;
font-size: 12px;
padding: 20px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
margin-top: 20px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ROA2WEB</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Autentificare Telegram Bot</p>
</div>
<div class="content">
<p>Salut <strong>{username}</strong>,</p>
<p>Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.</p>
<div class="code-box">
<p style="margin: 0; font-size: 14px; color: #666; font-weight: 500;">
Codul tău de autentificare:
</p>
<span class="code">{code}</span>
<p style="margin: 0; font-size: 12px; color: #888;">
Introdu acest cod în conversația Telegram
</p>
</div>
<div class="warning">
<strong>Important:</strong> Acest cod expiră în <strong>5 minute</strong>
și poate fi folosit o singură dată.
</div>
<p>După introducerea codului, vei fi solicitat să introduci parola ta Oracle.</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
<p style="font-size: 14px; color: #666;">
<strong>Nu ai solicitat acest cod?</strong><br>
Dacă nu ai inițiat această autentificare, poți ignora acest email în siguranță.
Nimeni nu va avea acces la contul tău fără parola ta Oracle.
</p>
</div>
<div class="footer">
<p><strong>ROA2WEB</strong> - ERP Reports Application</p>
<p>Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
<p style="margin-top: 10px; color: #999;">
© 2025 ROA2WEB. All rights reserved.
</p>
</div>
</div>
</body>
</html>
"""
"""Generate plain text email body (HTML blocked by spam filters)"""
return (
f"Codul tau de autentificare ROA2WEB:\n\n"
f" {code}\n\n"
f"Introdu acest cod in Telegram. Expira in 5 minute.\n\n"
f"---\n"
f"Solicitat pentru: {username}\n"
f"Daca nu ai initiat aceasta autentificare, ignora acest email."
)
# Singleton instance

View File

@@ -11,6 +11,18 @@ LOG_FILE=${2:-/tmp/unified_backend.log}
MAX_RESTARTS=10
RESTART_COUNT=0
RESTART_DELAY=3
UVICORN_PID=""
# On SIGTERM or SIGINT: kill uvicorn child and exit cleanly (no restart)
cleanup_and_exit() {
echo "[Backend Runner] Received termination signal, stopping..." | tee -a "$LOG_FILE"
if [ -n "$UVICORN_PID" ] && kill -0 "$UVICORN_PID" 2>/dev/null; then
kill "$UVICORN_PID" 2>/dev/null
wait "$UVICORN_PID" 2>/dev/null
fi
exit 0
}
trap cleanup_and_exit SIGTERM SIGINT
echo "[Backend Runner] Starting uvicorn with auto-restart (max $MAX_RESTARTS restarts)" | tee -a "$LOG_FILE"
echo "[Backend Runner] Port: $PORT, Log: $LOG_FILE" | tee -a "$LOG_FILE"
@@ -20,15 +32,27 @@ while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do
echo "" | tee -a "$LOG_FILE"
echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - Starting uvicorn (attempt $((RESTART_COUNT + 1))/$MAX_RESTARTS)..." | tee -a "$LOG_FILE"
# Run uvicorn - it will exit on crash (OOM, etc.)
uvicorn main:app --host 0.0.0.0 --port "$PORT" 2>&1 | tee -a "$LOG_FILE"
# Run uvicorn in background so we can track its PID and trap signals properly
uvicorn main:app --host 0.0.0.0 --port "$PORT" >> "$LOG_FILE" 2>&1 &
UVICORN_PID=$!
# Wait for uvicorn to exit
wait "$UVICORN_PID"
EXIT_CODE=$?
UVICORN_PID=""
END_TIME=$(date +%s)
RUNTIME=$((END_TIME - START_TIME))
echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - uvicorn exited with code $EXIT_CODE after ${RUNTIME}s" | tee -a "$LOG_FILE"
# Exit if trap already fired (cleanup_and_exit sets UVICORN_PID="")
# or if uvicorn exited due to a signal (130=SIGINT, 143=SIGTERM, 137=SIGKILL)
if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ] || [ $EXIT_CODE -eq 137 ]; then
echo "[Backend Runner] Received termination signal (code $EXIT_CODE), exiting..." | tee -a "$LOG_FILE"
exit 0
fi
# If it ran for more than 60 seconds, reset restart counter (was stable)
if [ $RUNTIME -gt 60 ]; then
RESTART_COUNT=0
@@ -38,12 +62,6 @@ while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do
echo "[Backend Runner] Quick crash detected, restart count: $RESTART_COUNT/$MAX_RESTARTS" | tee -a "$LOG_FILE"
fi
# Exit if user sent SIGINT (Ctrl+C) or SIGTERM
if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ]; then
echo "[Backend Runner] Received termination signal, exiting..." | tee -a "$LOG_FILE"
exit 0
fi
# Wait before restart
if [ $RESTART_COUNT -lt $MAX_RESTARTS ]; then
echo "[Backend Runner] Restarting in ${RESTART_DELAY}s..." | tee -a "$LOG_FILE"

View File

@@ -46,8 +46,8 @@
<remove fileExtension=".webmanifest" />
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
<!-- Client-side caching for static assets -->
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
<!-- No global client-side cache - outbound rules handle this per file type -->
<clientCache cacheControlMode="NoControl" />
</staticContent>
<!-- Custom HTTP Headers (Security) -->
@@ -132,6 +132,18 @@
<action type="Rewrite" value="max-age=31536000; includeSubDomains" />
</rule>
<!-- index.html, sw.js, manifest: NEVER cache - must always be fresh -->
<rule name="No Cache for HTML and SW" preCondition="IsHTMLorSW" stopProcessing="true">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="no-store, no-cache, must-revalidate" />
</rule>
<!-- Hashed assets (Vite output in /assets/): cache 1 year immutable -->
<rule name="Cache Hashed Assets" preCondition="IsHashedAsset" stopProcessing="true">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="public, max-age=31536000, immutable" />
</rule>
<!-- API responses: NO browser/proxy caching (backend cache handles this) -->
<rule name="No Cache for API" preCondition="IsAPIRequest">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
@@ -142,6 +154,12 @@
<preCondition name="IsHTTPS">
<add input="{HTTPS}" pattern="on" />
</preCondition>
<preCondition name="IsHTMLorSW">
<add input="{URL}" pattern="(index\.html|sw\.js|manifest\.json|\.webmanifest)$" />
</preCondition>
<preCondition name="IsHashedAsset">
<add input="{URL}" pattern="/assets/" />
</preCondition>
<preCondition name="IsAPIRequest">
<add input="{URL}" pattern="^/api/" />
</preCondition>

View File

@@ -8,8 +8,9 @@
<!-- BUILD_TIMESTAMP placeholder for cache busting -->
<meta name="build-time" content="BUILD_TIMESTAMP" />
<!-- PWA Support -->
<link rel="manifest" href="/roa2web/manifest.json" />
<link rel="manifest" href="%BASE_URL%manifest.json" />
<meta name="theme-color" content="#2563eb" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="ROA2WEB" />
@@ -27,8 +28,25 @@
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/roa2web/sw.js')
.then(reg => console.log('[PWA] Service Worker registered:', reg.scope))
const swBase = document.querySelector('link[rel="manifest"]')
?.getAttribute('href')?.replace('manifest.json', '') || '/';
navigator.serviceWorker.register(swBase + 'sw.js')
.then(reg => {
console.log('[PWA] Service Worker registered:', reg.scope);
// Auto-reload when a new SW takes control (picks up new deploy)
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[PWA] New Service Worker active - reloading for fresh version');
window.location.reload();
});
// Check for SW updates each time user returns to the tab (handles PWA on mobile)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
reg.update();
}
});
})
.catch(err => console.error('[PWA] Service Worker registration failed:', err));
});
}

View File

@@ -28,10 +28,24 @@ self.addEventListener('activate', (event) => {
}),
// Take control of all clients immediately
clients.claim()
])
]).then(() => {
// Notify all clients that a new SW version is active
return clients.matchAll({ includeUncontrolled: true }).then(allClients => {
allClients.forEach(client => {
client.postMessage({ type: 'SW_UPDATED' });
});
});
})
);
});
// Message handler - allows pages to trigger SW actions
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Fetch event - ALWAYS network first, no caching for HTML/JS/CSS
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
@@ -47,6 +61,19 @@ self.addEventListener('fetch', (event) => {
return;
}
// index.html - bypass HTTP cache entirely to always get fresh app shell
if (
url.pathname.endsWith('index.html') ||
url.pathname === '/roa2web/' ||
url.pathname === '/roa2web'
) {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.catch(() => caches.match(event.request))
);
return;
}
// HTML, JS, CSS - always fetch fresh from network
// This ensures PWA always loads latest version
event.respondWith(

View File

@@ -59,25 +59,34 @@
<!-- Outbound rules to set Cache-Control based on request path -->
<outboundRules>
<!-- index.html, sw.js, manifest: NEVER cache - must always be fresh -->
<rule name="No Cache for HTML and SW" preCondition="IsHTMLorSW" stopProcessing="true">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="no-store, no-cache, must-revalidate" />
</rule>
<!-- Hashed assets (Vite output in /assets/): cache 1 year immutable -->
<rule name="Cache Hashed Assets" preCondition="IsHashedAsset" stopProcessing="true">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="public, max-age=31536000, immutable" />
</rule>
<!-- API responses: NO caching (let backend cache handle it) -->
<rule name="No Cache for API" preCondition="IsAPIRequest">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="no-store, no-cache, must-revalidate, proxy-revalidate" />
</rule>
<!-- Static assets: cache for 1 year -->
<rule name="Cache Static Assets" preCondition="IsStaticAsset">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="public, max-age=31536000" />
</rule>
<preConditions>
<preCondition name="IsHTMLorSW">
<add input="{URL}" pattern="(index\.html|sw\.js|manifest\.json|\.webmanifest)$" />
</preCondition>
<preCondition name="IsHashedAsset">
<add input="{URL}" pattern="/assets/" />
</preCondition>
<preCondition name="IsAPIRequest">
<add input="{URL}" pattern="^/api/" />
</preCondition>
<preCondition name="IsStaticAsset">
<add input="{URL}" pattern="\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|ico|webmanifest)$" />
</preCondition>
</preConditions>
</outboundRules>
</rewrite>

View File

@@ -139,12 +139,11 @@ const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
// Format amount (without RON suffix)
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
if (!amount && amount !== 0) return "0";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
@@ -303,8 +302,7 @@ const initializeInflowsChart = async () => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
@@ -513,8 +511,7 @@ const initializeOutflowsChart = async () => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
@@ -618,6 +615,18 @@ onBeforeUnmount(() => {
min-height: 420px;
}
/* Metric label and value typography */
.metric-label {
font-size: var(--text-sm);
color: var(--text-color-secondary);
}
.metric-value {
font-size: var(--text-sm);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
/* Split layout: Încasări | Divider | Plăți */
.values-section {
display: grid;

View File

@@ -155,12 +155,11 @@ const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
// Format amount (without RON suffix)
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
if (!amount && amount !== 0) return "0";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
@@ -370,8 +369,7 @@ const initializeChart = async () => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
@@ -496,7 +494,7 @@ onBeforeUnmount(() => {
}
.header-label {
font-size: var(--text-base);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-color);
}
@@ -508,11 +506,31 @@ onBeforeUnmount(() => {
}
.header-total {
font-size: var(--text-xl);
font-size: var(--text-sm);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
/* Breakdown section typography */
.breakdown-label {
font-size: var(--text-sm);
}
.breakdown-value {
font-size: var(--text-sm);
font-family: var(--font-mono, monospace);
}
.breakdown-sublabel {
font-size: var(--text-xs);
color: var(--text-color-secondary);
}
.breakdown-subvalue {
font-size: var(--text-xs);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}

View File

@@ -155,12 +155,11 @@ const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
// Format amount (without RON suffix)
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
if (!amount && amount !== 0) return "0";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
@@ -370,8 +369,7 @@ const initializeChart = async () => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
@@ -496,7 +494,7 @@ onBeforeUnmount(() => {
}
.header-label {
font-size: var(--text-base);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-color);
}
@@ -508,11 +506,31 @@ onBeforeUnmount(() => {
}
.header-total {
font-size: var(--text-xl);
font-size: var(--text-sm);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
/* Breakdown section typography */
.breakdown-label {
font-size: var(--text-sm);
}
.breakdown-value {
font-size: var(--text-sm);
font-family: var(--font-mono, monospace);
}
.breakdown-sublabel {
font-size: var(--text-xs);
color: var(--text-color-secondary);
}
.breakdown-subvalue {
font-size: var(--text-xs);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}

View File

@@ -3,7 +3,7 @@
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
<div class="treasury-items">
<!-- Casa -->
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal > 0">
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal !== 0">
<div class="treasury-header" @click="toggleCasaExpanded">
<div class="treasury-header-left">
<i
@@ -12,7 +12,7 @@
></i>
<span class="treasury-label">Casa</span>
</div>
<span class="treasury-value text-success">{{ formatCurrency(casaTotal) }}</span>
<span class="treasury-value" :class="casaTotal >= 0 ? 'text-success' : 'text-danger'">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
@@ -26,13 +26,13 @@
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
</span>
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
<span class="treasury-subvalue" :class="{ 'text-danger': item.sold < 0 }">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
<!-- Bancă -->
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal > 0">
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal !== 0">
<div class="treasury-header" @click="toggleBancaExpanded">
<div class="treasury-header-left">
<i
@@ -41,7 +41,7 @@
></i>
<span class="treasury-label">Bancă</span>
</div>
<span class="treasury-value text-primary">{{ formatCurrency(bancaTotal) }}</span>
<span class="treasury-value" :class="bancaTotal >= 0 ? 'text-primary' : 'text-danger'">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Sub-items -->
@@ -55,7 +55,7 @@
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
</span>
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
<span class="treasury-subvalue" :class="{ 'text-danger': item.sold < 0 }">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
@@ -189,15 +189,14 @@ const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
// Format amount (without RON suffix)
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
if (!amount && amount !== 0) return "0";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
}).format(amount);
};
// Check if sparkline data exists
@@ -327,8 +326,7 @@ const initializeCasaChart = async () => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
@@ -511,8 +509,7 @@ const initializeBancaChart = async () => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
@@ -663,13 +660,13 @@ onBeforeUnmount(() => {
}
.treasury-label {
font-size: var(--text-base);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.treasury-value {
font-size: var(--text-xl);
font-size: var(--text-sm);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
@@ -775,10 +772,6 @@ onBeforeUnmount(() => {
min-height: 280px;
}
.treasury-value {
font-size: var(--text-lg);
}
.sparkline-chart {
height: 130px;
}

View File

@@ -9,7 +9,7 @@
<div class="solduri-compact-card__content">
<span class="solduri-compact-card__label">{{ label }}</span>
<span class="solduri-compact-card__value" :class="valueColorClass">
{{ formatCurrency(total) }}
{{ formatAmount(total) }}
</span>
</div>
<i
@@ -25,7 +25,7 @@
<!-- Casa Total -->
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Casa</span>
<span class="solduri-compact-card__breakdown-value">{{ formatCurrency(casaTotal) }}</span>
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(casaTotal) }}</span>
</div>
<!-- Sub-conturi Casa (imediat sub Casa) -->
<template v-if="breakdown?.casa?.items?.length">
@@ -37,13 +37,13 @@
<span class="solduri-compact-card__breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
</span>
<span class="solduri-compact-card__breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
<span class="solduri-compact-card__breakdown-subvalue">{{ formatAmount(item.sold) }}</span>
</div>
</template>
<!-- Bancă Total -->
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Bancă</span>
<span class="solduri-compact-card__breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(bancaTotal) }}</span>
</div>
<!-- Sub-conturi Bancă (imediat sub Bancă) -->
<template v-if="breakdown?.banca?.items?.length">
@@ -55,7 +55,7 @@
<span class="solduri-compact-card__breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
</span>
<span class="solduri-compact-card__breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
<span class="solduri-compact-card__breakdown-subvalue">{{ formatAmount(item.sold) }}</span>
</div>
</template>
</template>
@@ -65,13 +65,13 @@
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">În termen</span>
<span class="solduri-compact-card__breakdown-value">
{{ formatCurrency(breakdown?.in_termen?.total || 0) }}
{{ formatAmount(breakdown?.in_termen?.total || 0) }}
</span>
</div>
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Restant</span>
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--warning">
{{ formatCurrency(breakdown?.restant?.total || 0) }}
{{ formatAmount(breakdown?.restant?.total || 0) }}
</span>
</div>
<!-- Perioade restante -->
@@ -82,24 +82,50 @@
class="solduri-compact-card__breakdown-subitem"
>
<span class="solduri-compact-card__breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
<span class="solduri-compact-card__breakdown-subvalue">{{ formatCurrency(value) }}</span>
<span class="solduri-compact-card__breakdown-subvalue">{{ formatAmount(value) }}</span>
</div>
</template>
</template>
<!-- TVA: Simple display (no breakdown needed) -->
<!-- TVA / Datorii Buget: Breakdown pe grupe (TVA/BASS/CAM) cu sub-conturi -->
<template v-else-if="type === 'tva'">
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">
{{ total >= 0 ? 'TVA de recuperat' : 'TVA de plată' }}
</span>
<span
class="solduri-compact-card__breakdown-value"
:class="total >= 0 ? 'solduri-compact-card__breakdown-value--success' : 'solduri-compact-card__breakdown-value--danger'"
>
{{ formatCurrency(Math.abs(total)) }}
</span>
</div>
<template v-if="Array.isArray(breakdown) && (breakdown as any[]).length">
<div v-for="group in (breakdown as any[])" :key="group.key">
<!-- Rând grup -->
<div
class="solduri-compact-card__breakdown-item solduri-compact-card__breakdown-group"
@click.stop="toggleGroup(group.key)"
>
<span class="solduri-compact-card__breakdown-label solduri-compact-card__group-label">
<i class="pi pi-chevron-right solduri-compact-card__group-toggle"
:class="{ 'solduri-compact-card__group-toggle--expanded': expandedGroups.has(group.key) }"></i>
{{ group.label }}
</span>
<span class="solduri-compact-card__breakdown-value">
{{ group.precedent !== 0 ? formatAmount(group.precedent) : '-' }}
</span>
</div>
<!-- Sub-conturi -->
<div v-show="expandedGroups.has(group.key)">
<div
v-for="acc in group.sub_accounts"
:key="acc.cont"
class="solduri-compact-card__breakdown-subitem"
>
<span class="solduri-compact-card__breakdown-sublabel">{{ acc.label }}</span>
<span class="solduri-compact-card__breakdown-subvalue">
{{ acc.precedent !== 0 ? formatAmount(acc.precedent) : '-' }}
</span>
</div>
</div>
</div>
</template>
<template v-else>
<div class="solduri-compact-card__breakdown-item">
<span class="solduri-compact-card__breakdown-label">Fără date</span>
<span class="solduri-compact-card__breakdown-value">-</span>
</div>
</template>
</template>
</div>
</div>
@@ -144,6 +170,15 @@ const props = defineProps<{
// State
const isExpanded = ref(false)
const expandedGroups = ref(new Set<string>())
const toggleGroup = (key: string) => {
if (expandedGroups.value.has(key)) {
expandedGroups.value.delete(key)
} else {
expandedGroups.value.add(key)
}
expandedGroups.value = new Set(expandedGroups.value)
}
// Computed: Label based on type
const label = computed(() => {
@@ -151,7 +186,7 @@ const label = computed(() => {
trezorerie: 'TREZORERIE',
clienti: 'CLIENȚI',
furnizori: 'FURNIZORI',
tva: 'TVA'
tva: 'DATORII BUGET'
}
return labels[props.type] || props.type.toUpperCase()
})
@@ -159,9 +194,11 @@ const label = computed(() => {
// Computed: Value color class based on type and value
const valueColorClass = computed(() => {
if (props.type === 'tva') {
return props.total >= 0
? 'solduri-compact-card__value--success'
: 'solduri-compact-card__value--danger'
// total = tvaPreviousMonthNet = plata - recuperat
// pozitiv = datorie la buget (roșu), zero/negativ = fără datorie (verde)
return props.total > 0
? 'solduri-compact-card__value--danger'
: 'solduri-compact-card__value--success'
}
return ''
})
@@ -175,7 +212,7 @@ const hasBreakdown = computed(() => {
return props.breakdown !== null && props.breakdown !== undefined
}
if (props.type === 'tva') {
return true // TVA always shows status
return props.breakdown !== null && props.breakdown !== undefined
}
return false
})
@@ -187,11 +224,10 @@ const toggleExpanded = () => {
}
}
const formatCurrency = (amount: number | undefined | null): string => {
if (amount === undefined || amount === null) return '0 RON'
const formatAmount = (amount: number | undefined | null): string => {
if (amount === undefined || amount === null) return '0'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
@@ -304,13 +340,13 @@ const formatPeriodLabel = (key: string): string => {
}
.solduri-compact-card__breakdown-label {
font-size: var(--text-base);
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.solduri-compact-card__breakdown-value {
font-size: var(--text-base);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-text);
font-family: var(--font-mono, monospace);
@@ -349,6 +385,26 @@ const formatPeriodLabel = (key: string): string => {
font-family: var(--font-mono, monospace);
}
/* TVA grup toggle */
.solduri-compact-card__breakdown-group {
cursor: pointer;
font-weight: var(--font-semibold);
}
.solduri-compact-card__breakdown-group:hover { background: var(--surface-hover); }
.solduri-compact-card__group-label {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.solduri-compact-card__group-toggle {
font-size: var(--text-xs);
color: var(--color-text-secondary);
transition: transform var(--transition-fast);
}
.solduri-compact-card__group-toggle--expanded { transform: rotate(90deg); }
/* Responsive - Mobile */
@media (max-width: 768px) {
.solduri-compact-card {

View File

@@ -41,8 +41,13 @@
<!-- Company selection removed - now handled in header only -->
<!-- Loading Bar - non-blocking, subtle indicator while data loads -->
<div v-if="isLoading" class="loading-bar-container">
<div class="loading-bar"></div>
</div>
<!-- Secțiune Carduri Noi - Adăugare -->
<div class="metrics-cards-section" v-if="!isLoading">
<div class="metrics-cards-section">
<!-- Mobile: Swipeable KPI Cards Carousel -->
<!-- US-2002: 6 pages - first page is 2x2 grid with solduri, pages 2-5 are original graph cards, page 6 is financial indicators -->
<SwipeableCards v-if="isMobile" :totalCards="6" class="mobile-kpi-carousel">
@@ -68,7 +73,8 @@
/>
<SolduriCompactCard
type="tva"
:total="tvaTotal"
:total="tvaPreviousMonthNet"
:breakdown="tvaBreakdown"
/>
</div>
</template>
@@ -220,12 +226,66 @@
:cacheInfo="netBalanceCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Datorii la buget"
:value="budgetDebtTotalPrecedent"
:value-class="budgetDebtTotalPrecedent > 0 ? 'negative' : 'positive'"
>
<div class="budget-debt-breakdown-desktop">
<div class="budget-debt-breakdown-header">
<span class="budget-debt-col-label">Categorie</span>
<span class="budget-debt-col-header-value">Luna prec.</span>
<span class="budget-debt-col-header-value">Luna curentă</span>
</div>
<template v-for="group in budgetDebtBreakdown" :key="group.key">
<!-- Rând grup (clickabil pentru expand sub-conturi) -->
<div
class="budget-debt-breakdown-row budget-debt-group-row"
@click="toggleBudgetGroup(group.key)"
>
<span class="budget-debt-col-label budget-debt-group-label">
<i class="pi pi-chevron-right budget-debt-toggle"
:class="{ expanded: expandedBudgetGroups.has(group.key) }"></i>
{{ group.label }}
</span>
<span class="budget-debt-col-value">
{{ group.precedent !== 0 ? formatAmount(group.precedent) : '-' }}
</span>
<span class="budget-debt-col-value">
{{ group.curent !== 0 ? formatAmount(group.curent) : '-' }}
</span>
</div>
<!-- Sub-conturi (expandabile) -->
<div v-show="expandedBudgetGroups.has(group.key)" class="budget-debt-sub-accounts">
<div
v-for="acc in group.sub_accounts"
:key="acc.cont"
class="budget-debt-breakdown-row budget-debt-subrow"
>
<span class="budget-debt-col-label budget-debt-sub-label">{{ acc.label }}</span>
<span class="budget-debt-col-value budget-debt-sub-value">
{{ acc.precedent !== 0 ? formatAmount(acc.precedent) : '-' }}
</span>
<span class="budget-debt-col-value budget-debt-sub-value">
{{ acc.curent !== 0 ? formatAmount(acc.curent) : '-' }}
</span>
</div>
</div>
</template>
<div v-if="budgetDebtBreakdown.length === 0" class="budget-debt-breakdown-empty">
Nu există datorii înregistrate
</div>
</div>
</CollapsibleCard>
</div>
</div>
<!-- Financial Indicators Section - Desktop Only (US-014) -->
<div v-if="!isMobile && !isLoading" class="financial-indicators-section">
<div v-if="!isMobile" class="financial-indicators-section">
<FinancialIndicatorsCard
:loading="dashboardStore.financialIndicators.loading"
:error="dashboardStore.financialIndicators.error"
@@ -236,11 +296,6 @@
/>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se încarcă datele dashboard-ului...</p>
</div>
</div>
</main>
@@ -507,10 +562,63 @@ const totalTrezorerie = computed(() => {
});
const tvaTotal = computed(() => {
// TVA from dashboard summary if available, otherwise default to 0
return dashboardStore.summary?.tva_sold || 0;
const s = dashboardStore.summary;
if (!s) return 0;
// Pozitiv = de recuperat, Negativ = de plată
return Number(s.tva_recuperat_curent || 0) - Number(s.tva_plata_curent || 0);
});
const tvaPreviousMonth = computed(() => {
const s = dashboardStore.summary;
if (!s) return { plata: 0, recuperat: 0 };
return {
plata: Number(s.tva_plata_precedent || 0),
recuperat: Number(s.tva_recuperat_precedent || 0),
};
});
const tvaCurrentMonth = computed(() => {
const s = dashboardStore.summary;
if (!s) return { plata: 0, recuperat: 0 };
return {
plata: Number(s.tva_plata_curent || 0),
recuperat: Number(s.tva_recuperat_curent || 0),
};
});
// TVA net luna precedentă (valoare pozitivă = datorie la buget)
const tvaPreviousMonthNet = computed(() => {
const prev = tvaPreviousMonth.value;
return (prev.plata || 0) - (prev.recuperat || 0);
});
// Breakdown pe grupe datorii buget (TVA/BASS/CAM cu sub-conturi)
const budgetDebtBreakdown = computed(() => {
return dashboardStore.summary?.budget_debt_breakdown || [];
});
// Total precedent din toate grupurile (pentru header CollapsibleCard)
const budgetDebtTotalPrecedent = computed(() => {
const groups = dashboardStore.summary?.budget_debt_breakdown || [];
return groups.reduce((sum, g) => sum + Number(g.precedent || 0), 0);
});
const tvaBreakdown = computed(() => {
return dashboardStore.summary?.budget_debt_breakdown || [];
});
// Reactive state pentru expand/collapse grupe buget
const expandedBudgetGroups = ref(new Set());
const toggleBudgetGroup = (key) => {
if (expandedBudgetGroups.value.has(key)) {
expandedBudgetGroups.value.delete(key);
} else {
expandedBudgetGroups.value.add(key);
}
// Force reactivity update on Set
expandedBudgetGroups.value = new Set(expandedBudgetGroups.value);
};
// Net Cash Flow for CollapsibleCard header
const netCashFlow = computed(() => {
return (monthlyInflows.value || 0) - (monthlyOutflows.value || 0);
@@ -716,18 +824,18 @@ const handleCompanyChanged = async (company) => {
}
};
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0,00 RON";
const formatAmount = (amount) => {
if (!amount && amount !== 0) return "0";
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return "0,00 RON";
if (isNaN(numAmount)) return "0";
try {
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
style: "decimal",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(numAmount);
} catch (error) {
return `${numAmount.toLocaleString("ro-RO", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} RON`;
} catch {
return numAmount.toLocaleString("ro-RO", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
};
@@ -1100,6 +1208,14 @@ const loadDashboardData = async () => {
const indicatorAn = prevPeriod?.an || null;
try {
// Fire financial indicators independently - it has its own loading state
// and should not block the main dashboard cards from appearing
dashboardStore.loadFinancialIndicators(
companyStore.selectedCompany.id_firma,
indicatorLuna,
indicatorAn,
);
await Promise.all([
dashboardStore.loadDashboardSummary(
companyStore.selectedCompany.id_firma,
@@ -1111,12 +1227,6 @@ const loadDashboardData = async () => {
loadMonthlyFlows(),
loadTreasuryBreakdown(),
loadNetBalanceBreakdown(),
// US-014: Load financial indicators for desktop card (luna anterioară)
dashboardStore.loadFinancialIndicators(
companyStore.selectedCompany.id_firma,
indicatorLuna,
indicatorAn,
),
]);
} catch (error) {
console.error("Failed to load dashboard data:", error);
@@ -1447,15 +1557,26 @@ onUnmounted(() => {
color: var(--color-text-secondary);
}
/* Loading State - Uses global .loading-spinner from interactive.css */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
color: var(--color-text-secondary);
text-align: center;
/* Loading Bar - non-blocking, subtle indicator at top of content */
.loading-bar-container {
height: 3px;
background: var(--surface-border);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-md);
}
.loading-bar {
height: 100%;
background: var(--color-primary);
animation: loading-progress 1.5s ease-in-out infinite;
border-radius: var(--radius-full);
}
@keyframes loading-progress {
0% { width: 0%; transform: translateX(0%); }
50% { width: 60%; transform: translateX(60%); }
100% { width: 30%; transform: translateX(300%); }
}
/* Responsive Design - Consolidated (Component-specific only) */
@@ -1537,6 +1658,104 @@ onUnmounted(() => {
width: 100%;
}
/* Budget Debt Breakdown in Desktop CollapsibleCard */
.budget-debt-breakdown-desktop {
display: flex;
flex-direction: column;
padding: var(--space-md);
}
.budget-debt-breakdown-header {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--space-md);
padding: var(--space-xs) 0 var(--space-sm) 0;
border-bottom: 1px solid var(--surface-border);
margin-bottom: var(--space-xs);
}
.budget-debt-breakdown-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--space-md);
padding: var(--space-xs) 0;
align-items: center;
}
.budget-debt-col-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.budget-debt-breakdown-header .budget-debt-col-label {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.budget-debt-col-value {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
font-family: var(--font-mono, monospace);
color: var(--color-text);
white-space: nowrap;
min-width: 130px;
text-align: right;
}
.budget-debt-col-header-value {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
white-space: nowrap;
min-width: 130px;
text-align: right;
}
.budget-debt-breakdown-empty {
padding: var(--space-sm) 0;
color: var(--color-text-secondary);
font-style: italic;
font-size: var(--text-sm);
}
/* Grup row - clickabil, bold */
.budget-debt-group-row {
cursor: pointer;
font-weight: var(--font-semibold);
background: var(--surface-ground);
border-radius: var(--radius-sm);
}
.budget-debt-group-row:hover { background: var(--surface-hover); }
.budget-debt-group-label {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.budget-debt-toggle {
font-size: var(--text-xs);
color: var(--text-color-secondary);
transition: transform var(--transition-fast);
}
.budget-debt-toggle.expanded { transform: rotate(90deg); }
/* Sub-conturi indentate */
.budget-debt-sub-accounts {
background: var(--surface-card);
border-left: 2px solid var(--surface-border);
margin-left: var(--space-sm);
}
.budget-debt-subrow { opacity: 0.85; }
.budget-debt-sub-label { padding-left: var(--space-md); font-size: var(--text-xs); }
.budget-debt-sub-value { font-size: var(--text-xs); }
/* Responsive - All breakpoints consolidated */
@media (max-width: 1200px) {
.metrics-row {

View File

@@ -76,8 +76,7 @@ const formattedValue = computed(() => {
if (props.formatCurrency && typeof props.value === 'number') {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(props.value)
@@ -138,12 +137,14 @@ const toggleExpanded = () => {
flex-shrink: 0;
}
/* Label */
/* Label - matches SolduriCompactCard style */
.collapsible-card__label {
font-size: var(--text-sm);
font-size: var(--text-xs);
color: var(--text-color-secondary);
font-weight: var(--font-medium);
font-weight: var(--font-semibold);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Value */
@@ -223,10 +224,6 @@ const toggleExpanded = () => {
padding: var(--space-sm) var(--space-md);
}
.collapsible-card__label {
font-size: var(--text-xs);
}
.collapsible-card__value {
font-size: var(--text-base);
}

View File

@@ -38,6 +38,7 @@ LOG_DIR=""
BACKEND_LOG=""
FRONTEND_LOG=""
NEEDS_SSH_TUNNEL=false
BACKEND_RESTART_PID=""
configure_environment() {
case "$1" in
@@ -121,20 +122,23 @@ check_port() {
cleanup() {
print_message "Stopping all services..."
# Stop run-with-restart.sh wrapper FIRST (prevents auto-restart)
print_message "Stopping backend restart wrapper..."
pkill -f "run-with-restart.sh" 2>/dev/null || true
sleep 1
# Stop Unified Backend (8000)
if check_port 8000; then
print_message "Stopping Unified Backend..."
pkill -f "uvicorn main:app" 2>/dev/null || true
# Stop run-with-restart.sh wrapper FIRST by saved PID (prevents auto-restart race)
if [ -n "$BACKEND_RESTART_PID" ] && kill -0 "$BACKEND_RESTART_PID" 2>/dev/null; then
print_message "Stopping backend restart wrapper (PID $BACKEND_RESTART_PID)..."
kill "$BACKEND_RESTART_PID" 2>/dev/null || true
sleep 2
pkill -9 -f "uvicorn main:app" 2>/dev/null || true
lsof -ti:8000 | xargs kill -KILL 2>/dev/null || true
fi
# Kill ALL uvicorn instances unconditionally (regardless of port status)
# This handles cases where uvicorn is still starting up or has crashed
print_message "Stopping Unified Backend..."
pkill -f "uvicorn main:app" 2>/dev/null || true
pkill -f "run-with-restart.sh" 2>/dev/null || true
sleep 2
pkill -9 -f "uvicorn main:app" 2>/dev/null || true
pkill -9 -f "run-with-restart.sh" 2>/dev/null || true
lsof -ti:8000 | xargs kill -KILL 2>/dev/null || true
# Stop Unified Frontend (3000)
if check_port 3000; then
print_message "Stopping Unified Frontend..."
@@ -240,6 +244,7 @@ start_services() {
# Start backend with auto-restart on crash (OOM protection)
print_message "Starting unified backend with auto-restart..."
nohup ./run-with-restart.sh 8000 "$BACKEND_LOG" > /dev/null 2>&1 &
BACKEND_RESTART_PID=$!
cd "$SCRIPT_DIR"