fix: Add bank/cash name sorting to treasury register

- Add bancasa as third sorting criterion (date, number, bank name)
- Sort null-date rows (previous balances) alphabetically by bank name
- Sort bank names alphabetically in PDF export
- Improve window function for cumulative balance calculation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-05 14:13:30 +02:00
parent eb3dc195ed
commit 615593eb40
7 changed files with 1596 additions and 334 deletions

View File

@@ -3,25 +3,32 @@ from decimal import Decimal
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
class AccountingPeriod(BaseModel):
"""Model pentru perioada contabilă"""
an: Optional[int] = None
luna: Optional[int] = None
class BankCashRegister(BaseModel): class BankCashRegister(BaseModel):
"""Model pentru Registrul de Casă și Bancă""" """Model pentru Registrul de Casă și Bancă"""
nume: str nume: str
nract: int nract: Optional[int] = None
dataact: datetime dataact: Optional[datetime] = None
nume_cont_bancar: str # din vbalanta_parteneri.nume nume_cont_bancar: str # din vbalanta_parteneri.nume
incasari: Decimal incasari: Decimal
plati: Decimal plati: Decimal
sold: Decimal sold: Decimal
valuta: str valuta: Optional[str] = None
tip_registru: str # "BANCA LEI", "CASA VALUTA" etc tip_registru: str # "BANCA LEI", "CASA VALUTA" etc
explicatia: str explicatia: str
class RegisterFilter(BaseModel): class RegisterFilter(BaseModel):
"""Filtre pentru registrul de casă și bancă""" """Filtre pentru registrul de casă și bancă"""
company: str company: str
register_type: Optional[str] = None # BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate
date_from: Optional[datetime] = None date_from: Optional[datetime] = None
date_to: Optional[datetime] = None date_to: Optional[datetime] = None
partner_name: Optional[str] = None partner_name: Optional[str] = None
bank_account: Optional[str] = None # Filter for specific bank/cash account (bancasa)
page: int = 1 page: int = 1
page_size: int = 50 page_size: int = 50
@@ -34,4 +41,5 @@ class RegisterListResponse(BaseModel):
total_plati: Decimal total_plati: Decimal
page: int page: int
page_size: int page_size: int
has_more: bool has_more: bool
accounting_period: Optional[AccountingPeriod] = None

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional from typing import Optional, List
from datetime import date from datetime import date
import sys import sys
import os import os
@@ -15,53 +15,100 @@ router = APIRouter()
@router.get("/bank-cash-register", response_model=RegisterListResponse) @router.get("/bank-cash-register", response_model=RegisterListResponse)
async def get_bank_cash_register( async def get_bank_cash_register(
company: str = Query(description="Codul firmei"), company: str = Query(description="Codul firmei"),
register_type: Optional[str] = Query(None, description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate"),
date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"), date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"), partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
bank_account: Optional[str] = Query(None, description="Filtru cont bancă/casă (bancasa)"),
page: int = Query(1, ge=1, description="Pagina"), page: int = Query(1, ge=1, description="Pagina"),
page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"), page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user) current_user: CurrentUser = Depends(get_current_user)
): ):
""" """
Obține registrul de casă și bancă Obține registrul de casă și bancă
- Necesită autentificare JWT - Necesită autentificare JWT
- Suportă filtrare pe tip registru: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA
- Suportă filtrare și paginare - Suportă filtrare și paginare
""" """
try: try:
# Verifică dacă utilizatorul are acces la firma specificată # Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies: if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Validează register_type dacă e specificat
valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA']
if register_type and register_type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}"
)
# Convertește datele # Convertește datele
date_from_obj = None date_from_obj = None
date_to_obj = None date_to_obj = None
if date_from: if date_from:
try: try:
date_from_obj = date.fromisoformat(date_from) date_from_obj = date.fromisoformat(date_from)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Format dată început invalid") raise HTTPException(status_code=400, detail="Format dată început invalid")
if date_to: if date_to:
try: try:
date_to_obj = date.fromisoformat(date_to) date_to_obj = date.fromisoformat(date_to)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Format dată sfârșit invalid") raise HTTPException(status_code=400, detail="Format dată sfârșit invalid")
filter_params = RegisterFilter( filter_params = RegisterFilter(
company=company, company=company,
register_type=register_type,
date_from=date_from_obj, date_from=date_from_obj,
date_to=date_to_obj, date_to=date_to_obj,
partner_name=partner_name, partner_name=partner_name,
bank_account=bank_account,
page=page, page=page,
page_size=page_size page_size=page_size
) )
result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username) result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username)
return result return result
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea registrului: {str(e)}") raise HTTPException(status_code=500, detail=f"Eroare la obținerea registrului: {str(e)}")
@router.get("/bank-cash-accounts", response_model=List[str])
async def get_bank_cash_accounts(
company: str = Query(description="Codul firmei"),
register_type: str = Query(description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține lista distinctă de conturi bancă/casă pentru dropdown
- Necesită autentificare JWT
- Returnează lista de valori bancasa pentru tipul de registru selectat
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Validează register_type
valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA']
if register_type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}"
)
result = await TreasuryService.get_bank_cash_accounts(int(company), register_type)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea conturilor: {str(e)}")

