Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
411 lines
19 KiB
Python
411 lines
19 KiB
Python
# import sys # Removed - no longer needed
|
|
import os
|
|
from typing import Optional, List, Tuple, Any
|
|
|
|
import oracledb
|
|
from shared.database.oracle_pool import oracle_pool
|
|
from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse, AccountingPeriod
|
|
from ..cache.decorators import cached
|
|
from decimal import Decimal
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class TreasuryService:
|
|
"""Service pentru trezorerie - registru casă și bancă"""
|
|
|
|
@staticmethod
|
|
@cached(cache_type='schema', key_params=['company_id', 'server_id'])
|
|
async def _get_schema(company_id: int, server_id: Optional[str] = None) -> str:
|
|
"""Obține schema pentru company_id (CACHED PERMANENT)"""
|
|
async with oracle_pool.get_connection(server_id) as connection:
|
|
with connection.cursor() as cursor:
|
|
schema_query = """
|
|
SELECT schema
|
|
FROM CONTAFIN_ORACLE.v_nom_firme
|
|
WHERE id_firma = :company_id
|
|
"""
|
|
cursor.execute(schema_query, {'company_id': company_id})
|
|
schema_result = cursor.fetchone()
|
|
|
|
if not schema_result:
|
|
raise ValueError(f"Schema not found for company {company_id}")
|
|
|
|
return schema_result[0]
|
|
|
|
@staticmethod
|
|
def _get_view_query(schema: str, register_type: Optional[str] = None) -> str:
|
|
"""
|
|
Construiește query-ul pentru view-ul vbancasa corespunzător.
|
|
Dacă register_type este None, returnează UNION ALL pentru toate tipurile.
|
|
NU se filtrează pe incasari/plati > 0 - se afișează TOATE înregistrările!
|
|
"""
|
|
view_configs = {
|
|
'BANCA_LEI': {
|
|
'view': f'{schema}.vbancasa_5121_cum',
|
|
'incasari_col': 'incasari',
|
|
'plati_col': 'plati',
|
|
'valuta': "'RON'",
|
|
'tip': "'BANCA LEI'"
|
|
},
|
|
'BANCA_VALUTA': {
|
|
'view': f'{schema}.vbancasa_5124_cum',
|
|
'incasari_col': 'incasval',
|
|
'plati_col': 'platival',
|
|
'valuta': "COALESCE(numeval, 'EUR')",
|
|
'tip': "'BANCA VALUTA'"
|
|
},
|
|
'CASA_LEI': {
|
|
'view': f'{schema}.vbancasa_5311_cum',
|
|
'incasari_col': 'incasari',
|
|
'plati_col': 'plati',
|
|
'valuta': "'RON'",
|
|
'tip': "'CASA LEI'"
|
|
},
|
|
'CASA_VALUTA': {
|
|
'view': f'{schema}.vbancasa_5314_cum',
|
|
'incasari_col': 'incasval',
|
|
'plati_col': 'platival',
|
|
'valuta': "COALESCE(numeval, 'EUR')",
|
|
'tip': "'CASA VALUTA'"
|
|
}
|
|
}
|
|
|
|
def build_select(config):
|
|
# NU se filtrează - se afișează TOATE înregistrările
|
|
# SOLD CUMULAT: Running balance per bancasa using window function
|
|
# NULL-date rows (opening balance) come first due to NULLS FIRST
|
|
return f"""
|
|
SELECT
|
|
nume, nract, dataact, bancasa,
|
|
{config['incasari_col']} as incasari,
|
|
{config['plati_col']} as plati,
|
|
SUM({config['incasari_col']} - {config['plati_col']}) OVER (
|
|
PARTITION BY bancasa
|
|
ORDER BY dataact ASC NULLS FIRST, nract ASC NULLS FIRST
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
) as sold,
|
|
{config['valuta']} as valuta,
|
|
{config['tip']} as tip_registru,
|
|
explicatia
|
|
FROM {config['view']}
|
|
"""
|
|
|
|
if register_type and register_type in view_configs:
|
|
return build_select(view_configs[register_type])
|
|
else:
|
|
# UNION ALL pentru toate tipurile
|
|
queries = [build_select(cfg) for cfg in view_configs.values()]
|
|
return " UNION ALL ".join(queries)
|
|
|
|
@staticmethod
|
|
@cached(cache_type='treasury', key_params=['filter_params', 'username', 'server_id'])
|
|
async def get_bank_cash_register(filter_params: RegisterFilter, username: str, server_id: Optional[str] = None) -> RegisterListResponse:
|
|
"""
|
|
Obține registrul de casă și bancă din vbancasa views (CACHED 10 min)
|
|
|
|
IMPORTANT: PACK_SESIUNE.SETAN și SETLUNA trebuie executate în ACEEAȘI
|
|
tranzacție cu SELECT-ul din vbancasa* views!
|
|
|
|
Folosim un bloc PL/SQL anonim care:
|
|
1. Obține anul și luna curentă din calendar
|
|
2. Apelează PACK_SESIUNE.SETAN și SETLUNA
|
|
3. Execută SELECT-ul din vbancasa*
|
|
Toate în aceeași tranzacție!
|
|
"""
|
|
company_id = int(filter_params.company)
|
|
schema = await TreasuryService._get_schema(company_id, server_id)
|
|
|
|
async with oracle_pool.get_connection(server_id) as connection:
|
|
with connection.cursor() as cursor:
|
|
|
|
# Construiește query-ul pentru tipul de registru selectat
|
|
base_select = TreasuryService._get_view_query(schema, filter_params.register_type)
|
|
|
|
# Construiește WHERE conditions
|
|
where_conditions = []
|
|
|
|
# Date filter preserves NULL-date rows (opening balance)
|
|
# for correct cumulative sum calculation
|
|
if filter_params.date_from and filter_params.date_to:
|
|
where_conditions.append(f"(dataact IS NULL OR (dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD') AND dataact <= TO_DATE('{filter_params.date_to.strftime('%Y-%m-%d')}', 'YYYY-MM-DD')))")
|
|
elif filter_params.date_from:
|
|
where_conditions.append(f"(dataact IS NULL OR dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))")
|
|
elif filter_params.date_to:
|
|
where_conditions.append(f"(dataact IS NULL OR dataact <= TO_DATE('{filter_params.date_to.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))")
|
|
|
|
if filter_params.partner_name:
|
|
# Escape single quotes pentru SQL
|
|
partner_escaped = filter_params.partner_name.replace("'", "''")
|
|
where_conditions.append(f"UPPER(nume) LIKE UPPER('%{partner_escaped}%')")
|
|
|
|
if filter_params.bank_account:
|
|
# Escape single quotes pentru SQL
|
|
bank_escaped = filter_params.bank_account.replace("'", "''")
|
|
where_conditions.append(f"bancasa = '{bank_escaped}'")
|
|
|
|
where_clause = ""
|
|
if where_conditions:
|
|
where_clause = " WHERE " + " AND ".join(where_conditions)
|
|
|
|
# Paginare Oracle
|
|
offset = (filter_params.page - 1) * filter_params.page_size
|
|
limit_val = filter_params.page_size
|
|
|
|
# Determine period to use: from params or MAX from calendar
|
|
if filter_params.luna and filter_params.an:
|
|
use_param_period = True
|
|
period_select = f"""
|
|
v_an := :param_an;
|
|
v_luna := :param_luna;
|
|
"""
|
|
else:
|
|
use_param_period = False
|
|
period_select = f"""
|
|
SELECT anul, luna INTO v_an, v_luna
|
|
FROM {schema}.calendar
|
|
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar);
|
|
"""
|
|
|
|
# Bloc PL/SQL anonim care face totul într-o singură tranzacție:
|
|
# 1. Obține anul și luna din params sau calendar
|
|
# 2. Setează PACK_SESIUNE.SETAN și SETLUNA
|
|
# 3. Returnează datele prin REF CURSOR
|
|
# IMPORTANT: Folosim ROW_NUMBER() pentru paginare corectă cu ORDER BY NULLS FIRST
|
|
plsql_block = f"""
|
|
DECLARE
|
|
v_an NUMBER;
|
|
v_luna NUMBER;
|
|
v_cursor SYS_REFCURSOR;
|
|
BEGIN
|
|
-- Obține anul și luna din parametri sau calendar
|
|
{period_select}
|
|
|
|
-- Setează contextul de sesiune (OBLIGATORIU înainte de SELECT din vbancasa*)
|
|
{schema}.PACK_SESIUNE.SETAN(v_an);
|
|
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
|
|
|
|
-- Return accounting period
|
|
:out_an := v_an;
|
|
:out_luna := v_luna;
|
|
|
|
-- Returnează datele prin cursor cu ROW_NUMBER pentru paginare corectă
|
|
-- Pentru rânduri cu dataact=NULL (solduri precedente), sortare după bancasa
|
|
-- Pentru rânduri cu date, sortare după data, număr, bancasa
|
|
OPEN :result_cursor FOR
|
|
SELECT * FROM (
|
|
SELECT t.*, ROW_NUMBER() OVER (
|
|
ORDER BY dataact ASC NULLS FIRST,
|
|
CASE WHEN dataact IS NULL THEN bancasa END ASC,
|
|
nract ASC NULLS FIRST,
|
|
bancasa ASC
|
|
) as rn
|
|
FROM ({base_select}) t{where_clause}
|
|
) WHERE rn > {offset} AND rn <= {offset + limit_val};
|
|
END;
|
|
"""
|
|
|
|
# Creează cursor pentru rezultate (oracledb.CURSOR pentru REF CURSOR)
|
|
result_cursor = cursor.var(oracledb.CURSOR)
|
|
out_an = cursor.var(int)
|
|
out_luna = cursor.var(int)
|
|
|
|
# Build params dict
|
|
exec_params = {'result_cursor': result_cursor, 'out_an': out_an, 'out_luna': out_luna}
|
|
if use_param_period:
|
|
exec_params['param_an'] = filter_params.an
|
|
exec_params['param_luna'] = filter_params.luna
|
|
|
|
# Execută blocul PL/SQL cu REF CURSOR
|
|
cursor.execute(plsql_block, exec_params)
|
|
|
|
# Get accounting period values
|
|
accounting_year = out_an.getvalue()
|
|
accounting_month = out_luna.getvalue()
|
|
|
|
# Obține rezultatele din cursor
|
|
ref_cursor = result_cursor.getvalue()
|
|
rows = ref_cursor.fetchall()
|
|
ref_cursor.close()
|
|
|
|
# Pentru count total, executăm alt bloc PL/SQL
|
|
count_plsql = f"""
|
|
DECLARE
|
|
v_an NUMBER;
|
|
v_luna NUMBER;
|
|
BEGIN
|
|
-- Obține anul și luna din parametri sau calendar
|
|
{period_select}
|
|
|
|
{schema}.PACK_SESIUNE.SETAN(v_an);
|
|
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
|
|
|
|
SELECT COUNT(*) INTO :total_count FROM ({base_select}) sub{where_clause};
|
|
END;
|
|
"""
|
|
|
|
total_count_var = cursor.var(int)
|
|
count_params = {'total_count': total_count_var}
|
|
if use_param_period:
|
|
count_params['param_an'] = filter_params.an
|
|
count_params['param_luna'] = filter_params.luna
|
|
cursor.execute(count_plsql, count_params)
|
|
total_count = total_count_var.getvalue()
|
|
|
|
# Query pentru TOTALURI din TOATE înregistrările filtrate (nu doar pagina curentă)
|
|
# sold_precedent = suma sold pentru rânduri cu dataact IS NULL
|
|
# total_incasari = suma incasari pentru rânduri cu dataact IS NOT NULL
|
|
# total_plati = suma plati pentru rânduri cu dataact IS NOT NULL
|
|
# Notă: where_clause poate fi gol sau poate conține "WHERE ..."
|
|
# Dacă e gol, adăugăm WHERE; dacă nu, adăugăm AND
|
|
dataact_null_cond = " AND dataact IS NULL" if where_clause else " WHERE dataact IS NULL"
|
|
dataact_not_null_cond = " AND dataact IS NOT NULL" if where_clause else " WHERE dataact IS NOT NULL"
|
|
|
|
totals_plsql = f"""
|
|
DECLARE
|
|
v_an NUMBER;
|
|
v_luna NUMBER;
|
|
BEGIN
|
|
-- Obține anul și luna din parametri sau calendar
|
|
{period_select}
|
|
|
|
{schema}.PACK_SESIUNE.SETAN(v_an);
|
|
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
|
|
|
|
-- Sold precedent: suma sold pentru rânduri fără dată (opening balance)
|
|
SELECT NVL(SUM(sold), 0) INTO :sold_precedent_all
|
|
FROM ({base_select}) sub{where_clause}{dataact_null_cond};
|
|
|
|
-- Total încasări: suma incasari pentru rânduri cu dată (transactions)
|
|
SELECT NVL(SUM(incasari), 0) INTO :total_incasari_all
|
|
FROM ({base_select}) sub{where_clause}{dataact_not_null_cond};
|
|
|
|
-- Total plăți: suma plati pentru rânduri cu dată (transactions)
|
|
SELECT NVL(SUM(plati), 0) INTO :total_plati_all
|
|
FROM ({base_select}) sub{where_clause}{dataact_not_null_cond};
|
|
END;
|
|
"""
|
|
|
|
sold_precedent_all_var = cursor.var(oracledb.NUMBER)
|
|
total_incasari_all_var = cursor.var(oracledb.NUMBER)
|
|
total_plati_all_var = cursor.var(oracledb.NUMBER)
|
|
|
|
totals_params = {
|
|
'sold_precedent_all': sold_precedent_all_var,
|
|
'total_incasari_all': total_incasari_all_var,
|
|
'total_plati_all': total_plati_all_var
|
|
}
|
|
if use_param_period:
|
|
totals_params['param_an'] = filter_params.an
|
|
totals_params['param_luna'] = filter_params.luna
|
|
|
|
cursor.execute(totals_plsql, totals_params)
|
|
|
|
sold_precedent_all = Decimal(str(sold_precedent_all_var.getvalue() or 0))
|
|
total_incasari_all = Decimal(str(total_incasari_all_var.getvalue() or 0))
|
|
total_plati_all = Decimal(str(total_plati_all_var.getvalue() or 0))
|
|
sold_final_all = sold_precedent_all + total_incasari_all - total_plati_all
|
|
|
|
# Procesare rezultate
|
|
registers = []
|
|
total_incasari = Decimal('0.00')
|
|
total_plati = Decimal('0.00')
|
|
|
|
for row in rows:
|
|
# Coloane: nume, nract, dataact, bancasa, incasari, plati, sold, valuta, tip_registru, explicatia, rn
|
|
# row[0-9] = date, row[10] = rn (ROW_NUMBER la final)
|
|
register_data = BankCashRegister(
|
|
nume=row[0] or '',
|
|
nract=row[1],
|
|
dataact=row[2],
|
|
nume_cont_bancar=row[3] or '',
|
|
incasari=Decimal(str(row[4] or 0)),
|
|
plati=Decimal(str(row[5] or 0)),
|
|
sold=Decimal(str(row[6] or 0)),
|
|
valuta=row[7],
|
|
tip_registru=row[8],
|
|
explicatia=row[9] or ''
|
|
)
|
|
registers.append(register_data)
|
|
total_incasari += register_data.incasari
|
|
total_plati += register_data.plati
|
|
|
|
logger.info(f"Treasury query for company {company_id}, type={filter_params.register_type}: {len(registers)} records, total={total_count}")
|
|
|
|
return RegisterListResponse(
|
|
registers=registers,
|
|
total_count=total_count,
|
|
filtered_count=len(registers),
|
|
total_incasari=total_incasari,
|
|
total_plati=total_plati,
|
|
page=filter_params.page,
|
|
page_size=filter_params.page_size,
|
|
has_more=len(registers) == filter_params.page_size,
|
|
accounting_period=AccountingPeriod(an=accounting_year, luna=accounting_month),
|
|
# Totaluri din TOATE înregistrările filtrate
|
|
sold_precedent_all=sold_precedent_all,
|
|
total_incasari_all=total_incasari_all,
|
|
total_plati_all=total_plati_all,
|
|
sold_final_all=sold_final_all
|
|
)
|
|
|
|
@staticmethod
|
|
@cached(cache_type='treasury', key_params=['company_id', 'register_type', 'server_id'])
|
|
async def get_bank_cash_accounts(company_id: int, register_type: str, server_id: Optional[str] = None) -> List[str]:
|
|
"""
|
|
Obține lista distinctă de conturi bancă/casă (bancasa) pentru dropdown.
|
|
Cached pentru performanță.
|
|
IMPORTANT: Trebuie să setăm contextul PACK_SESIUNE înainte de a accesa vbancasa views!
|
|
"""
|
|
schema = await TreasuryService._get_schema(company_id, server_id)
|
|
|
|
# Map register_type to view
|
|
view_map = {
|
|
'BANCA_LEI': f'{schema}.vbancasa_5121_cum',
|
|
'BANCA_VALUTA': f'{schema}.vbancasa_5124_cum',
|
|
'CASA_LEI': f'{schema}.vbancasa_5311_cum',
|
|
'CASA_VALUTA': f'{schema}.vbancasa_5314_cum'
|
|
}
|
|
|
|
if register_type not in view_map:
|
|
return []
|
|
|
|
view_name = view_map[register_type]
|
|
|
|
async with oracle_pool.get_connection(server_id) as connection:
|
|
with connection.cursor() as cursor:
|
|
# PL/SQL block to set session context and get accounts
|
|
plsql_block = f"""
|
|
DECLARE
|
|
v_an NUMBER;
|
|
v_luna NUMBER;
|
|
BEGIN
|
|
-- Get current year and month from calendar
|
|
SELECT anul, luna INTO v_an, v_luna
|
|
FROM {schema}.calendar
|
|
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar);
|
|
|
|
-- Set session context (REQUIRED before accessing vbancasa* views)
|
|
{schema}.PACK_SESIUNE.SETAN(v_an);
|
|
{schema}.PACK_SESIUNE.SETLUNA(v_luna);
|
|
|
|
-- Return accounts via cursor
|
|
OPEN :result_cursor FOR
|
|
SELECT DISTINCT bancasa
|
|
FROM {view_name}
|
|
WHERE bancasa IS NOT NULL
|
|
ORDER BY bancasa;
|
|
END;
|
|
"""
|
|
|
|
result_cursor = cursor.var(oracledb.CURSOR)
|
|
cursor.execute(plsql_block, {'result_cursor': result_cursor})
|
|
|
|
ref_cursor = result_cursor.getvalue()
|
|
rows = ref_cursor.fetchall()
|
|
ref_cursor.close()
|
|
|
|
accounts = [row[0] for row in rows if row[0]]
|
|
logger.info(f"Found {len(accounts)} bank/cash accounts for company {company_id}, type={register_type}")
|
|
return accounts
|