feat: Migrate to ultrathin monolith architecture

Consolidate 3 separate applications (reports-app, data-entry-app, telegram-bot) into a unified
architecture with single backend and frontend:

Backend Changes:
- Unified FastAPI backend at backend/ with modular structure
- Modules: reports, data_entry, telegram in backend/modules/
- Centralized config.py and main.py with all routers registered
- Single worker mode (--workers 1) for Telegram bot compatibility
- Shared Oracle connection pool and JWT authentication
- Unified requirements.txt and environment configuration

Frontend Changes:
- Single Vue.js SPA with module-based routing
- Unified frontend at src/ with modules in src/modules/{reports,data-entry}/
- Shared components and stores in src/shared/
- Error boundaries for module isolation
- Dual API proxy in Vite for module communication

Infrastructure:
- New unified startup scripts: start-prod.sh, start-test.sh, start-backend.sh
- Environment templates: .env.dev.example, .env.test.example, .env.prod.example
- Updated deployment scripts for Windows IIS
- Simplified SSH tunnel management

Documentation:
- Comprehensive CLAUDE.md with architecture overview
- Module-specific docs in docs/{data-entry,telegram}/
- Architecture decision records in docs/ARCHITECTURE-DECISIONS.md
- Deployment guides consolidated in deployment/windows/docs/

This migration reduces complexity, improves maintainability, and enables easier
deployment while maintaining all existing functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 23:48:14 +02:00
parent 2a101f1ef5
commit c5e051ad80
378 changed files with 7566 additions and 73730 deletions

View File