View File

@@ -2,10 +2,12 @@ import sys
import os import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared')) sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
import oracledb
from database.oracle_pool import oracle_pool from database.oracle_pool import oracle_pool
from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse, AccountingPeriod
from ..cache.decorators import cached from ..cache.decorators import cached
from decimal import Decimal from decimal import Decimal
from typing import Optional, List, Tuple, Any
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,137 +34,229 @@ class TreasuryService:
return schema_result[0] 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 @staticmethod
@cached(cache_type='treasury', key_params=['filter_params', 'username']) @cached(cache_type='treasury', key_params=['filter_params', 'username'])
async def get_bank_cash_register(filter_params: RegisterFilter, username: str) -> RegisterListResponse: 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) 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) company_id = int(filter_params.company)
schema = await TreasuryService._get_schema(company_id) schema = await TreasuryService._get_schema(company_id)
async with oracle_pool.get_connection() as connection: async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
# Query pentru registrele de bancă și casă # Construiește query-ul pentru tipul de registru selectat
union_queries = [] base_select = TreasuryService._get_view_query(schema, filter_params.register_type)
# BANCA LEI (5121) # Construiește WHERE conditions
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasari, vb.plati,
vb.incasari - vb.plati as sold,
'RON' as valuta,
'BANCA LEI' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5121_cum vb
WHERE (vb.incasari > 0 OR vb.plati > 0)
""")
# BANCA VALUTA (5124)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasval, vb.platival,
vb.incasval - vb.platival as sold,
COALESCE(vb.numeval, 'EUR') as valuta,
'BANCA VALUTA' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5124_cum vb
WHERE (vb.incasval > 0 OR vb.platival > 0)
""")
# CASA LEI (5311)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasari, vb.plati,
vb.incasari - vb.plati as sold,
'RON' as valuta,
'CASA LEI' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5311_cum vb
WHERE (vb.incasari > 0 OR vb.plati > 0)
""")
# CASA VALUTA (5314)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasval, vb.platival,
vb.incasval - vb.platival as sold,
COALESCE(vb.numeval, 'EUR') as valuta,
'CASA VALUTA' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5314_cum vb
WHERE (vb.incasval > 0 OR vb.platival > 0)
""")
base_query = " UNION ALL ".join(union_queries)
params = {}
where_conditions = [] where_conditions = []
if filter_params.date_from: # Date filter preserves NULL-date rows (opening balance)
where_conditions.append("dataact >= :date_from") # for correct cumulative sum calculation
params['date_from'] = filter_params.date_from 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')))")
if filter_params.date_to: elif filter_params.date_from:
where_conditions.append("dataact <= :date_to") where_conditions.append(f"(dataact IS NULL OR dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))")
params['date_to'] = filter_params.date_to 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: if filter_params.partner_name:
where_conditions.append("UPPER(nume) LIKE UPPER(:partner_name)") # Escape single quotes pentru SQL
params['partner_name'] = f"%{filter_params.partner_name}%" 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: if where_conditions:
base_query = f"SELECT * FROM ({base_query}) WHERE {' AND '.join(where_conditions)}" where_clause = " WHERE " + " AND ".join(where_conditions)
# Count pentru paginare # Paginare Oracle
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Query cu paginare
base_query += " ORDER BY dataact DESC, nract"
offset = (filter_params.page - 1) * filter_params.page_size offset = (filter_params.page - 1) * filter_params.page_size
limit = offset + filter_params.page_size limit_val = filter_params.page_size
paginated_query = f"""
SELECT * FROM ( # Bloc PL/SQL anonim care face totul într-o singură tranzacție:
SELECT ROWNUM as rn, t.* FROM ({base_query}) t WHERE ROWNUM <= :limit # 1. Obține anul și luna din calendar
) WHERE rn > :offset # 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 curentă din calendar
SELECT anul, luna INTO v_an, v_luna
FROM {schema}.calendar
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar);
-- 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;
""" """
params['offset'] = offset
params['limit'] = limit # Creează cursor pentru rezultate (oracledb.CURSOR pentru REF CURSOR)
result_cursor = cursor.var(oracledb.CURSOR)
cursor.execute(paginated_query, params) out_an = cursor.var(int)
rows = cursor.fetchall() out_luna = cursor.var(int)
# Execută blocul PL/SQL cu REF CURSOR
cursor.execute(plsql_block, {'result_cursor': result_cursor, 'out_an': out_an, 'out_luna': out_luna})
# 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
SELECT anul, luna INTO v_an, v_luna
FROM {schema}.calendar
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar);
{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)
cursor.execute(count_plsql, {'total_count': total_count_var})
total_count = total_count_var.getvalue()
# Procesare rezultate # Procesare rezultate
registers = [] registers = []
total_incasari = Decimal('0.00') total_incasari = Decimal('0.00')
total_plati = Decimal('0.00') total_plati = Decimal('0.00')
for row in rows: for row in rows:
# Skip ROWNUM # 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( register_data = BankCashRegister(
nume=row[1] or '', nume=row[0] or '',
nract=row[2] or 0, nract=row[1],
dataact=row[3], dataact=row[2],
nume_cont_bancar=row[4] or '', nume_cont_bancar=row[3] or '',
incasari=Decimal(str(row[5] or 0)), incasari=Decimal(str(row[4] or 0)),
plati=Decimal(str(row[6] or 0)), plati=Decimal(str(row[5] or 0)),
sold=Decimal(str(row[7] or 0)), sold=Decimal(str(row[6] or 0)),
valuta=row[8], valuta=row[7],
tip_registru=row[9], tip_registru=row[8],
explicatia=row[10] or '' explicatia=row[9] or ''
) )
registers.append(register_data) registers.append(register_data)
total_incasari += register_data.incasari total_incasari += register_data.incasari
total_plati += register_data.plati 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( return RegisterListResponse(
registers=registers, registers=registers,
total_count=total_count, total_count=total_count,
@@ -171,5 +265,66 @@ class TreasuryService:
total_plati=total_plati, total_plati=total_plati,
page=filter_params.page, page=filter_params.page,
page_size=filter_params.page_size, page_size=filter_params.page_size,
has_more=len(registers) == filter_params.page_size has_more=len(registers) == filter_params.page_size,
) accounting_period=AccountingPeriod(an=accounting_year, luna=accounting_month)
)
@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

@@ -15,6 +15,7 @@ export const useTreasuryStore = defineStore("treasury", () => {
total_incasari: 0, total_incasari: 0,
total_plati: 0, total_plati: 0,
}); });
const accountingPeriod = ref({ an: null, luna: null });
const loadBankCashRegister = async (companyId, filters = {}) => { const loadBankCashRegister = async (companyId, filters = {}) => {
isLoading.value = true; isLoading.value = true;
@@ -39,6 +40,11 @@ export const useTreasuryStore = defineStore("treasury", () => {
total_plati: response.data.total_plati, total_plati: response.data.total_plati,
}; };
// Store accounting period if available
if (response.data.accounting_period) {
accountingPeriod.value = response.data.accounting_period;
}
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
error.value = err.response?.data?.detail || "Failed to load register"; error.value = err.response?.data?.detail || "Failed to load register";
@@ -57,6 +63,7 @@ export const useTreasuryStore = defineStore("treasury", () => {
registers.value = []; registers.value = [];
isLoading.value = false; isLoading.value = false;
error.value = null; error.value = null;
accountingPeriod.value = { an: null, luna: null };
pagination.value = { pagination.value = {
page: 0, page: 0,
rows: 50, rows: 50,
@@ -70,6 +77,7 @@ export const useTreasuryStore = defineStore("treasury", () => {
error, error,
pagination, pagination,
totals, totals,
accountingPeriod,
loadBankCashRegister, loadBankCashRegister,
setPagination, setPagination,
reset, reset,

View File

@@ -55,7 +55,7 @@ const formatNumberForPDF = (value) => {
* @param {Array} data - Array of objects to export * @param {Array} data - Array of objects to export
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'text|number|currency', width: 30}] * @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'text|number|currency', width: 30}]
* @param {String} filename - Name of the file (without extension) * @param {String} filename - Name of the file (without extension)
* @param {Object} header - Header configuration {companyName: '', title: '', period: ''} * @param {Object} header - Header configuration {companyName: '', title: '', period: '', subtitle2: '', initialBalances: [], totalInitialBalance: 0}
*/ */
export const exportToPDF = (data, columns, filename, header) => { export const exportToPDF = (data, columns, filename, header) => {
try { try {
@@ -78,6 +78,9 @@ export const exportToPDF = (data, columns, filename, header) => {
const marginRight = 8; const marginRight = 8;
const contentWidth = pageWidth - marginLeft - marginRight; const contentWidth = pageWidth - marginLeft - marginRight;
// Check if there are initial balances to display
const hasInitialBalances = header.initialBalances && header.initialBalances.length > 0;
// Function to add header (called for each page) // Function to add header (called for each page)
const addHeader = () => { const addHeader = () => {
// Line 1: Company name (left aligned, bold, larger font) // Line 1: Company name (left aligned, bold, larger font)
@@ -100,12 +103,31 @@ export const exportToPDF = (data, columns, filename, header) => {
const periodWidth = doc.getTextWidth(periodText); const periodWidth = doc.getTextWidth(periodText);
const periodX = marginLeft + (contentWidth - periodWidth) / 2; const periodX = marginLeft + (contentWidth - periodWidth) / 2;
doc.text(periodText, periodX, 32); doc.text(periodText, periodX, 32);
// Line 4: Subtitle2 - filters (left aligned, below period) - optional
let currentY = 32;
if (header.subtitle2) {
currentY = 39;
doc.setFontSize(10);
doc.setFont(undefined, "normal");
doc.text(header.subtitle2, marginLeft, currentY);
}
// Initial Balances section - rendered just before table, closer to it
// This is handled in didDrawPage for first page only
}; };
// Prepare table data // Prepare table data and track total rows
const tableColumns = columns.map((col) => col.header); const tableColumns = columns.map((col) => col.header);
const tableRows = data.map((row) => const totalRowIndices = new Set(); // Track which rows are totals
columns.map((col) => {
const tableRows = data.map((row, rowIndex) => {
// Track total rows for special styling
if (row._isTotal) {
totalRowIndices.add(rowIndex);
}
return columns.map((col) => {
const value = row[col.field]; const value = row[col.field];
if (col.type === "currency") { if (col.type === "currency") {
return formatCurrency(value); return formatCurrency(value);
@@ -113,8 +135,8 @@ export const exportToPDF = (data, columns, filename, header) => {
return formatNumberForPDF(value); return formatNumberForPDF(value);
} }
return value || "-"; return value || "-";
}), });
); });
// Function to add footer (called for each page) // Function to add footer (called for each page)
const addFooter = (pageNum, totalPages) => { const addFooter = (pageNum, totalPages) => {
@@ -183,7 +205,64 @@ export const exportToPDF = (data, columns, filename, header) => {
} }
}); });
const tableStartY = 36; // Start table lower based on header content
let tableStartY = 36;
if (header.subtitle2) tableStartY = 43;
if (hasInitialBalances) {
// Initial balances rendered close to table (just 3mm above table header)
const balancesCount = header.initialBalances.length;
const hasTotal = balancesCount > 1;
const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0);
// Base position after header content
const baseY = header.subtitle2 ? 43 : 36;
tableStartY = baseY + balancesHeight + 5; // balances + small gap before table
}
// Function to draw initial balances (called only on first page)
const drawInitialBalances = (tableY) => {
if (!hasInitialBalances) return;
const valueRightEdge = pageWidth - marginRight;
const balancesCount = header.initialBalances.length;
const hasTotal = balancesCount > 1;
const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0);
// Start position: just above table header (3mm gap)
let y = tableY - 3 - (hasTotal ? 7 : 0) - (balancesCount * 5);
doc.setFont(undefined, "normal");
doc.setFontSize(9);
// Draw each balance line: "AccountName sold precedent: VALUE"
header.initialBalances.forEach((item) => {
const value = formatNumberForPDF(item.sold);
const valueWidth = doc.getTextWidth(value);
const label = `${item.accountName} sold precedent:`;
doc.text(label, valueRightEdge - valueWidth - doc.getTextWidth(" sold precedent:") - doc.getTextWidth(item.accountName) - 2, y);
doc.text(value, valueRightEdge - valueWidth, y);
y += 5;
});
// Total only if multiple accounts
if (hasTotal) {
// Separator line
doc.setDrawColor(150, 150, 150);
doc.line(valueRightEdge - 40, y - 2, valueRightEdge, y - 2);
// Total line
doc.setFont(undefined, "bold");
const totalValue = formatNumberForPDF(header.totalInitialBalance || 0);
const totalValueWidth = doc.getTextWidth(totalValue);
const totalLabel = "TOTAL sold precedent:";
const totalLabelWidth = doc.getTextWidth(totalLabel);
doc.text(totalLabel, valueRightEdge - totalValueWidth - 3 - totalLabelWidth, y + 2);
doc.text(totalValue, valueRightEdge - totalValueWidth, y + 2);
}
};
let isFirstPage = true;
// Add table using autoTable (call as function, not method) // Add table using autoTable (call as function, not method)
autoTable(doc, { autoTable(doc, {
@@ -218,16 +297,28 @@ export const exportToPDF = (data, columns, filename, header) => {
}, },
tableWidth: pageWidth - marginLeft - marginRight, // Use full page width tableWidth: pageWidth - marginLeft - marginRight, // Use full page width
theme: "grid", theme: "grid",
didDrawPage: function (data) { didDrawPage: function () {
// Add header to each page // Add header to each page
addHeader(); addHeader();
// Draw initial balances only on first page
if (isFirstPage && hasInitialBalances) {
drawInitialBalances(tableStartY);
isFirstPage = false;
}
}, },
didParseCell: function (data) { didParseCell: function (data) {
// Force alignment based on column type (body cells only) // Force alignment based on column type (body cells only)
if (data.section === "body") { if (data.section === "body") {
const rowIndex = data.row.index;
const colIndex = data.column.index; const colIndex = data.column.index;
const column = columns[colIndex]; const column = columns[colIndex];
// Style total rows differently (bold, light gray background)
if (totalRowIndices.has(rowIndex)) {
data.cell.styles.fontStyle = "bold";
data.cell.styles.fillColor = [230, 230, 230]; // Light gray
}
if (column) { if (column) {
if (column.type === "number" || column.type === "currency") { if (column.type === "number" || column.type === "currency") {
data.cell.styles.halign = "right"; data.cell.styles.halign = "right";
@@ -351,6 +442,375 @@ export const exportSoldNetBreakdown = (summaryData) => {
return data; return data;
}; };
/**
* Export Bank Cash Register to PDF with grouped format
* Matches the Romanian standard format with:
* - Bank name + Sold precedent on same line
* - Daily totals (Total zi)
* - Cumulative totals (Total cumulat)
*
* @param {Array} data - Array of register entries
* @param {Object} header - Header configuration
* @param {String} filename - Output filename
*/
export const exportBankCashRegisterPDF = (data, header, filename) => {
try {
if (!data || data.length === 0) {
console.error("No data to export");
return { success: false, error: "No data available" };
}
const doc = new jsPDF("landscape", "mm", "a4");
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const marginLeft = 8;
const marginRight = 8;
const contentWidth = pageWidth - marginLeft - marginRight;
// Remove diacritics helper
const removeDiacritics = (text) => {
if (!text) return "";
return text
.replace(/[ăâ]/gi, (m) => (m === m.toLowerCase() ? "a" : "A"))
.replace(/[î]/gi, (m) => (m === m.toLowerCase() ? "i" : "I"))
.replace(/[ș]/gi, (m) => (m === m.toLowerCase() ? "s" : "S"))
.replace(/[ț]/gi, (m) => (m === m.toLowerCase() ? "t" : "T"));
};
// Group data by bank account (bancasa)
const groupedByBank = {};
const initialBalances = {};
data.forEach((row) => {
const bankName = row.nume_cont_bancar || "Necunoscut";
if (!groupedByBank[bankName]) {
groupedByBank[bankName] = [];
initialBalances[bankName] = 0;
}
if (!row.dataact) {
// Initial balance row (null date) - sold precedent
initialBalances[bankName] = parseFloat(row.sold) || 0;
} else {
// Transaction row with date
groupedByBank[bankName].push(row);
}
});
// Table columns definition
const tableColumns = [
"Data act",
"Nr.act",
"Explicatia",
"Incasari",
"Plati",
"Sold",
];
const columnWidths = {
0: contentWidth * 0.10, // Data act
1: contentWidth * 0.08, // Nr.act
2: contentWidth * 0.42, // Explicatia
3: contentWidth * 0.13, // Incasari
4: contentWidth * 0.13, // Plati
5: contentWidth * 0.14, // Sold
};
const columnStyles = {};
Object.keys(columnWidths).forEach((idx) => {
columnStyles[idx] = { cellWidth: columnWidths[idx] };
if (idx >= 3) {
columnStyles[idx].halign = "right";
}
});
let currentY = 15;
let pageNum = 1;
// Function to add page header
const addPageHeader = () => {
// Company name (left)
doc.setFontSize(12);
doc.setFont(undefined, "bold");
doc.text(removeDiacritics(header.companyName || ""), marginLeft, 12);
// Luna: MM / YYYY (right)
doc.setFontSize(10);
doc.setFont(undefined, "normal");
const lunaText = `Luna: ${header.luna || ""} / ${header.an || ""}`;
const lunaWidth = doc.getTextWidth(lunaText);
doc.text(lunaText, pageWidth - marginRight - lunaWidth, 12);
// Title centered
doc.setFontSize(13);
doc.setFont(undefined, "bold");
const titleWidth = doc.getTextWidth(header.title || "");
doc.text(header.title || "", marginLeft + (contentWidth - titleWidth) / 2, 20);
};
// Function to check if we need a new page (for tables spanning multiple pages within a bank)
const checkNewPage = (neededHeight = 20) => {
if (currentY + neededHeight > pageHeight - 15) {
doc.addPage();
pageNum++;
addPageHeader();
currentY = 28;
return true;
}
return false;
};
// Process each bank account - each on a new page (sorted alphabetically)
const bankNames = Object.keys(groupedByBank).sort((a, b) => a.localeCompare(b, 'ro'));
bankNames.forEach((bankName, bankIndex) => {
const bankRows = groupedByBank[bankName];
const soldPrecedent = initialBalances[bankName] || 0;
// Start each bank/casa on a new page (except first one which is already on page 1)
if (bankIndex > 0) {
doc.addPage();
pageNum++;
}
// Add full page header (company, title, luna/an)
addPageHeader();
currentY = 28;
// Bank/Casa header: "Banca: NAME" (left) + "Sold precedent: VALUE" (right)
doc.setFontSize(10);
doc.setFont(undefined, "bold");
const bankLabel = header.isBanca ? "Banca:" : "Casa:";
const bankHeaderText = `${bankLabel} ${removeDiacritics(bankName)}`;
doc.text(bankHeaderText, marginLeft, currentY);
const soldPrecedentText = `Sold precedent: ${formatNumberForPDF(soldPrecedent)}`;
const soldPrecedentWidth = doc.getTextWidth(soldPrecedentText);
doc.text(soldPrecedentText, pageWidth - marginRight - soldPrecedentWidth, currentY);
currentY += 6;
// Handle case when there are no transactions (only initial balance)
if (bankRows.length === 0) {
// Draw empty table with header only
autoTable(doc, {
head: [tableColumns],
body: [],
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
lineColor: [200, 200, 200],
lineWidth: 0.1,
},
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: "bold",
halign: "center",
fontSize: 8,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "grid",
});
currentY = doc.lastAutoTable.finalY;
// Show total with sold precedent (no transactions)
const totalRows = [
[
"",
"",
"Total:",
formatNumberForPDF(0),
formatNumberForPDF(0),
formatNumberForPDF(soldPrecedent),
],
];
const totalsStartY = currentY;
autoTable(doc, {
body: totalRows,
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
fontStyle: "bold",
lineWidth: 0,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "plain",
});
// Draw outer border for totals box
const totalsEndY = doc.lastAutoTable.finalY;
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY);
currentY = doc.lastAutoTable.finalY + 3;
} else {
// Group bank rows by date
const groupedByDate = {};
bankRows.forEach((row) => {
const dateKey = row.dataact;
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = [];
}
groupedByDate[dateKey].push(row);
});
// Cumulative totals for the bank
let cumulativeIncasari = 0;
let cumulativePlati = 0;
let lastSold = soldPrecedent;
const dates = Object.keys(groupedByDate).sort();
dates.forEach((dateKey, dateIndex) => {
const dateRows = groupedByDate[dateKey];
const dateFormatted = dateKey
? new Date(dateKey).toLocaleDateString("ro-RO")
: "";
checkNewPage(30);
// Prepare rows for this date
const tableRows = [];
let dailyIncasari = 0;
let dailyPlati = 0;
dateRows.forEach((row) => {
const incasari = parseFloat(row.incasari) || 0;
const plati = parseFloat(row.plati) || 0;
dailyIncasari += incasari;
dailyPlati += plati;
lastSold = parseFloat(row.sold) || lastSold;
tableRows.push([
dateFormatted,
row.nract || "",
removeDiacritics(row.explicatia || row.nume || ""),
formatNumberForPDF(incasari),
formatNumberForPDF(plati),
formatNumberForPDF(row.sold),
]);
});
cumulativeIncasari += dailyIncasari;
cumulativePlati += dailyPlati;
// Draw table for this date group
autoTable(doc, {
head: dateIndex === 0 ? [tableColumns] : [],
body: tableRows,
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
lineColor: [200, 200, 200],
lineWidth: 0.1,
overflow: "linebreak",
},
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: "bold",
halign: "center",
fontSize: 8,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "grid",
showHead: dateIndex === 0 ? "firstPage" : "never",
});
currentY = doc.lastAutoTable.finalY;
// Daily total + Cumulative total rows in same box
checkNewPage(16);
const totalRows = [
[
"",
"",
`Total zi: ${dateFormatted}`,
formatNumberForPDF(dailyIncasari),
formatNumberForPDF(dailyPlati),
"Sold",
],
[
"",
"",
"Total cumulat:",
formatNumberForPDF(cumulativeIncasari),
formatNumberForPDF(cumulativePlati),
formatNumberForPDF(lastSold),
],
];
const totalsStartY = currentY;
autoTable(doc, {
body: totalRows,
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
fontStyle: "bold",
lineWidth: 0,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "plain",
});
// Draw outer border for totals box (no internal lines)
const totalsEndY = doc.lastAutoTable.finalY;
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY);
currentY = doc.lastAutoTable.finalY + 3;
});
}
});
// Add footer to all pages (Generat: DATE on left, Pagina X din Y on right)
const totalPages = doc.internal.getNumberOfPages();
const generatedText = `Generat: ${new Date().toLocaleString("ro-RO")}`;
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setFont(undefined, "normal");
const footerY = pageHeight - 8;
// Left: Generated date
doc.text(generatedText, marginLeft, footerY);
// Right: Page number
const pageText = `Pagina ${i} din ${totalPages}`;
const pageTextWidth = doc.getTextWidth(pageText);
doc.text(pageText, pageWidth - marginRight - pageTextWidth, footerY);
}
doc.save(`${filename}_${new Date().toISOString().split("T")[0]}.pdf`);
return { success: true };
} catch (error) {
console.error("Bank Cash Register PDF export error:", error);
return { success: false, error: error.message || "PDF generation failed" };
}
};
/** /**
* Export Trend Data * Export Trend Data
*/ */

File diff suppressed because it is too large Load Diff

View File

@@ -797,7 +797,15 @@ const searchData = () => {
} }
}; };
// Watchers - removed unused watchers // Watch for company changes - reload dashboard when company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await loadDashboardData();
}
},
);
// Lifecycle // Lifecycle
onMounted(async () => { onMounted(async () => {