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 typing import Optional, List
class AccountingPeriod(BaseModel):
"""Model pentru perioada contabilă"""
an: Optional[int] = None
luna: Optional[int] = None
class BankCashRegister(BaseModel):
"""Model pentru Registrul de Casă și Bancă"""
nume: str
nract: int
dataact: datetime
nract: Optional[int] = None
dataact: Optional[datetime] = None
nume_cont_bancar: str # din vbalanta_parteneri.nume
incasari: Decimal
plati: Decimal
sold: Decimal
valuta: str
valuta: Optional[str] = None
tip_registru: str # "BANCA LEI", "CASA VALUTA" etc
explicatia: str
class RegisterFilter(BaseModel):
"""Filtre pentru registrul de casă și bancă"""
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_to: Optional[datetime] = None
partner_name: Optional[str] = None
bank_account: Optional[str] = None # Filter for specific bank/cash account (bancasa)
page: int = 1
page_size: int = 50
@@ -34,4 +41,5 @@ class RegisterListResponse(BaseModel):
total_plati: Decimal
page: 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 typing import Optional
from typing import Optional, List
from datetime import date
import sys
import os
@@ -15,53 +15,100 @@ router = APIRouter()
@router.get("/bank-cash-register", response_model=RegisterListResponse)
async def get_bank_cash_register(
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_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
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_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține registrul de casă și bancă
- Necesită autentificare JWT
- Suportă filtrare pe tip registru: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA
- Suportă filtrare și paginare
"""
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 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
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = date.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Format dată început invalid")
if date_to:
try:
date_to_obj = date.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Format dată sfârșit invalid")
filter_params = RegisterFilter(
company=company,
register_type=register_type,
date_from=date_from_obj,
date_to=date_to_obj,
partner_name=partner_name,
bank_account=bank_account,
page=page,
page_size=page_size
)
result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username)
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 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
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
import oracledb
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 decimal import Decimal
from typing import Optional, List, Tuple, Any
import logging
logger = logging.getLogger(__name__)
@@ -32,137 +34,229 @@ class TreasuryService:
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:
# Query pentru registrele de bancă și casă
union_queries = []
# BANCA LEI (5121)
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 = {}
# 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 = []
if filter_params.date_from:
where_conditions.append("dataact >= :date_from")
params['date_from'] = filter_params.date_from
if filter_params.date_to:
where_conditions.append("dataact <= :date_to")
params['date_to'] = filter_params.date_to
# 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:
where_conditions.append("UPPER(nume) LIKE UPPER(:partner_name)")
params['partner_name'] = f"%{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:
base_query = f"SELECT * FROM ({base_query}) WHERE {' AND '.join(where_conditions)}"
# Count pentru paginare
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"
where_clause = " WHERE " + " AND ".join(where_conditions)
# 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
limit_val = filter_params.page_size
# Bloc PL/SQL anonim care face totul într-o singură tranzacție:
# 1. Obține anul și luna din 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 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
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# 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)
# 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
registers = []
total_incasari = Decimal('0.00')
total_plati = Decimal('0.00')
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(
nume=row[1] or '',
nract=row[2] or 0,
dataact=row[3],
nume_cont_bancar=row[4] or '',
incasari=Decimal(str(row[5] or 0)),
plati=Decimal(str(row[6] or 0)),
sold=Decimal(str(row[7] or 0)),
valuta=row[8],
tip_registru=row[9],
explicatia=row[10] or ''
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,
@@ -171,5 +265,66 @@ class TreasuryService:
total_plati=total_plati,
page=filter_params.page,
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_plati: 0,
});
const accountingPeriod = ref({ an: null, luna: null });
const loadBankCashRegister = async (companyId, filters = {}) => {
isLoading.value = true;
@@ -39,6 +40,11 @@ export const useTreasuryStore = defineStore("treasury", () => {
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 };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load register";
@@ -57,6 +63,7 @@ export const useTreasuryStore = defineStore("treasury", () => {
registers.value = [];
isLoading.value = false;
error.value = null;
accountingPeriod.value = { an: null, luna: null };
pagination.value = {
page: 0,
rows: 50,
@@ -70,6 +77,7 @@ export const useTreasuryStore = defineStore("treasury", () => {
error,
pagination,
totals,
accountingPeriod,
loadBankCashRegister,
setPagination,
reset,

View File

@@ -55,7 +55,7 @@ const formatNumberForPDF = (value) => {
* @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 {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) => {
try {
@@ -78,6 +78,9 @@ export const exportToPDF = (data, columns, filename, header) => {
const marginRight = 8;
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)
const addHeader = () => {
// 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 periodX = marginLeft + (contentWidth - periodWidth) / 2;
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 tableRows = data.map((row) =>
columns.map((col) => {
const totalRowIndices = new Set(); // Track which rows are totals
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];
if (col.type === "currency") {
return formatCurrency(value);
@@ -113,8 +135,8 @@ export const exportToPDF = (data, columns, filename, header) => {
return formatNumberForPDF(value);
}
return value || "-";
}),
);
});
});
// Function to add footer (called for each page)
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)
autoTable(doc, {
@@ -218,16 +297,28 @@ export const exportToPDF = (data, columns, filename, header) => {
},
tableWidth: pageWidth - marginLeft - marginRight, // Use full page width
theme: "grid",
didDrawPage: function (data) {
didDrawPage: function () {
// Add header to each page
addHeader();
// Draw initial balances only on first page
if (isFirstPage && hasInitialBalances) {
drawInitialBalances(tableStartY);
isFirstPage = false;
}
},
didParseCell: function (data) {
// Force alignment based on column type (body cells only)
if (data.section === "body") {
const rowIndex = data.row.index;
const colIndex = data.column.index;
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.type === "number" || column.type === "currency") {
data.cell.styles.halign = "right";
@@ -351,6 +442,375 @@ export const exportSoldNetBreakdown = (summaryData) => {
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
*/

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
onMounted(async () => {