Add manager-friendly explanations and dynamic generators

main.py:
- Add PDF_EXPLANATIONS dict with Romanian explanations for all report sections
- Add dynamic explanation generators:
  - generate_indicatori_generali_explanation() - financial ratios with values
  - generate_indicatori_lichiditate_explanation() - liquidity ratios
  - generate_ciclu_cash_explanation() - cash conversion cycle
  - generate_solduri_clienti/furnizori_explanation() - balance summaries

report_generator.py:
- Add diacritics removal for PDF compatibility (Helvetica font)
- Add sanitize_for_pdf() helper function
- Add explanation_fill and explanation_border styles for info boxes
- Enhance add_consolidated_sheet() with explanation parameter
- Add merged explanation boxes with light grey background

These changes complement the FORMULA column additions, providing
managers with contextual explanations throughout the reports.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-12 16:20:30 +02:00
parent 38d8f9c6d2
commit bc05d02319
2 changed files with 596 additions and 199 deletions

View File

@@ -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()