@@ -0,0 +1,77 @@
"""
Calendar service for fetching available accounting periods
"""
# import sys # Removed - no longer needed
import os
from shared.database.oracle_pool import oracle_pool
from ..models.calendar import CalendarPeriod, CalendarPeriodsResponse
from ..cache.decorators import cached
import logging
logger = logging.getLogger(__name__)
class CalendarService:
"""Service for calendar/accounting period operations"""
# Romanian month names for display
MONTH_NAMES_RO = [
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
]
@staticmethod
@cached(cache_type='schema', key_params=['company_id'])
async def _get_schema(company_id: int) -> str:
"""Get schema for company (CACHED 24h)"""
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme
WHERE id_firma = :company_id
""", {'company_id': company_id})
result = cursor.fetchone()
return result[0] if result else None
@staticmethod
@cached(cache_type='calendar_periods', key_params=['company_id'])
async def get_available_periods(company_id: int) -> CalendarPeriodsResponse:
"""
Get all available accounting periods for a company (CACHED 1h)
Returns periods ordered by year DESC, month DESC with Romanian month names.
"""
schema = await CalendarService._get_schema(company_id)
if not schema:
logger.warning(f"Schema not found for company {company_id}")
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(f"""
SELECT anul, luna
FROM {schema}.calendar
ORDER BY anul DESC, luna DESC
""")
rows = cursor.fetchall()
periods = []
for row in rows:
an, luna = row[0], row[1]
month_name = CalendarService.MONTH_NAMES_RO[luna - 1]
periods.append(CalendarPeriod(
an=an,
luna=luna,
display_name=f"{month_name} {an}"
))
current_period = periods[0] if periods else None
logger.info(f"Loaded {len(periods)} accounting periods for company {company_id}")
return CalendarPeriodsResponse(
periods=periods,
current_period=current_period,
total_count=len(periods)
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,324 @@
"""
Service pentru logica facturi - Portează query-urile din aplicația Flask
"""
# import sys # Removed - no longer needed
import os
from shared.database.oracle_pool import oracle_pool
from typing import List, Tuple
from ..models.invoice import Invoice, InvoiceFilter, InvoiceListResponse, InvoiceSummary
from ..cache.decorators import cached
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
class InvoiceService:
"""Service pentru gestionarea facturilor"""
@staticmethod
@cached(cache_type='schema', key_params=['company_id'])
async def _get_schema(company_id: int) -> str:
"""Obține schema pentru company_id (CACHED PERMANENT)"""
async with oracle_pool.get_connection() 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
@cached(cache_type='invoices', key_params=['filter_params', 'username'])
async def get_invoices(filter_params: InvoiceFilter, username: str) -> InvoiceListResponse:
"""
Obține lista de facturi - Query simplu pentru afișare în tabel (CACHED 10 min)
"""
company_id = int(filter_params.company)
schema = await InvoiceService._get_schema(company_id)
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Determină conturile în funcție de partner_type
if filter_params.partner_type == "CLIENTI":
conturi = "'4111', '461'"
elif filter_params.partner_type == "FURNIZORI":
conturi = "'401', '404', '462'"
else:
conturi = "'4111'" # default
# Determine period to use: from params or MAX from calendar
if filter_params.luna and filter_params.an:
period_condition = "vp.an = :an AND vp.luna = :luna"
use_param_period = True
else:
period_condition = f"""vp.an = (SELECT anul FROM {schema}.calendar WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar))
AND vp.luna = (SELECT luna FROM {schema}.calendar WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar))"""
use_param_period = False
# Query cu calculele corecte pentru solduri
base_query = f"""
SELECT
vp.NUME,
vp.NRACT,
vp.DATAACT,
vp.DATASCAD,
vp.CONTRACT,
vp.COD_FISCAL,
vp.REG_COMERT,
CASE
WHEN vp.CONT IN ('4111','461') THEN vp.PRECDEB + vp.DEBIT -- Total facturat clienți
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECCRED + vp.CREDIT -- Total facturat furnizori
END as total_facturat,
CASE
WHEN vp.CONT IN ('4111','461') THEN vp.PRECCRED + vp.CREDIT -- Încasat clienți
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECDEB + vp.DEBIT -- Achitat furnizori
END as achitat,
CASE
WHEN vp.CONT IN ('4111','461') THEN
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT) -- Sold clienți
WHEN vp.CONT IN ('401','404','462') THEN
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT) -- Sold furnizori
END as sold,
vp.CONT,
NVL(vp.NUME_VAL, 'RON') as valuta,
CASE
WHEN vp.DATASCAD < SYSDATE THEN 'restant'
ELSE 'in_termen'
END as status
FROM {schema}.vireg_parteneri vp
WHERE {period_condition}
AND (
(:partner_type = 'CLIENTI' AND vp.cont IN ('4111', '461'))
OR
(:partner_type = 'FURNIZORI' AND vp.cont IN ('401', '404', '462'))
)
"""
params = {'partner_type': filter_params.partner_type}
# Add period params if using explicit period
if use_param_period:
params['an'] = filter_params.an
params['luna'] = filter_params.luna
if filter_params.partner_name:
base_query += " AND UPPER(vp.nume) LIKE UPPER(:partner_name)"
params['partner_name'] = f"%{filter_params.partner_name}%"
if filter_params.cont:
base_query += " AND vp.cont = :cont"
params['cont'] = filter_params.cont
if filter_params.min_amount:
base_query += " AND total_facturat >= :min_amount"
params['min_amount'] = filter_params.min_amount
if filter_params.max_amount:
base_query += " AND total_facturat <= :max_amount"
params['max_amount'] = filter_params.max_amount
if filter_params.only_unpaid:
# Nu putem folosi aliasul "sold" în WHERE în Oracle, trebuie să repetăm calculul
base_query += """ AND (
CASE
WHEN vp.CONT IN ('4111','461') THEN
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT)
WHEN vp.CONT IN ('401','404','462') THEN
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT)
END
) > 0"""
# Count total pentru paginare
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Query pentru TOTAL SOLD din TOATE facturile filtrate (nu doar pagina curentă)
total_sold_query = f"""
SELECT NVL(SUM(sold), 0) as total_sold
FROM ({base_query})
"""
cursor.execute(total_sold_query, params)
total_sold_result = cursor.fetchone()
total_sold_all = Decimal(str(total_sold_result[0])) if total_sold_result else Decimal('0.00')
# Get accounting period - use params if provided, else from calendar
if use_param_period:
accounting_period = {
'an': filter_params.an,
'luna': filter_params.luna
}
else:
period_query = f"""
SELECT anul, luna
FROM {schema}.calendar
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar)
"""
cursor.execute(period_query)
period_result = cursor.fetchone()
accounting_period = {
'an': period_result[0] if period_result else None,
'luna': period_result[1] if period_result else None
}
# Adaugă ORDER BY și paginare - Ordonare cronologică (DATAACT, NRACT, NUME)
base_query += " ORDER BY vp.DATAACT ASC, vp.NRACT ASC, vp.NUME"
# Paginare Oracle
offset = (filter_params.page - 1) * filter_params.page_size
limit = offset + filter_params.page_size
paginated_query = f"""
SELECT * FROM (
SELECT ROWNUM as rn, t.* FROM ({base_query}) t WHERE ROWNUM <= :limit
) WHERE rn > :offset
"""
params['offset'] = offset
params['limit'] = limit
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# Procesează rezultatele cu structura nouă
invoices = []
total_amount = Decimal('0.00')
for row in rows:
# Skip ROWNUM, extrage valorile din query-ul nou
nume = row[1]
nract = row[2]
dataact = row[3]
datascad = row[4]
contract = row[5]
cod_fiscal = row[6]
reg_comert = row[7]
total_facturat = Decimal(str(row[8] or 0))
achitat = Decimal(str(row[9] or 0))
sold = Decimal(str(row[10] or 0))
cont = row[11]
valuta = row[12] or 'RON'
status = row[13]
invoice_data = {
'nume': nume or '',
'nract': nract or 0,
'dataact': dataact,
'datascad': datascad,
'contract': contract,
'cod_fiscal': cod_fiscal,
'reg_comert': reg_comert,
'cont': cont,
'totctva': total_facturat,
'achitat': achitat,
'soldfinal': sold,
'valuta': valuta
}
invoice = Invoice(**invoice_data)
invoices.append(invoice)
total_amount += total_facturat
return InvoiceListResponse(
invoices=invoices,
total_count=total_count,
filtered_count=len(invoices),
total_amount=total_amount,
page=filter_params.page,
page_size=filter_params.page_size,
has_more=len(invoices) == filter_params.page_size,
accounting_period=accounting_period,
# Total sold din TOATE facturile filtrate
total_sold_all=total_sold_all
)
@staticmethod
async def get_invoice_details(company: str, invoice_number: str, username: str) -> Invoice:
"""
Obține detaliile unei facturi specifice
"""
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține schema din v_nom_firme bazat pe id_firma
company_id = int(company)
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 nu a fost găsită pentru id_firma {company_id}")
schema = schema_result[0]
# Query simplu pentru detalii factură
detail_query = f"""
SELECT
NUME,
NRACT,
DATAACT,
DATASCAD,
CONTRACT,
COD_FISCAL,
REG_COMERT,
PRECDEB,
PRECCRED,
DEBIT,
CREDIT,
CONT
FROM {schema}.vireg_parteneri
WHERE nract = :invoice_number
AND an = (select anul from {schema}.calendar where anul*12+luna = (select max(anul*12+luna) as anmax from {schema}.calendar))
AND luna = (select luna from {schema}.calendar where anul*12+luna = (select max(anul*12+luna) as anmax from {schema}.calendar))
"""
cursor.execute(detail_query, {'invoice_number': invoice_number})
row = cursor.fetchone()
if not row:
raise ValueError(f"Factura {invoice_number} nu a fost găsită")
# Extrage valorile
nume = row[0]
nract = row[1]
dataact = row[2]
datascad = row[3]
contract = row[4]
cod_fiscal = row[5]
reg_comert = row[6]
precdeb = Decimal(str(row[7] or 0))
preccred = Decimal(str(row[8] or 0))
debit = Decimal(str(row[9] or 0))
credit = Decimal(str(row[10] or 0))
cont = row[11]
# Calculează valorile în funcție de tipul contului
if cont in ('4111', '461'): # CLIENTI
totctva = precdeb + debit
achitat = preccred + credit
soldfinal = precdeb - preccred + debit - credit
else: # FURNIZORI
totctva = preccred + credit
achitat = precdeb + debit
soldfinal = preccred - precdeb + credit - debit
invoice_data = {
'nume': nume or '',
'nract': nract or 0,
'dataact': dataact,
'datascad': datascad,
'contract': contract,
'cod_fiscal': cod_fiscal,
'reg_comert': reg_comert,
'totctva': totctva,
'achitat': achitat,
'soldfinal': soldfinal
}
return Invoice(**invoice_data)

View File

@@ -0,0 +1,410 @@
# import sys # Removed - no longer needed
import os
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
from typing import Optional, List, Tuple, Any
import logging
logger = logging.getLogger(__name__)
class TreasuryService:
"""Service pentru trezorerie - registru casă și bancă"""
@staticmethod
@cached(cache_type='schema', key_params=['company_id'])
async def _get_schema(company_id: int) -> str:
"""Obține schema pentru company_id (CACHED PERMANENT)"""
async with oracle_pool.get_connection() 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'])
async def get_bank_cash_register(filter_params: RegisterFilter, username: str) -> 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)
async with oracle_pool.get_connection() 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'])
async def get_bank_cash_accounts(company_id: int, register_type: str) -> 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)
# 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() 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

View File

@@ -0,0 +1,217 @@
"""
Service pentru Trial Balance (Balanță de Verificare) - Query VBAL VIEW
Refactored to use caching system for optimal performance
"""
# import sys # Removed - no longer needed
import os
from shared.database.oracle_pool import oracle_pool
from typing import Dict, Any
from ..models.trial_balance import (
TrialBalanceItem,
TrialBalanceFilters,
TrialBalancePagination,
TrialBalanceResponse
)
from ..cache.decorators import cached
from decimal import Decimal
import math
import logging
logger = logging.getLogger(__name__)
class TrialBalanceService:
"""Service pentru gestionarea balanței de verificare cu cache"""
@staticmethod
@cached(cache_type='schema', key_params=['company_id'])
async def _get_schema(company_id: int) -> str:
"""
Obține schema pentru company_id (CACHED 24h)
This is cached permanently because company schemas rarely change.
"""
async with oracle_pool.get_connection() 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
@cached(cache_type='trial_balance', key_params=['company_id', 'luna', 'an', 'cont_filter',
'denumire_filter', 'sort_by', 'sort_order',
'page', 'page_size', 'username'])
async def get_trial_balance(
company_id: int,
luna: int,
an: int,
cont_filter: str | None,
denumire_filter: str | None,
sort_by: str,
sort_order: str,
page: int,
page_size: int,
username: str
) -> Dict[str, Any]:
"""
Obține balanța de verificare sintetică (CACHED 10 min)
Cache key includes all filter parameters to ensure unique cache entries
for different query variations.
Args:
company_id: ID firmei
luna: Luna (1-12)
an: Anul
cont_filter: Filtru număr cont (optional)
denumire_filter: Filtru denumire cont (optional)
sort_by: Coloană pentru sortare
sort_order: Ordinea sortării (asc/desc)
page: Pagina
page_size: Mărimea paginii
username: Username pentru cache tracking
Returns:
Dictionary cu items, pagination, filters_applied
"""
# Get schema (cached separately)
schema = await TrialBalanceService._get_schema(company_id)
# Validate sort_order
if sort_order.lower() not in ['asc', 'desc']:
sort_order = 'asc'
# Validate sort_by (prevent SQL injection)
valid_sort_columns = ['CONT', 'DENUMIRE', 'PRECDEB', 'PRECCRED',
'RULDEB', 'RULCRED', 'SOLDDEB', 'SOLDCRED']
if sort_by.upper() not in valid_sort_columns:
sort_by = 'CONT'
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Build base query for VBAL VIEW
base_query = f"""
SELECT
CONT,
NVL(DENUMIRE, '') as DENUMIRE,
NVL(PRECDEB, 0) as PRECDEB,
NVL(PRECCRED, 0) as PRECCRED,
NVL(RULDEB, 0) as RULDEB,
NVL(RULCRED, 0) as RULCRED,
NVL(SOLDDEB, 0) as SOLDDEB,
NVL(SOLDCRED, 0) as SOLDCRED
FROM {schema}.VBAL
WHERE AN = :an
AND LUNA = :luna
"""
params = {
'an': an,
'luna': luna
}
# Add dynamic filters
if cont_filter:
base_query += " AND CONT LIKE :cont_filter"
params['cont_filter'] = f"{cont_filter}%"
if denumire_filter:
base_query += " AND UPPER(DENUMIRE) LIKE UPPER(:denumire_filter)"
params['denumire_filter'] = f"%{denumire_filter}%"
# Count total for pagination
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Query pentru TOTALURI din TOATE înregistrările filtrate (nu doar pagina curentă)
totals_query = f"""
SELECT
NVL(SUM(PRECDEB), 0) as total_prec_deb,
NVL(SUM(PRECCRED), 0) as total_prec_cred,
NVL(SUM(RULDEB), 0) as total_rul_deb,
NVL(SUM(RULCRED), 0) as total_rul_cred,
NVL(SUM(SOLDDEB), 0) as total_sold_deb,
NVL(SUM(SOLDCRED), 0) as total_sold_cred
FROM ({base_query})
"""
cursor.execute(totals_query, params)
totals_row = cursor.fetchone()
totals = {
"total_sold_precedent_debit": Decimal(str(totals_row[0])) if totals_row else Decimal('0.00'),
"total_sold_precedent_credit": Decimal(str(totals_row[1])) if totals_row else Decimal('0.00'),
"total_rulaj_lunar_debit": Decimal(str(totals_row[2])) if totals_row else Decimal('0.00'),
"total_rulaj_lunar_credit": Decimal(str(totals_row[3])) if totals_row else Decimal('0.00'),
"total_sold_final_debit": Decimal(str(totals_row[4])) if totals_row else Decimal('0.00'),
"total_sold_final_credit": Decimal(str(totals_row[5])) if totals_row else Decimal('0.00')
}
# Add sorting
base_query += f" ORDER BY {sort_by.upper()} {sort_order.upper()}"
# Pagination (Oracle ROWNUM with ORDER BY)
offset = (page - 1) * page_size
limit = offset + page_size
paginated_query = f"""
SELECT * FROM (
SELECT a.*, ROWNUM rnum FROM (
{base_query}
) a WHERE ROWNUM <= :limit
) WHERE rnum > :offset
"""
params['offset'] = offset
params['limit'] = limit
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# Process results
# Index: CONT(0), DENUMIRE(1), PRECDEB(2), PRECCRED(3),
# RULDEB(4), RULCRED(5), SOLDDEB(6), SOLDCRED(7), rnum(8)
items = []
for row in rows:
item = TrialBalanceItem(
cont=row[0] or '',
denumire=row[1] or '',
sold_precedent_debit=Decimal(str(row[2] or 0)),
sold_precedent_credit=Decimal(str(row[3] or 0)),
rulaj_lunar_debit=Decimal(str(row[4] or 0)),
rulaj_lunar_credit=Decimal(str(row[5] or 0)),
sold_final_debit=Decimal(str(row[6] or 0)),
sold_final_credit=Decimal(str(row[7] or 0))
)
items.append(item.dict())
# Calculate pagination
total_pages = math.ceil(total_count / page_size) if page_size > 0 else 0
# Build response
return {
"items": items,
"pagination": {
"total_items": total_count,
"total_pages": total_pages,
"current_page": page,
"page_size": page_size
},
"filters_applied": {
"luna": luna,
"an": an,
"cont_filter": cont_filter,
"denumire_filter": denumire_filter
},
# Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă)
"totals": totals
}