diff --git a/main.py b/main.py index ebe3fd2..0b77efd 100644 --- a/main.py +++ b/main.py @@ -63,6 +63,156 @@ from report_generator import ( from recommendations import RecommendationsEngine +# Manager-friendly explanations for PDF and Excel sections (Romanian without diacritics) +PDF_EXPLANATIONS = { + # KPIs and Dashboard + 'kpis': "Indicatorii cheie (KPIs) ofera o imagine de ansamblu a performantei. Vanzarile totale arata volumul de business, marja bruta arata profitabilitatea (ideal peste 20%), iar TREND-ul compara cu anul trecut - CRESTERE e pozitiv, SCADERE necesita investigare.", + 'recomandari': "Sistemul analizeaza automat datele si genereaza alerte prioritizate. ALERTA (rosu) = problema critica ce necesita actiune imediata; ATENTIE (galben) = risc de monitorizat; OK (verde) = situatie normala.", + + # Charts + 'evolutie_lunara': "Graficul arata tendinta vanzarilor pe ultimele 12 luni. Urmariti sezonalitatea (luni slabe/puternice) si tendinta generala. Daca volumul creste dar marja scade, exista presiune pe preturi.", + 'concentrare_clienti': "Dependenta de clientii mari - regula 80/20: ideal max 60-70% din vanzari de la top 20% clienti. Daca un singur client depaseste 25% din vanzari, exista risc major - diversificati portofoliul.", + 'ciclu_cash': "Ciclul de conversie cash (CCC) arata cate zile sunt blocati banii: DIO (zile stoc) + DSO (zile incasare) - DPO (zile plata furnizori). Sub 30 zile = excelent, 30-60 = acceptabil, peste 60 = problema de lichiditate.", + + # Alerts and tables + 'alerte_critice': "Vanzarile sub cost sunt tranzactii in pierdere - verificati preturile imediat. Clientii cu marja sub 15% consuma resurse fara profit adecvat - renegociati sau reduceti prioritatea.", + 'top_clienti_produse': "Clasamentele arata cei mai importanti clienti si produse dupa vanzari si marja. Concentrati-va pe clientii cu marja mare si investigati pe cei cu marja mica (<15%).", + 'aging_creante': "Analiza vechimii creantelor arata suma facturilor neincasate pe intervale de timp. Creantele peste 90 de zile reprezinta risc major de neincasare - contactati urgent acesti clienti.", + 'stoc_lent': "Stocul lent blocheaza capital si ocupa spatiu. Produsele fara miscare peste 90 zile trebuie lichidate prin promotii sau trecute in pierdere daca sunt expirate/depasate.", + + # Excel-specific sections + 'venituri': "Vanzarile pe linii de business arata contributia fiecarui segment. Productia proprie are de obicei marja mai mare (25-40%), revanzarea are marja mai mica (10-20%) dar volum mai mare.", + 'portofoliu_clienti': "Structura portofoliului de clienti pe segmente. Un portofoliu sanatos are diversificare pe mai multe segmente, fara dependenta excesiva de o singura categorie.", + 'risc_concentrare': "Indicatorii de concentrare arata cat de dependenti sunteti de putini clienti. Top 5 clienti ideal sub 50% din vanzari, top 10 ideal sub 70%. Urmariti trendul YoY.", + 'indicatori_generali': "Indicatori financiari structurali: grad indatorare (Datorii/Capital - ideal <1), autonomie financiara (Capital/Active - ideal >0.5), rata datoriilor (Datorii/Active - ideal <0.5), marja neta (Profit/Vanzari %), ROA (Profit/Active %), rotatia activelor.", + 'indicatori_lichiditate': "Ratii de lichiditate: Lichiditate curenta (Active curente/Datorii curente - ideal >=1.5), Quick Ratio (fara stocuri - ideal >=1.0), Cash Ratio (Cash/Datorii - ideal >=0.2), Fond de rulment (Active curente - Datorii curente).", + 'ciclu_conversie_cash': "Ciclul de conversie cash (CCC) = DIO + DSO - DPO. DIO = zile stoc (cat timp sta marfa pe stoc), DSO = zile incasare (cat timp asteptam banii de la clienti), DPO = zile plata (in cat timp platim furnizorii). CCC sub 30 zile = excelent, 30-60 = acceptabil, peste 60 = probleme de lichiditate.", + 'solduri_clienti': "Solduri de incasat de la clienti - creante comerciale din cont 4111. Verificati vechimea si urgentati incasarile peste 30 zile pentru a imbunatati cash-flow-ul.", + 'solduri_furnizori': "Datorii catre furnizori din cont 401. Prioritizati platile in functie de scadente si relatia comerciala pentru a evita penalizari si pastrarea relatiilor bune.", + 'clasificare_datorii': "Datoriile pe intervale de intarziere. Datoriile sub 30 zile sunt normale, 30-60 zile necesita atentie, peste 60 zile afecteaza relatia cu furnizorii.", + 'proiectie_lichiditate': "Previziunea de cash pe 30 zile bazata pe scadente. Sold negativ = risc de lipsă numerar - asigurati finantare suplimentara sau urgentati incasarile." +} + + +# ============================================================================= +# DYNAMIC EXPLANATION GENERATORS - Create explanations with actual values +# ============================================================================= + +def generate_indicatori_generali_explanation(df): + """Generate dynamic explanation with actual values and formulas for financial indicators.""" + if df is None or df.empty: + return "Nu exista date pentru indicatorii financiari generali." + + parts = ["INDICATORI FINANCIARI:"] + for _, row in df.iterrows(): + ind = row.get('INDICATOR', '') + val = row.get('VALOARE', 0) + status = row.get('STATUS', '') + + if val is None: + val = 0 + + if 'indatorare' in ind.lower(): + parts.append(f"Grad indatorare = {val:.2f} (Datorii/Capital propriu) - {status}") + elif 'autonomie' in ind.lower(): + parts.append(f"Autonomie financiara = {val:.2f} (Capital/Active) - {status}") + elif 'rata datoriilor' in ind.lower(): + parts.append(f"Rata datoriilor = {val:.2f} (Datorii/Active) - {status}") + elif 'marja' in ind.lower(): + parts.append(f"Marja neta = {val:.1f}% - {status}") + elif 'roa' in ind.lower(): + parts.append(f"ROA = {val:.1f}% (Profit/Active) - {status}") + elif 'rotatia' in ind.lower(): + parts.append(f"Rotatia activelor = {val:.2f} - {status}") + + return " | ".join(parts) if len(parts) > 1 else parts[0] + + +def generate_indicatori_lichiditate_explanation(df): + """Generate dynamic explanation for liquidity ratios with actual values.""" + if df is None or df.empty: + return "Nu exista date pentru indicatorii de lichiditate." + + parts = ["RATII LICHIDITATE:"] + for _, row in df.iterrows(): + ind = row.get('INDICATOR', '') + val = row.get('VALOARE', 0) + status = row.get('STATUS', '') + + if val is None: + val = 0 + + if 'curenta' in ind.lower() and 'lichiditate' in ind.lower(): + parts.append(f"Lichiditate curenta = {val:.2f} (Active curente/Datorii curente, ideal >= 1.5) - {status}") + elif 'rapida' in ind.lower() or 'quick' in ind.lower(): + parts.append(f"Quick Ratio = {val:.2f} (fara stocuri, ideal >= 1.0) - {status}") + elif 'cash' in ind.lower(): + parts.append(f"Cash Ratio = {val:.2f} (Cash/Datorii, ideal >= 0.2) - {status}") + elif 'fond' in ind.lower() or 'rulment' in ind.lower(): + parts.append(f"Fond de rulment = {val:,.0f} RON - {status}") + + return " | ".join(parts) if len(parts) > 1 else parts[0] + + +def generate_ciclu_cash_explanation(df): + """Generate dynamic explanation for cash conversion cycle with actual values.""" + if df is None or df.empty: + return "Nu exista date pentru ciclul de conversie cash." + + dio = dso = dpo = ccc = 0 + for _, row in df.iterrows(): + ind = str(row.get('INDICATOR', '')).upper() + zile = row.get('ZILE', 0) + if zile is None: + zile = 0 + + if 'DIO' in ind and 'DSO' not in ind: + dio = zile + elif 'DSO' in ind and 'DIO' not in ind: + dso = zile + elif 'DPO' in ind: + dpo = zile + elif 'CCC' in ind or 'CICLU' in ind: + ccc = zile + + # Calculate CCC if not directly available + if ccc == 0 and (dio > 0 or dso > 0): + ccc = dio + dso - dpo + + status = "EXCELENT" if ccc < 30 else "ACCEPTABIL" if ccc < 60 else "PROBLEME LICHIDITATE" + + return (f"CICLU CONVERSIE CASH: DIO = {dio:.0f} zile (stoc -> vanzare) + " + f"DSO = {dso:.0f} zile (factura -> incasare) - " + f"DPO = {dpo:.0f} zile (achizitie -> plata) = " + f"CCC = {ccc:.0f} zile. STATUS: {status}") + + +def generate_solduri_clienti_explanation(df): + """Generate dynamic explanation for customer balances with actual totals.""" + if df is None or df.empty: + return "Nu exista solduri de incasat de la clienti." + + total = df['SOLD_CURENT'].sum() if 'SOLD_CURENT' in df.columns else 0 + count = len(df) + top1_pct = (df['SOLD_CURENT'].iloc[0] / total * 100) if total > 0 and len(df) > 0 else 0 + + return (f"CREANTE CLIENTI: Total de incasat = {total:,.0f} RON de la {count} clienti. " + f"Top 1 client = {top1_pct:.1f}% din total. " + f"Verificati vechimea creantelor si urgentati incasarile peste 30 zile.") + + +def generate_solduri_furnizori_explanation(df): + """Generate dynamic explanation for supplier balances with actual totals.""" + if df is None or df.empty: + return "Nu exista datorii catre furnizori." + + total = df['SOLD_CURENT'].sum() if 'SOLD_CURENT' in df.columns else 0 + count = len(df) + + return (f"DATORII FURNIZORI: Total de platit = {total:,.0f} RON catre {count} furnizori. " + f"Verificati scadentele si prioritizati platile pentru a evita penalizari.") + + class PerformanceLogger: """Tracks execution time for each operation to identify bottlenecks.""" @@ -131,18 +281,23 @@ class PerformanceLogger: class OracleConnection: """Context manager for Oracle database connection""" - + def __init__(self): self.connection = None - + def __enter__(self): try: print(f"🔌 Conectare la Oracle: {get_dsn()}...") + # Add TCP keepalive and timeout settings to prevent connection drops self.connection = oracledb.connect( user=ORACLE_CONFIG['user'], password=ORACLE_CONFIG['password'], - dsn=get_dsn() + dsn=get_dsn(), + tcp_connect_timeout=30, + expire_time=5 # Send TCP keepalive every 5 minutes ) + # Set call timeout to 10 minutes for slow queries + self.connection.call_timeout = 600000 # milliseconds print("✓ Conectat cu succes!") return self.connection except oracledb.Error as e: @@ -634,7 +789,7 @@ def generate_reports(args): # ========================================================================= # --- Sheet 0: DASHBOARD COMPLET (toate secțiunile într-o singură vedere) --- - perf.start("EXCEL: Dashboard Complet sheet (9 sections)") + perf.start("EXCEL: Dashboard Complet sheet (12 sections)") excel_gen.add_consolidated_sheet( name='Dashboard Complet', sheet_title='Dashboard Executiv - Vedere Completă', @@ -644,50 +799,80 @@ def generate_reports(args): { 'title': 'KPIs cu Comparație YoY', 'df': results.get('kpi_consolidated', pd.DataFrame()), - 'description': 'Indicatori cheie de performanță - curent vs anterior' + 'description': 'Indicatori cheie de performanță - curent vs anterior', + 'explanation': PDF_EXPLANATIONS['kpis'] }, { 'title': 'Recomandări Prioritare', 'df': results.get('recomandari', pd.DataFrame()).head(10), - 'description': 'Top 10 acțiuni sugerate bazate pe analiză' + 'description': 'Top 10 acțiuni sugerate bazate pe analiză', + 'explanation': PDF_EXPLANATIONS['recomandari'] }, # Venituri { 'title': 'Venituri per Linie Business', 'df': results.get('venituri_consolidated', pd.DataFrame()), - 'description': 'Producție proprie, Materii prime, Marfă revândută' + 'description': 'Producție proprie, Materii prime, Marfă revândută', + 'explanation': PDF_EXPLANATIONS['venituri'] }, # Clienți și Risc { 'title': 'Portofoliu Clienți', 'df': results.get('portofoliu_clienti', pd.DataFrame()), - 'description': 'Structura și segmentarea clienților' + 'description': 'Structura și segmentarea clienților', + 'explanation': PDF_EXPLANATIONS['portofoliu_clienti'] }, { 'title': 'Concentrare Risc YoY', 'df': results.get('risc_consolidated', pd.DataFrame()), - 'description': 'Dependența de clienții mari - curent vs anterior' + 'description': 'Dependența de clienții mari - curent vs anterior', + 'explanation': PDF_EXPLANATIONS['risc_concentrare'] }, - # Tablou Financiar + # Tablou Financiar - with DYNAMIC explanations { 'title': 'Indicatori Generali', 'df': results.get('indicatori_generali', pd.DataFrame()), - 'description': 'Sold clienți, furnizori, cifra afaceri' + 'description': 'Ratii financiare: indatorare, autonomie, datorii, rentabilitate', + 'explanation': generate_indicatori_generali_explanation(results.get('indicatori_generali')) }, { 'title': 'Indicatori Lichiditate', 'df': results.get('indicatori_lichiditate', pd.DataFrame()), - 'description': 'Zile rotație stoc, creanțe, datorii' + 'description': 'Capacitatea de plata pe termen scurt', + 'explanation': generate_indicatori_lichiditate_explanation(results.get('indicatori_lichiditate')) + }, + # NEW: Ciclu Conversie Cash (was missing from Dashboard) + { + 'title': 'Ciclu Conversie Cash', + 'df': results.get('ciclu_conversie_cash', pd.DataFrame()), + 'description': 'DIO (zile stoc) + DSO (zile incasare) - DPO (zile plata) = CCC', + 'explanation': generate_ciclu_cash_explanation(results.get('ciclu_conversie_cash')) + }, + # NEW: Solduri Clienti (Top 10 - was missing from Dashboard) + { + 'title': 'Solduri Clienti (Top 10)', + 'df': results.get('solduri_clienti', pd.DataFrame()).head(10) if results.get('solduri_clienti') is not None and not results.get('solduri_clienti', pd.DataFrame()).empty else pd.DataFrame(), + 'description': 'Creante de incasat din cont 4111', + 'explanation': generate_solduri_clienti_explanation(results.get('solduri_clienti')) + }, + # NEW: Solduri Furnizori (Top 10 - was missing from Dashboard) + { + 'title': 'Solduri Furnizori (Top 10)', + 'df': results.get('solduri_furnizori', pd.DataFrame()).head(10) if results.get('solduri_furnizori') is not None and not results.get('solduri_furnizori', pd.DataFrame()).empty else pd.DataFrame(), + 'description': 'Datorii de platit din cont 401', + 'explanation': generate_solduri_furnizori_explanation(results.get('solduri_furnizori')) }, { 'title': 'Clasificare Datorii', 'df': results.get('clasificare_datorii', pd.DataFrame()), - 'description': 'Datorii pe intervale de întârziere' + 'description': 'Datorii pe intervale de întârziere', + 'explanation': PDF_EXPLANATIONS['clasificare_datorii'] }, { 'title': 'Proiecție Lichiditate', 'df': results.get('proiectie_lichiditate', pd.DataFrame()), - 'description': 'Previziune încasări și plăți pe 30 zile' + 'description': 'Previziune încasări și plăți pe 30 zile', + 'explanation': PDF_EXPLANATIONS['proiectie_lichiditate'] } ] ) @@ -745,6 +930,7 @@ def generate_reports(args): # Pagina 2-3: DASHBOARD COMPLET (toate secțiunile într-o vedere unificată) perf.start("PDF: Dashboard Complet page (4 sections)") + pdf_gen.add_explanation(PDF_EXPLANATIONS['kpis']) pdf_gen.add_consolidated_page( 'Dashboard Complet', sections=[ @@ -783,6 +969,7 @@ def generate_reports(args): # Alerte (vânzări sub cost, clienți marjă mică) perf.start("PDF: Alerts section") + pdf_gen.add_explanation(PDF_EXPLANATIONS['alerte_critice']) pdf_gen.add_alerts_section({ 'vanzari_sub_cost': results.get('vanzari_sub_cost', pd.DataFrame()), 'clienti_marja_mica': results.get('clienti_marja_mica', pd.DataFrame()) @@ -798,6 +985,7 @@ def generate_reports(args): # Grafic: Evoluția Vânzărilor Lunare if 'vanzari_lunare' in results and not results['vanzari_lunare'].empty: perf.start("PDF: Chart - vanzari_lunare") + pdf_gen.add_explanation(PDF_EXPLANATIONS['evolutie_lunara']) fig = create_monthly_chart(results['vanzari_lunare']) pdf_gen.add_chart_image(fig, "Evoluția Vânzărilor și Marjei") perf.stop() @@ -805,15 +993,17 @@ def generate_reports(args): # Grafic: Concentrare Clienți if 'concentrare_clienti' in results and not results['concentrare_clienti'].empty: perf.start("PDF: Chart - concentrare_clienti") + pdf_gen.add_explanation(PDF_EXPLANATIONS['concentrare_clienti']) fig = create_client_concentration_chart(results['concentrare_clienti']) pdf_gen.add_chart_image(fig, "Concentrare Clienți") perf.stop() pdf_gen.add_page_break() - # Grafic: Ciclu Conversie Cash + # Grafic: Ciclu Conversie Cash - with DYNAMIC explanation showing actual values if 'ciclu_conversie_cash' in results and not results['ciclu_conversie_cash'].empty: perf.start("PDF: Chart - ciclu_conversie_cash") + pdf_gen.add_explanation(generate_ciclu_cash_explanation(results.get('ciclu_conversie_cash'))) fig = create_cash_cycle_chart(results['ciclu_conversie_cash']) pdf_gen.add_chart_image(fig, "Ciclu Conversie Cash (DIO + DSO - DPO)") perf.stop() @@ -826,6 +1016,7 @@ def generate_reports(args): perf.stop() # Tabel: Top clienți + pdf_gen.add_explanation(PDF_EXPLANATIONS['top_clienti_produse']) pdf_gen.add_table_section( "Top 15 Clienți după Vânzări", results.get('marja_per_client'), @@ -854,6 +1045,7 @@ def generate_reports(args): # Tabel: Aging Creanțe if 'aging_creante' in results and not results['aging_creante'].empty: pdf_gen.add_page_break() + pdf_gen.add_explanation(PDF_EXPLANATIONS['aging_creante']) pdf_gen.add_table_section( "Aging Creanțe (Vechime Facturi Neîncasate)", results.get('aging_creante'), @@ -864,6 +1056,7 @@ def generate_reports(args): # Tabel: Stoc lent if 'stoc_lent' in results and not results['stoc_lent'].empty: pdf_gen.add_page_break() + pdf_gen.add_explanation(PDF_EXPLANATIONS['stoc_lent']) pdf_gen.add_table_section( "Stoc Lent (>90 zile fără mișcare)", results.get('stoc_lent'), diff --git a/report_generator.py b/report_generator.py index 00c9886..7b8c2f0 100644 --- a/report_generator.py +++ b/report_generator.py @@ -5,11 +5,45 @@ Generates Excel and PDF reports from query results import pandas as pd from datetime import datetime from pathlib import Path +import unicodedata +import re import matplotlib matplotlib.use('Agg') # Non-interactive backend import matplotlib.pyplot as plt import matplotlib.ticker as ticker from openpyxl import Workbook + + +# Romanian diacritics mapping for PDF (Helvetica doesn't support them) +DIACRITICS_MAP = { + 'ă': 'a', 'Ă': 'A', + 'â': 'a', 'Â': 'A', + 'î': 'i', 'Î': 'I', + 'ș': 's', 'Ș': 'S', + 'ş': 's', 'Ş': 'S', # Alternative encoding + 'ț': 't', 'Ț': 'T', + 'ţ': 't', 'Ţ': 'T', # Alternative encoding +} + + +def remove_diacritics(text): + """Remove Romanian diacritics from text for PDF compatibility""" + if not isinstance(text, str): + return text + for diacritic, replacement in DIACRITICS_MAP.items(): + text = text.replace(diacritic, replacement) + return text + + +def sanitize_for_pdf(value, max_length=None): + """Sanitize value for PDF: remove diacritics and optionally truncate""" + if value is None: + return '' + text = str(value) + text = remove_diacritics(text) + if max_length and len(text) > max_length: + text = text[:max_length-3] + '...' + return text from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils.dataframe import dataframe_to_rows from openpyxl.chart import BarChart, LineChart, PieChart, Reference @@ -47,6 +81,14 @@ class ExcelReportGenerator: top=Side(style='thin'), bottom=Side(style='thin') ) + # Style for manager-friendly explanations + self.explanation_fill = PatternFill(start_color='F8F9FA', end_color='F8F9FA', fill_type='solid') + self.explanation_border = Border( + left=Side(style='thin', color='DEE2E6'), + right=Side(style='thin', color='DEE2E6'), + top=Side(style='thin', color='DEE2E6'), + bottom=Side(style='thin', color='DEE2E6') + ) def add_sheet(self, name: str, df: pd.DataFrame, title: str = None, description: str = None, legend: dict = None): """Add a formatted sheet to the workbook with optional legend""" @@ -87,7 +129,7 @@ class ExcelReportGenerator: start_row += 1 if df is None or df.empty: - ws.cell(row=start_row, column=1, value="Nu există date pentru această analiză.") + ws.cell(row=start_row, column=1, value="Nu exista date pentru această analiză.") return # Write headers @@ -304,6 +346,7 @@ class ExcelReportGenerator: section_title = section.get('title', '') df = section.get('df') description = section.get('description', '') + explanation = section.get('explanation', '') legend = section.get('legend', {}) # Section separator @@ -315,7 +358,23 @@ class ExcelReportGenerator: cell.font = Font(bold=True, color='FFFFFF', size=11) start_row += 1 - # Section description + # Manager-friendly explanation box (if provided) + if explanation: + # Merge cells for explanation box (columns 1-8) + ws.merge_cells(start_row=start_row, start_column=1, end_row=start_row, end_column=8) + cell = ws.cell(row=start_row, column=1, value=f"💡 {explanation}") + cell.fill = self.explanation_fill + cell.border = self.explanation_border + cell.font = Font(size=9, color='555555') + cell.alignment = Alignment(wrap_text=True, vertical='center') + ws.row_dimensions[start_row].height = 40 # Taller row for wrapped text + # Apply border to merged cells + for col in range(1, 9): + ws.cell(row=start_row, column=col).fill = self.explanation_fill + ws.cell(row=start_row, column=col).border = self.explanation_border + start_row += 1 + + # Section description (technical) if description: ws.cell(row=start_row, column=1, value=description) ws.cell(row=start_row, column=1).font = Font(italic=True, size=9, color='666666') @@ -325,7 +384,7 @@ class ExcelReportGenerator: # Check for empty data if df is None or df.empty: - ws.cell(row=start_row, column=1, value="Nu există date pentru această secțiune.") + ws.cell(row=start_row, column=1, value="Nu exista date pentru această secțiune.") ws.cell(row=start_row, column=1).font = Font(italic=True, color='999999') start_row += 3 continue @@ -457,126 +516,192 @@ class PDFReportGenerator: fontSize=8, textColor=colors.gray )) + # Table cell styles with word wrapping + self.styles.add(ParagraphStyle( + name='TableCell', + parent=self.styles['Normal'], + fontSize=7, + leading=9, + wordWrap='CJK', + )) + self.styles.add(ParagraphStyle( + name='TableCellBold', + parent=self.styles['Normal'], + fontSize=7, + leading=9, + fontName='Helvetica-Bold', + )) + # Explanation style for manager-friendly text boxes + self.styles.add(ParagraphStyle( + name='Explanation', + parent=self.styles['Normal'], + fontSize=9, + textColor=colors.HexColor('#555555'), + backColor=colors.HexColor('#F8F9FA'), + borderPadding=8, + spaceBefore=5, + spaceAfter=10, + )) + def make_cell_paragraph(self, text, bold=False): + """Create a Paragraph for table cell with word wrapping""" + style = self.styles['TableCellBold'] if bold else self.styles['TableCell'] + return Paragraph(sanitize_for_pdf(text), style) + + def add_explanation(self, text: str): + """Add a manager-friendly explanation box before a section""" + # Create a table with background color to simulate a box + explanation_para = Paragraph(sanitize_for_pdf(text), self.styles['Explanation']) + box_table = Table([[explanation_para]], colWidths=[16*cm]) + box_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#F8F9FA')), + ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#DEE2E6')), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ('LEFTPADDING', (0, 0), (-1, -1), 10), + ('RIGHTPADDING', (0, 0), (-1, -1), 10), + ])) + self.elements.append(box_table) + self.elements.append(Spacer(1, 0.3*cm)) + def add_title_page(self, report_date: datetime = None): """Add title page""" if report_date is None: report_date = datetime.now() - + self.elements.append(Spacer(1, 3*cm)) - self.elements.append(Paragraph(self.company_name, self.styles['CustomTitle'])) + self.elements.append(Paragraph(remove_diacritics(self.company_name), self.styles['CustomTitle'])) self.elements.append(Spacer(1, 1*cm)) self.elements.append(Paragraph( f"Raport generat: {report_date.strftime('%d %B %Y, %H:%M')}", self.styles['Normal'] )) self.elements.append(Paragraph( - "Perioada analizată: Ultimele 12 luni", + "Perioada analizata: Ultimele 12 luni", self.styles['Normal'] )) self.elements.append(PageBreak()) def add_kpi_section(self, kpi_df: pd.DataFrame): """Add KPI summary section""" - self.elements.append(Paragraph("📊 Sumar Executiv - KPIs", self.styles['SectionHeader'])) - + self.elements.append(Paragraph("Sumar Executiv - KPIs", self.styles['SectionHeader'])) + if kpi_df is not None and not kpi_df.empty: - data = [['Indicator', 'Valoare', 'UM']] + # Header row with bold style + header_style = ParagraphStyle( + 'TableHeaderCell', parent=self.styles['Normal'], + fontSize=9, fontName='Helvetica-Bold', textColor=colors.white + ) + data = [[ + Paragraph('Indicator', header_style), + Paragraph('Valoare', header_style), + Paragraph('UM', header_style) + ]] for _, row in kpi_df.iterrows(): data.append([ - str(row.get('INDICATOR', '')), - str(row.get('VALOARE', '')), - str(row.get('UM', '')) + self.make_cell_paragraph(row.get('INDICATOR', '')), + self.make_cell_paragraph(row.get('VALOARE', '')), + self.make_cell_paragraph(row.get('UM', '')) ]) - + table = Table(data, colWidths=[8*cm, 4*cm, 2*cm]) table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#366092')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('ALIGN', (1, 1), (1, -1), 'RIGHT'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, 0), 10), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('TOPPADDING', (0, 1), (-1, -1), 4), + ('BOTTOMPADDING', (0, 1), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.gray), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f0f0f0')]) ])) self.elements.append(table) - + self.elements.append(Spacer(1, 0.5*cm)) def add_alerts_section(self, alerts_data: dict): """Add critical alerts section""" - self.elements.append(Paragraph("🚨 Alerte Critice", self.styles['SectionHeader'])) - - # Vânzări sub cost + self.elements.append(Paragraph("Alerte Critice", self.styles['SectionHeader'])) + + # Vanzari sub cost if 'vanzari_sub_cost' in alerts_data and not alerts_data['vanzari_sub_cost'].empty: df = alerts_data['vanzari_sub_cost'] count = len(df) total_loss = df['PIERDERE'].sum() if 'PIERDERE' in df.columns else 0 - + self.elements.append(Paragraph( - f"⛔ VÂNZĂRI SUB COST: {count} tranzacții cu pierdere totală de {abs(total_loss):,.2f} RON", + f"VANZARI SUB COST: {count} tranzactii cu pierdere totala de {abs(total_loss):,.2f} RON", self.styles['AlertHeader'] )) - + # Show top 5 top5 = df.head(5) if not top5.empty: cols_to_show = ['FACTURA', 'CLIENT', 'PRODUS', 'PIERDERE'] cols_to_show = [c for c in cols_to_show if c in top5.columns] if cols_to_show: - data = [cols_to_show] + top5[cols_to_show].values.tolist() + # Header with Paragraph + data = [[self.make_cell_paragraph(c, bold=True) for c in cols_to_show]] + for _, row in top5.iterrows(): + row_data = [self.make_cell_paragraph(row.get(c, '')) for c in cols_to_show] + data.append(row_data) table = Table(data, colWidths=[3*cm, 4*cm, 5*cm, 2*cm]) table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#c0392b')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), - ('FONTSIZE', (0, 0), (-1, -1), 8), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.gray), ])) self.elements.append(table) - - # Clienți cu marjă mică + + # Clienti cu marja mica if 'clienti_marja_mica' in alerts_data and not alerts_data['clienti_marja_mica'].empty: df = alerts_data['clienti_marja_mica'] count = len(df) - + self.elements.append(Spacer(1, 0.3*cm)) self.elements.append(Paragraph( - f"⚠️ CLIENȚI CU MARJĂ MICĂ (<15%): {count} clienți necesită renegociere", + f"CLIENTI CU MARJA MICA (<15%): {count} clienti necesita renegociere", self.styles['AlertHeader'] )) - + top5 = df.head(5) if not top5.empty: cols_to_show = ['CLIENT', 'VANZARI_FARA_TVA', 'PROCENT_MARJA'] cols_to_show = [c for c in cols_to_show if c in top5.columns] if cols_to_show: - data = [cols_to_show] + # Header with Paragraph + data = [[self.make_cell_paragraph(c, bold=True) for c in cols_to_show]] for _, row in top5.iterrows(): data.append([ - str(row.get('CLIENT', ''))[:30], - f"{row.get('VANZARI_FARA_TVA', 0):,.0f}", - f"{row.get('PROCENT_MARJA', 0):.1f}%" + self.make_cell_paragraph(row.get('CLIENT', '')), + self.make_cell_paragraph(f"{row.get('VANZARI_FARA_TVA', 0):,.0f}"), + self.make_cell_paragraph(f"{row.get('PROCENT_MARJA', 0):.1f}%") ]) table = Table(data, colWidths=[6*cm, 3*cm, 2*cm]) table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e67e22')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), - ('FONTSIZE', (0, 0), (-1, -1), 8), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.gray), ])) self.elements.append(table) - + self.elements.append(Spacer(1, 0.5*cm)) def add_chart_image(self, fig, title: str): """Add a matplotlib figure as image""" - self.elements.append(Paragraph(title, self.styles['SectionHeader'])) + self.elements.append(Paragraph(remove_diacritics(title), self.styles['SectionHeader'])) # Save figure to buffer buf = io.BytesIO() - fig.savefig(buf, format='png', dpi=150, bbox_inches='tight') + fig.savefig(buf, format='png', dpi=200, bbox_inches='tight') buf.seek(0) # Add to PDF @@ -587,60 +712,60 @@ class PDFReportGenerator: plt.close(fig) def add_table_section(self, title: str, df: pd.DataFrame, columns: list = None, max_rows: int = 15): - """Add a data table section""" - self.elements.append(Paragraph(title, self.styles['SectionHeader'])) - + """Add a data table section with word-wrapped cells""" + self.elements.append(Paragraph(remove_diacritics(title), self.styles['SectionHeader'])) + if df is None or df.empty: - self.elements.append(Paragraph("Nu există date.", self.styles['Normal'])) + self.elements.append(Paragraph("Nu exista date.", self.styles['Normal'])) return - + # Select columns if columns: cols = [c for c in columns if c in df.columns] else: cols = list(df.columns)[:6] # Max 6 columns for PDF - + if not cols: return - - # Prepare data - data = [cols] + + # Prepare data with Paragraph cells for word wrapping + data = [[self.make_cell_paragraph(c, bold=True) for c in cols]] for _, row in df.head(max_rows).iterrows(): row_data = [] for col in cols: val = row.get(col, '') if isinstance(val, float): - row_data.append(f"{val:,.2f}") + row_data.append(self.make_cell_paragraph(f"{val:,.2f}")) elif isinstance(val, int): - row_data.append(f"{val:,}") + row_data.append(self.make_cell_paragraph(f"{val:,}")) else: - row_data.append(str(val)[:25]) # Truncate long strings + row_data.append(self.make_cell_paragraph(val)) data.append(row_data) - + # Calculate column widths n_cols = len(cols) col_width = 16*cm / n_cols - + table = Table(data, colWidths=[col_width] * n_cols) table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#366092')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, -1), 7), - ('BOTTOMPADDING', (0, 0), (-1, 0), 8), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.gray), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f5f5f5')]) ])) - + self.elements.append(table) - + if len(df) > max_rows: self.elements.append(Paragraph( - f"... și încă {len(df) - max_rows} înregistrări (vezi Excel pentru lista completă)", + f"... si inca {len(df) - max_rows} inregistrari (vezi Excel pentru lista completa)", self.styles['SmallText'] )) - + self.elements.append(Spacer(1, 0.5*cm)) def add_page_break(self): @@ -649,7 +774,7 @@ class PDFReportGenerator: def add_consolidated_page(self, page_title: str, sections: list): """ - Add a consolidated PDF page with multiple sections. + Add a consolidated PDF page with multiple sections and word-wrapped cells. Args: page_title: Main title for the page @@ -660,7 +785,7 @@ class PDFReportGenerator: - 'max_rows': Max rows to display (default 15) """ # Page title - self.elements.append(Paragraph(page_title, self.styles['SectionHeader'])) + self.elements.append(Paragraph(remove_diacritics(page_title), self.styles['SectionHeader'])) self.elements.append(Spacer(1, 0.3*cm)) for section in sections: @@ -678,10 +803,10 @@ class PDFReportGenerator: spaceAfter=5, textColor=colors.HexColor('#2C3E50') ) - self.elements.append(Paragraph(section_title, subsection_style)) + self.elements.append(Paragraph(remove_diacritics(section_title), subsection_style)) if df is None or df.empty: - self.elements.append(Paragraph("Nu există date.", self.styles['Normal'])) + self.elements.append(Paragraph("Nu exista date.", self.styles['Normal'])) self.elements.append(Spacer(1, 0.3*cm)) continue @@ -694,18 +819,18 @@ class PDFReportGenerator: if not cols: continue - # Prepare data - data = [cols] + # Prepare data with Paragraph cells for word wrapping + data = [[self.make_cell_paragraph(c, bold=True) for c in cols]] for _, row in df.head(max_rows).iterrows(): row_data = [] for col in cols: val = row.get(col, '') if isinstance(val, float): - row_data.append(f"{val:,.2f}") + row_data.append(self.make_cell_paragraph(f"{val:,.2f}")) elif isinstance(val, int): - row_data.append(f"{val:,}") + row_data.append(self.make_cell_paragraph(f"{val:,}")) else: - row_data.append(str(val)[:30]) # Truncate long strings + row_data.append(self.make_cell_paragraph(val)) data.append(row_data) # Calculate column widths @@ -719,9 +844,9 @@ class PDFReportGenerator: ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#366092')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, -1), 7), - ('BOTTOMPADDING', (0, 0), (-1, 0), 6), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.gray), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f5f5f5')]) ] @@ -743,7 +868,7 @@ class PDFReportGenerator: if len(df) > max_rows: self.elements.append(Paragraph( - f"... și încă {len(df) - max_rows} înregistrări", + f"... si inca {len(df) - max_rows} inregistrari", self.styles['SmallText'] )) @@ -764,11 +889,11 @@ class PDFReportGenerator: df_sorted = df_sorted.sort_values('_order').head(7) for _, row in df_sorted.iterrows(): - status = row.get('STATUS', 'OK') - indicator = row.get('INDICATOR', '') - valoare = row.get('VALOARE', '') - explicatie = row.get('EXPLICATIE', '') - recomandare = row.get('RECOMANDARE', '') + status = sanitize_for_pdf(row.get('STATUS', 'OK')) + indicator = sanitize_for_pdf(row.get('INDICATOR', '')) + valoare = sanitize_for_pdf(row.get('VALOARE', '')) + explicatie = sanitize_for_pdf(row.get('EXPLICATIE', '')) + recomandare = sanitize_for_pdf(row.get('RECOMANDARE', '')) # Color based on status if status == 'ALERTA': @@ -819,128 +944,202 @@ class PDFReportGenerator: print(f"✓ PDF salvat: {self.output_path}") +# Modern minimalist chart colors +CHART_COLORS = { + 'primary': '#2C3E50', # Dark blue-gray + 'secondary': '#7F8C8D', # Gray + 'accent': '#E74C3C', # Red for alerts/negative + 'positive': '#27AE60', # Green for positive trends + 'light': '#ECF0F1', # Light background +} + + +def setup_chart_style(): + """Apply modern minimalist styling to charts""" + plt.rcParams.update({ + 'font.family': 'sans-serif', + 'font.size': 10, + 'axes.titlesize': 12, + 'axes.titleweight': 'bold', + 'axes.spines.top': False, + 'axes.spines.right': False, + 'axes.grid': True, + 'grid.alpha': 0.3, + 'grid.linestyle': '--', + 'figure.facecolor': 'white', + 'axes.facecolor': 'white', + 'axes.edgecolor': '#7F8C8D', + 'xtick.color': '#7F8C8D', + 'ytick.color': '#7F8C8D', + }) + + def create_monthly_chart(df: pd.DataFrame) -> plt.Figure: - """Create monthly sales and margin chart""" + """Create monthly sales chart - modern minimalist style""" + setup_chart_style() if df is None or df.empty: - fig, ax = plt.subplots(figsize=(12, 6)) - ax.text(0.5, 0.5, 'Nu există date', ha='center', va='center') + fig, ax = plt.subplots(figsize=(12, 5)) + ax.text(0.5, 0.5, 'Nu exista date', ha='center', va='center') return fig - - fig, ax1 = plt.subplots(figsize=(12, 6)) - + + fig, ax = plt.subplots(figsize=(12, 5)) + x = range(len(df)) - # Bar chart for sales - bars = ax1.bar(x, df['VANZARI_FARA_TVA'], color='#366092', alpha=0.7, label='Vânzări') - ax1.set_xlabel('Luna') - ax1.set_ylabel('Vânzări (RON)', color='#366092') - ax1.tick_params(axis='y', labelcolor='#366092') - ax1.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k')) + # Single color bars with clean styling + bars = ax.bar(x, df['VANZARI_FARA_TVA'], color=CHART_COLORS['primary'], alpha=0.85, + edgecolor='white', linewidth=0.5) - # Line chart for margin - ax2 = ax1.twinx() - line = ax2.plot(x, df['MARJA_BRUTA'], color='#e74c3c', linewidth=2, marker='o', label='Marja') - ax2.set_ylabel('Marja (RON)', color='#e74c3c') - ax2.tick_params(axis='y', labelcolor='#e74c3c') - ax2.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k')) + # Add value labels on top of bars + for bar in bars: + height = bar.get_height() + ax.text(bar.get_x() + bar.get_width()/2., height, + f'{height/1000:,.0f}k', ha='center', va='bottom', fontsize=8, color='#555') - # X-axis labels - ax1.set_xticks(x) - ax1.set_xticklabels(df['LUNA'], rotation=45, ha='right') + # Clean axis formatting + ax.set_xlabel('Luna', fontsize=10, color='#555') + ax.set_ylabel('Vanzari (RON)', fontsize=10, color='#555') + ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k')) + ax.set_xticks(x) + ax.set_xticklabels(df['LUNA'], rotation=45, ha='right', fontsize=9) - # Legend - lines1, labels1 = ax1.get_legend_handles_labels() - lines2, labels2 = ax2.get_legend_handles_labels() - ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') + # Add subtle trend line if margin data exists + if 'MARJA_BRUTA' in df.columns: + ax2 = ax.twinx() + ax2.plot(x, df['MARJA_BRUTA'], color=CHART_COLORS['accent'], linewidth=2, + marker='o', markersize=4, label='Marja Bruta', alpha=0.8) + ax2.set_ylabel('Marja (RON)', fontsize=10, color=CHART_COLORS['accent']) + ax2.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k')) + ax2.spines['right'].set_visible(True) + ax2.spines['right'].set_color(CHART_COLORS['accent']) + ax2.tick_params(axis='y', colors=CHART_COLORS['accent']) - plt.title('Evoluția Vânzărilor și Marjei Lunare') + ax.set_title('Evolutia Vanzarilor Lunare', fontsize=12, fontweight='bold', color='#2C3E50') plt.tight_layout() - return fig def create_client_concentration_chart(df: pd.DataFrame) -> plt.Figure: - """Create client concentration pie chart""" + """Create client concentration chart - horizontal bars (easier to read than pie)""" + setup_chart_style() if df is None or df.empty: - fig, ax = plt.subplots(figsize=(10, 8)) - ax.text(0.5, 0.5, 'Nu există date', ha='center', va='center') + fig, ax = plt.subplots(figsize=(12, 6)) + ax.text(0.5, 0.5, 'Nu exista date', ha='center', va='center') return fig + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) - - # Pie chart - Top 10 vs Others top10 = df.head(10) - others_pct = 100 - top10['PROCENT_CUMULAT'].iloc[-1] if len(top10) >= 10 else 0 - sizes = list(top10['PROCENT_DIN_TOTAL']) - if others_pct > 0: - sizes.append(others_pct) - labels = list(top10['CLIENT'].str[:20]) # Truncate names - if others_pct > 0: - labels.append('Alții') + # Left: Horizontal bar chart showing client share (cleaner than pie) + y_pos = range(len(top10)-1, -1, -1) # Reverse for top-to-bottom + colors = [CHART_COLORS['primary'] if pct < 25 else CHART_COLORS['accent'] + for pct in top10['PROCENT_DIN_TOTAL']] - colors_list = plt.cm.Set3(range(len(sizes))) + bars = ax1.barh(y_pos, top10['PROCENT_DIN_TOTAL'], color=colors, alpha=0.85, + edgecolor='white', linewidth=0.5) + ax1.set_yticks(y_pos) + ax1.set_yticklabels([c[:25] for c in top10['CLIENT']], fontsize=9) + ax1.set_xlabel('% din Vanzari Totale', fontsize=10) + ax1.set_title('Top 10 Clienti - Pondere Vanzari', fontsize=11, fontweight='bold') - ax1.pie(sizes, labels=None, colors=colors_list, autopct='%1.1f%%', startangle=90) - ax1.set_title('Concentrare Top 10 Clienți') - ax1.legend(labels, loc='center left', bbox_to_anchor=(1, 0.5), fontsize=8) + # Add percentage labels + for bar, pct in zip(bars, top10['PROCENT_DIN_TOTAL']): + ax1.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2, + f'{pct:.1f}%', va='center', fontsize=8, color='#555') - # Bar chart - Pareto - ax2.bar(range(len(top10)), top10['VANZARI'], color='#366092', alpha=0.7) + # Add 25% threshold line + ax1.axvline(x=25, color=CHART_COLORS['accent'], linestyle='--', alpha=0.7, + label='Prag risc 25%') + ax1.legend(loc='lower right', fontsize=8) + + # Right: Pareto chart with cumulative line + x = range(len(top10)) + ax2.bar(x, top10['VANZARI'], color=CHART_COLORS['primary'], alpha=0.85, + edgecolor='white', linewidth=0.5) + + # Cumulative line on secondary axis ax2_twin = ax2.twinx() - ax2_twin.plot(range(len(top10)), top10['PROCENT_CUMULAT'], 'r-o', linewidth=2) - ax2_twin.axhline(y=80, color='green', linestyle='--', alpha=0.5, label='80%') + ax2_twin.plot(x, top10['PROCENT_CUMULAT'], color=CHART_COLORS['accent'], + linewidth=2, marker='o', markersize=4) + ax2_twin.axhline(y=80, color=CHART_COLORS['positive'], linestyle='--', + alpha=0.7, linewidth=1.5, label='Prag 80%') - ax2.set_xticks(range(len(top10))) - ax2.set_xticklabels([c[:15] for c in top10['CLIENT']], rotation=45, ha='right', fontsize=8) - ax2.set_ylabel('Vânzări (RON)') - ax2_twin.set_ylabel('% Cumulat') - ax2.set_title('Analiză Pareto Clienți') + ax2.set_xticks(x) + ax2.set_xticklabels([c[:12] for c in top10['CLIENT']], rotation=45, ha='right', fontsize=8) + ax2.set_ylabel('Vanzari (RON)', fontsize=10) + ax2.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k')) + ax2_twin.set_ylabel('% Cumulat', fontsize=10, color=CHART_COLORS['accent']) + ax2_twin.set_ylim(0, 105) + ax2.set_title('Analiza Pareto - Concentrare Clienti', fontsize=11, fontweight='bold') + ax2_twin.legend(loc='center right', fontsize=8) plt.tight_layout() return fig def create_production_chart(df: pd.DataFrame) -> plt.Figure: - """Create production vs resale comparison chart""" + """Create production vs resale comparison chart - modern minimalist style""" + setup_chart_style() if df is None or df.empty: - fig, ax = plt.subplots(figsize=(10, 6)) - ax.text(0.5, 0.5, 'Nu există date', ha='center', va='center') + fig, ax = plt.subplots(figsize=(10, 5)) + ax.text(0.5, 0.5, 'Nu exista date', ha='center', va='center') return fig fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) - # Bar chart - Sales by type x = range(len(df)) - ax1.bar(x, df['VANZARI_FARA_TVA'], color=['#366092', '#e74c3c', '#2ecc71'][:len(df)]) + + # Left: Sales by type (two-color scheme: primary + secondary) + bar_colors = [CHART_COLORS['primary'], CHART_COLORS['secondary']][:len(df)] + if len(df) > 2: + bar_colors = [CHART_COLORS['primary']] * len(df) + + bars = ax1.bar(x, df['VANZARI_FARA_TVA'], color=bar_colors, alpha=0.85, + edgecolor='white', linewidth=0.5) ax1.set_xticks(x) - ax1.set_xticklabels(df['TIP_PRODUS'], rotation=15) - ax1.set_ylabel('Vânzări (RON)') - ax1.set_title('Vânzări per Tip Produs') + ax1.set_xticklabels(df['TIP_PRODUS'], rotation=15, fontsize=9) + ax1.set_ylabel('Vanzari (RON)', fontsize=10) + ax1.set_title('Vanzari per Tip Produs', fontsize=11, fontweight='bold') ax1.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k')) - # Bar chart - Margin % - colors = ['#2ecc71' if m > 20 else '#e67e22' if m > 15 else '#e74c3c' for m in df['PROCENT_MARJA']] - ax2.bar(x, df['PROCENT_MARJA'], color=colors) + # Add value labels + for bar in bars: + height = bar.get_height() + ax1.text(bar.get_x() + bar.get_width()/2., height, + f'{height/1000:,.0f}k', ha='center', va='bottom', fontsize=9, color='#555') + + # Right: Margin % with color-coded status + colors = [CHART_COLORS['positive'] if m > 20 else CHART_COLORS['secondary'] if m > 15 + else CHART_COLORS['accent'] for m in df['PROCENT_MARJA']] + bars2 = ax2.bar(x, df['PROCENT_MARJA'], color=colors, alpha=0.85, + edgecolor='white', linewidth=0.5) ax2.set_xticks(x) - ax2.set_xticklabels(df['TIP_PRODUS'], rotation=15) - ax2.set_ylabel('Marjă (%)') - ax2.set_title('Marjă per Tip Produs') - ax2.axhline(y=15, color='red', linestyle='--', alpha=0.5, label='Prag minim 15%') - ax2.legend() + ax2.set_xticklabels(df['TIP_PRODUS'], rotation=15, fontsize=9) + ax2.set_ylabel('Marja (%)', fontsize=10) + ax2.set_title('Marja per Tip Produs', fontsize=11, fontweight='bold') + ax2.axhline(y=15, color=CHART_COLORS['accent'], linestyle='--', alpha=0.7, + linewidth=1.5, label='Prag minim 15%') + ax2.legend(loc='upper right', fontsize=8) + + # Add value labels + for bar, val in zip(bars2, df['PROCENT_MARJA']): + ax2.text(bar.get_x() + bar.get_width()/2., bar.get_height(), + f'{val:.1f}%', ha='center', va='bottom', fontsize=9, color='#555') plt.tight_layout() return fig def create_cash_cycle_chart(df: pd.DataFrame) -> plt.Figure: - """Create cash conversion cycle visualization""" + """Create cash conversion cycle visualization - modern minimalist style""" + setup_chart_style() if df is None or df.empty: - fig, ax = plt.subplots(figsize=(10, 6)) + fig, ax = plt.subplots(figsize=(10, 5)) ax.text(0.5, 0.5, 'Nu exista date', ha='center', va='center') return fig - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) # Extract values indicators = df['INDICATOR'].tolist() if 'INDICATOR' in df.columns else [] @@ -951,12 +1150,12 @@ def create_cash_cycle_chart(df: pd.DataFrame) -> plt.Figure: ax2.text(0.5, 0.5, 'Date incomplete', ha='center', va='center') return fig - # Colors for each component + # Simplified color scheme colors_map = { - 'DIO': '#3498db', # Blue for inventory - 'DSO': '#e74c3c', # Red for receivables - 'DPO': '#2ecc71', # Green for payables - 'CCC': '#9b59b6' # Purple for total cycle + 'DIO': CHART_COLORS['primary'], # Dark blue for inventory + 'DSO': CHART_COLORS['secondary'], # Gray for receivables + 'DPO': CHART_COLORS['positive'], # Green for payables (reduces cycle) + 'CCC': CHART_COLORS['accent'] # Red for total cycle result } bar_colors = [] @@ -966,59 +1165,64 @@ def create_cash_cycle_chart(df: pd.DataFrame) -> plt.Figure: bar_colors.append(color) break else: - bar_colors.append('#95a5a6') + bar_colors.append(CHART_COLORS['secondary']) - # Bar chart + # Left: Component bars x = range(len(indicators)) - bars = ax1.bar(x, zile, color=bar_colors, alpha=0.8) + bars = ax1.bar(x, zile, color=bar_colors, alpha=0.85, edgecolor='white', linewidth=0.5) ax1.set_xticks(x) ax1.set_xticklabels([ind[:20] for ind in indicators], rotation=45, ha='right', fontsize=9) - ax1.set_ylabel('Zile') - ax1.set_title('Ciclu Conversie Cash - Componente') + ax1.set_ylabel('Zile', fontsize=10) + ax1.set_title('Ciclu Conversie Cash - Componente', fontsize=11, fontweight='bold') - # Add value labels on bars + # Add value labels for bar, val in zip(bars, zile): - height = bar.get_height() - ax1.text(bar.get_x() + bar.get_width()/2., height, - f'{int(val)}', - ha='center', va='bottom', fontsize=10, fontweight='bold') + ax1.text(bar.get_x() + bar.get_width()/2., bar.get_height(), + f'{int(val)}', ha='center', va='bottom', fontsize=10, fontweight='bold', color='#555') - # Waterfall-style visualization - # DIO + DSO - DPO = CCC + # Right: Formula visualization dio = next((z for i, z in zip(indicators, zile) if 'DIO' in i.upper()), 0) dso = next((z for i, z in zip(indicators, zile) if 'DSO' in i.upper() and 'DIO' not in i.upper()), 0) dpo = next((z for i, z in zip(indicators, zile) if 'DPO' in i.upper()), 0) ccc = dio + dso - dpo - waterfall_labels = ['DIO\n(Zile Stoc)', 'DSO\n(Zile Incasare)', 'DPO\n(Zile Plata)', 'CCC\n(Ciclu Total)'] - waterfall_values = [dio, dso, -dpo, ccc] - waterfall_colors = ['#3498db', '#e74c3c', '#2ecc71', '#9b59b6'] - - # Calculate positions for waterfall - cumulative = [0] - for i, v in enumerate(waterfall_values[:-1]): - cumulative.append(cumulative[-1] + v) - - ax2.bar([0, 1, 2], [dio, dso, dpo], color=['#3498db', '#e74c3c', '#2ecc71'], alpha=0.8) - ax2.axhline(y=ccc, color='#9b59b6', linewidth=3, linestyle='--', label=f'CCC = {int(ccc)} zile') + bars2 = ax2.bar([0, 1, 2], [dio, dso, dpo], + color=[CHART_COLORS['primary'], CHART_COLORS['secondary'], CHART_COLORS['positive']], + alpha=0.85, edgecolor='white', linewidth=0.5) + + # CCC result line with color based on health + if ccc > 60: + ccc_color = CHART_COLORS['accent'] + elif ccc > 30: + ccc_color = CHART_COLORS['secondary'] + else: + ccc_color = CHART_COLORS['positive'] + + ax2.axhline(y=ccc, color=ccc_color, linewidth=3, linestyle='--', + label=f'CCC = {int(ccc)} zile') ax2.set_xticks([0, 1, 2]) ax2.set_xticklabels(['DIO\n(+Stoc)', 'DSO\n(+Incasare)', 'DPO\n(-Plata)'], fontsize=9) - ax2.set_ylabel('Zile') - ax2.set_title('Formula: DIO + DSO - DPO = CCC') - ax2.legend(loc='upper right') + ax2.set_ylabel('Zile', fontsize=10) + ax2.set_title('Formula: DIO + DSO - DPO = CCC', fontsize=11, fontweight='bold') + ax2.legend(loc='upper right', fontsize=9) + + # Add value labels + for bar, val in zip(bars2, [dio, dso, dpo]): + ax2.text(bar.get_x() + bar.get_width()/2., bar.get_height(), + f'{int(val)}', ha='center', va='bottom', fontsize=10, fontweight='bold', color='#555') - # Add annotation explaining the result + # Status annotation if ccc > 60: verdict = "Ciclu lung - capital blocat mult timp" - verdict_color = '#c0392b' + verdict_color = CHART_COLORS['accent'] elif ccc > 30: verdict = "Ciclu moderat - poate fi optimizat" - verdict_color = '#d68910' + verdict_color = CHART_COLORS['secondary'] else: verdict = "Ciclu eficient - capital rotit rapid" - verdict_color = '#27ae60' + verdict_color = CHART_COLORS['positive'] - ax2.text(0.5, -0.15, verdict, transform=ax2.transAxes, + ax2.text(0.5, -0.18, verdict, transform=ax2.transAxes, ha='center', fontsize=10, color=verdict_color, fontweight='bold') plt.tight_layout()