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>
1230 lines
52 KiB
Python
1230 lines
52 KiB
Python
"""
|
|
Report Generator Module
|
|
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
|
|
from openpyxl.utils import get_column_letter
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4, landscape
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import cm, mm
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
|
PageBreak, Image, KeepTogether
|
|
)
|
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
|
import io
|
|
|
|
|
|
class ExcelReportGenerator:
|
|
"""Generate Excel reports with multiple sheets and formatting"""
|
|
|
|
def __init__(self, output_path: Path):
|
|
self.output_path = output_path
|
|
self.wb = Workbook()
|
|
# Remove default sheet
|
|
self.wb.remove(self.wb.active)
|
|
|
|
# Define styles
|
|
self.header_font = Font(bold=True, color='FFFFFF', size=11)
|
|
self.header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
|
|
self.alert_fill = PatternFill(start_color='FF6B6B', end_color='FF6B6B', fill_type='solid')
|
|
self.warning_fill = PatternFill(start_color='FFE66D', end_color='FFE66D', fill_type='solid')
|
|
self.good_fill = PatternFill(start_color='4ECDC4', end_color='4ECDC4', fill_type='solid')
|
|
self.border = Border(
|
|
left=Side(style='thin'),
|
|
right=Side(style='thin'),
|
|
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"""
|
|
# Truncate sheet name to 31 chars (Excel limit)
|
|
sheet_name = name[:31]
|
|
ws = self.wb.create_sheet(title=sheet_name)
|
|
|
|
start_row = 1
|
|
|
|
# Add title if provided
|
|
if title:
|
|
ws.cell(row=start_row, column=1, value=title)
|
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=14)
|
|
start_row += 1
|
|
|
|
# Add description if provided
|
|
if description:
|
|
ws.cell(row=start_row, column=1, value=description)
|
|
ws.cell(row=start_row, column=1).font = Font(italic=True, size=10, color='666666')
|
|
start_row += 1
|
|
|
|
# Add timestamp
|
|
ws.cell(row=start_row, column=1, value=f"Generat: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
ws.cell(row=start_row, column=1).font = Font(size=9, color='999999')
|
|
start_row += 1
|
|
|
|
# Add legend if provided
|
|
if legend:
|
|
start_row += 1
|
|
ws.cell(row=start_row, column=1, value="Explicații calcule:")
|
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=9, color='336699')
|
|
start_row += 1
|
|
for col_name, explanation in legend.items():
|
|
ws.cell(row=start_row, column=1, value=f"• {col_name}: {explanation}")
|
|
ws.cell(row=start_row, column=1).font = Font(size=8, color='666666')
|
|
start_row += 1
|
|
|
|
start_row += 1
|
|
|
|
if df is None or df.empty:
|
|
ws.cell(row=start_row, column=1, value="Nu exista date pentru această analiză.")
|
|
return
|
|
|
|
# Write headers
|
|
for col_idx, col_name in enumerate(df.columns, 1):
|
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
|
cell.font = self.header_font
|
|
cell.fill = self.header_fill
|
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
|
cell.border = self.border
|
|
|
|
# Write data
|
|
for row_idx, row in enumerate(df.itertuples(index=False), start_row + 1):
|
|
for col_idx, value in enumerate(row, 1):
|
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
|
cell.border = self.border
|
|
|
|
# Format numbers
|
|
if isinstance(value, (int, float)):
|
|
cell.number_format = '#,##0.00' if isinstance(value, float) else '#,##0'
|
|
cell.alignment = Alignment(horizontal='right')
|
|
|
|
# Highlight negative values
|
|
if isinstance(value, (int, float)) and value < 0:
|
|
cell.fill = self.alert_fill
|
|
|
|
# Highlight low margins
|
|
col_name = df.columns[col_idx - 1].lower()
|
|
if 'procent' in col_name or 'marja' in col_name:
|
|
if isinstance(value, (int, float)):
|
|
if value < 10:
|
|
cell.fill = self.alert_fill
|
|
elif value < 15:
|
|
cell.fill = self.warning_fill
|
|
elif value > 25:
|
|
cell.fill = self.good_fill
|
|
|
|
# Highlight TREND column for YoY sheets
|
|
if col_name == 'trend':
|
|
if isinstance(value, str):
|
|
if value in ('CRESTERE', 'IMBUNATATIRE', 'DIVERSIFICARE'):
|
|
cell.fill = self.good_fill
|
|
elif value in ('SCADERE', 'DETERIORARE', 'CONCENTRARE', 'PIERDUT'):
|
|
cell.fill = self.alert_fill
|
|
elif value == 'ATENTIE':
|
|
cell.fill = self.warning_fill
|
|
|
|
# Highlight STATUS column
|
|
if col_name == 'status' or col_name == 'acoperire':
|
|
if isinstance(value, str):
|
|
if value == 'OK':
|
|
cell.fill = self.good_fill
|
|
elif value in ('ATENTIE', 'NECESAR'):
|
|
cell.fill = self.warning_fill
|
|
elif value in ('ALERTA', 'DEFICIT', 'RISC MARE'):
|
|
cell.fill = self.alert_fill
|
|
|
|
# Highlight variatie columns (positive = green, negative = red)
|
|
if 'variatie' in col_name:
|
|
if isinstance(value, (int, float)):
|
|
if value > 0:
|
|
cell.fill = self.good_fill
|
|
elif value < 0:
|
|
cell.fill = self.alert_fill
|
|
|
|
# Auto-adjust column widths
|
|
for col_idx, col_name in enumerate(df.columns, 1):
|
|
max_length = len(str(col_name))
|
|
for row in df.itertuples(index=False):
|
|
cell_value = row[col_idx - 1]
|
|
if cell_value:
|
|
max_length = max(max_length, len(str(cell_value)))
|
|
adjusted_width = min(max_length + 2, 50)
|
|
ws.column_dimensions[get_column_letter(col_idx)].width = adjusted_width
|
|
|
|
# Freeze header row
|
|
ws.freeze_panes = ws.cell(row=start_row + 1, column=1)
|
|
|
|
def add_sheet_with_recommendations(self, name: str, df: pd.DataFrame,
|
|
recommendations_df: pd.DataFrame,
|
|
title: str = None, description: str = None,
|
|
legend: dict = None, top_n_recommendations: int = 5):
|
|
"""Adauga sheet formatat cu KPIs si top recomandari dedesubt"""
|
|
sheet_name = name[:31]
|
|
ws = self.wb.create_sheet(title=sheet_name)
|
|
|
|
start_row = 1
|
|
|
|
# Adauga titlu
|
|
if title:
|
|
ws.cell(row=start_row, column=1, value=title)
|
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=14)
|
|
start_row += 1
|
|
|
|
# Adauga descriere
|
|
if description:
|
|
ws.cell(row=start_row, column=1, value=description)
|
|
ws.cell(row=start_row, column=1).font = Font(italic=True, size=10, color='666666')
|
|
start_row += 1
|
|
|
|
# Adauga timestamp
|
|
ws.cell(row=start_row, column=1, value=f"Generat: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
ws.cell(row=start_row, column=1).font = Font(size=9, color='999999')
|
|
start_row += 2
|
|
|
|
# === SECTIUNEA 1: KPIs ===
|
|
if df is not None and not df.empty:
|
|
# Header
|
|
for col_idx, col_name in enumerate(df.columns, 1):
|
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
|
cell.font = self.header_font
|
|
cell.fill = self.header_fill
|
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
|
cell.border = self.border
|
|
|
|
# Date
|
|
for row_idx, row in enumerate(df.itertuples(index=False), start_row + 1):
|
|
for col_idx, value in enumerate(row, 1):
|
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
|
cell.border = self.border
|
|
if isinstance(value, (int, float)):
|
|
cell.number_format = '#,##0.00' if isinstance(value, float) else '#,##0'
|
|
cell.alignment = Alignment(horizontal='right')
|
|
|
|
start_row = start_row + len(df) + 3
|
|
|
|
# === SECTIUNEA 2: TOP RECOMANDARI ===
|
|
if recommendations_df is not None and not recommendations_df.empty:
|
|
ws.cell(row=start_row, column=1, value="Top Recomandari Prioritare")
|
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=12, color='366092')
|
|
start_row += 1
|
|
|
|
# Sorteaza dupa prioritate (ALERTA primul, apoi ATENTIE, apoi OK)
|
|
df_sorted = recommendations_df.copy()
|
|
status_order = {'ALERTA': 0, 'ATENTIE': 1, 'OK': 2}
|
|
df_sorted['_order'] = df_sorted['STATUS'].map(status_order).fillna(3)
|
|
df_sorted = df_sorted.sort_values('_order').head(top_n_recommendations)
|
|
df_sorted = df_sorted.drop(columns=['_order'])
|
|
|
|
# Coloane de afisat
|
|
display_cols = ['STATUS', 'CATEGORIE', 'INDICATOR', 'VALOARE', 'RECOMANDARE']
|
|
display_cols = [c for c in display_cols if c in df_sorted.columns]
|
|
|
|
# Header cu background mov
|
|
rec_header_fill = PatternFill(start_color='8E44AD', end_color='8E44AD', fill_type='solid')
|
|
for col_idx, col_name in enumerate(display_cols, 1):
|
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
|
cell.font = self.header_font
|
|
cell.fill = rec_header_fill
|
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
|
cell.border = self.border
|
|
|
|
# Randuri cu colorare dupa status
|
|
for row_idx, (_, row) in enumerate(df_sorted.iterrows(), start_row + 1):
|
|
status = row.get('STATUS', 'OK')
|
|
for col_idx, col_name in enumerate(display_cols, 1):
|
|
value = row.get(col_name, '')
|
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
|
cell.border = self.border
|
|
cell.alignment = Alignment(wrap_text=True)
|
|
|
|
# Colorare conditionala
|
|
if status == 'ALERTA':
|
|
cell.fill = PatternFill(start_color='FADBD8', end_color='FADBD8', fill_type='solid')
|
|
elif status == 'ATENTIE':
|
|
cell.fill = PatternFill(start_color='FCF3CF', end_color='FCF3CF', fill_type='solid')
|
|
else:
|
|
cell.fill = PatternFill(start_color='D5F5E3', end_color='D5F5E3', fill_type='solid')
|
|
|
|
# Auto-adjust latime coloane
|
|
for col_idx in range(1, 8):
|
|
ws.column_dimensions[get_column_letter(col_idx)].width = 22
|
|
|
|
ws.freeze_panes = ws.cell(row=5, column=1)
|
|
|
|
def add_consolidated_sheet(self, name: str, sections: list, sheet_title: str = None,
|
|
sheet_description: str = None):
|
|
"""
|
|
Add a consolidated sheet with multiple sections separated visually.
|
|
|
|
Args:
|
|
name: Sheet name (max 31 chars)
|
|
sections: List of dicts with keys:
|
|
- 'title': Section title (str)
|
|
- 'df': DataFrame with data
|
|
- 'description': Optional section description (str)
|
|
- 'legend': Optional dict with column explanations
|
|
sheet_title: Overall sheet title
|
|
sheet_description: Overall sheet description
|
|
"""
|
|
sheet_name = name[:31]
|
|
ws = self.wb.create_sheet(title=sheet_name)
|
|
|
|
start_row = 1
|
|
|
|
# Add overall sheet title
|
|
if sheet_title:
|
|
ws.cell(row=start_row, column=1, value=sheet_title)
|
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=16)
|
|
start_row += 1
|
|
|
|
# Add overall description
|
|
if sheet_description:
|
|
ws.cell(row=start_row, column=1, value=sheet_description)
|
|
ws.cell(row=start_row, column=1).font = Font(italic=True, size=10, color='666666')
|
|
start_row += 1
|
|
|
|
# Add timestamp
|
|
ws.cell(row=start_row, column=1, value=f"Generat: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
ws.cell(row=start_row, column=1).font = Font(size=9, color='999999')
|
|
start_row += 2
|
|
|
|
# Process each section
|
|
for section in sections:
|
|
section_title = section.get('title', '')
|
|
df = section.get('df')
|
|
description = section.get('description', '')
|
|
explanation = section.get('explanation', '')
|
|
legend = section.get('legend', {})
|
|
|
|
# Section separator
|
|
separator_fill = PatternFill(start_color='2C3E50', end_color='2C3E50', fill_type='solid')
|
|
for col in range(1, 10): # Wide separator
|
|
# Use >>> instead of === to avoid Excel formula interpretation
|
|
cell = ws.cell(row=start_row, column=col, value='' if col > 1 else f'>>> {section_title}')
|
|
cell.fill = separator_fill
|
|
cell.font = Font(bold=True, color='FFFFFF', size=11)
|
|
start_row += 1
|
|
|
|
# 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')
|
|
start_row += 1
|
|
|
|
start_row += 1
|
|
|
|
# Check for empty data
|
|
if df is None or df.empty:
|
|
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
|
|
|
|
# Write headers
|
|
for col_idx, col_name in enumerate(df.columns, 1):
|
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
|
cell.font = self.header_font
|
|
cell.fill = self.header_fill
|
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
|
cell.border = self.border
|
|
|
|
# Write data
|
|
for row_idx, row in enumerate(df.itertuples(index=False), start_row + 1):
|
|
for col_idx, value in enumerate(row, 1):
|
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
|
cell.border = self.border
|
|
|
|
# Format numbers
|
|
if isinstance(value, (int, float)):
|
|
cell.number_format = '#,##0.00' if isinstance(value, float) else '#,##0'
|
|
cell.alignment = Alignment(horizontal='right')
|
|
|
|
# Highlight based on column name
|
|
col_name = df.columns[col_idx - 1].lower()
|
|
|
|
# Status coloring
|
|
if col_name == 'status' or col_name == 'acoperire':
|
|
if isinstance(value, str):
|
|
if value == 'OK':
|
|
cell.fill = self.good_fill
|
|
elif value in ('ATENTIE', 'NECESAR'):
|
|
cell.fill = self.warning_fill
|
|
elif value in ('ALERTA', 'DEFICIT', 'RISC MARE'):
|
|
cell.fill = self.alert_fill
|
|
|
|
# Trend coloring
|
|
if col_name == 'trend':
|
|
if isinstance(value, str):
|
|
if value in ('CRESTERE', 'IMBUNATATIRE', 'DIVERSIFICARE'):
|
|
cell.fill = self.good_fill
|
|
elif value in ('SCADERE', 'DETERIORARE', 'CONCENTRARE', 'PIERDUT'):
|
|
cell.fill = self.alert_fill
|
|
elif value == 'ATENTIE':
|
|
cell.fill = self.warning_fill
|
|
|
|
# Variatie coloring
|
|
if 'variatie' in col_name:
|
|
if isinstance(value, (int, float)):
|
|
if value > 0:
|
|
cell.fill = self.good_fill
|
|
elif value < 0:
|
|
cell.fill = self.alert_fill
|
|
|
|
# Margin coloring
|
|
if 'procent' in col_name or 'marja' in col_name:
|
|
if isinstance(value, (int, float)):
|
|
if value < 10:
|
|
cell.fill = self.alert_fill
|
|
elif value < 15:
|
|
cell.fill = self.warning_fill
|
|
elif value > 25:
|
|
cell.fill = self.good_fill
|
|
|
|
start_row = start_row + len(df) + 2
|
|
|
|
# Add legend for this section
|
|
if legend:
|
|
ws.cell(row=start_row, column=1, value="Legendă:")
|
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=8, color='336699')
|
|
start_row += 1
|
|
for col_name, explanation in legend.items():
|
|
ws.cell(row=start_row, column=1, value=f"• {col_name}: {explanation}")
|
|
ws.cell(row=start_row, column=1).font = Font(size=8, color='666666')
|
|
start_row += 1
|
|
|
|
# Space between sections
|
|
start_row += 2
|
|
|
|
# Auto-adjust column widths
|
|
for col_idx in range(1, 12):
|
|
ws.column_dimensions[get_column_letter(col_idx)].width = 18
|
|
|
|
# Freeze title row
|
|
ws.freeze_panes = ws.cell(row=5, column=1)
|
|
|
|
def save(self):
|
|
"""Save the workbook"""
|
|
self.wb.save(self.output_path)
|
|
print(f"✓ Excel salvat: {self.output_path}")
|
|
|
|
|
|
class PDFReportGenerator:
|
|
"""Generate PDF executive summary with charts"""
|
|
|
|
def __init__(self, output_path: Path, company_name: str = "Data Intelligence Report"):
|
|
self.output_path = output_path
|
|
self.company_name = company_name
|
|
self.elements = []
|
|
self.styles = getSampleStyleSheet()
|
|
|
|
# Custom styles
|
|
self.styles.add(ParagraphStyle(
|
|
name='CustomTitle',
|
|
parent=self.styles['Title'],
|
|
fontSize=24,
|
|
spaceAfter=30,
|
|
alignment=TA_CENTER
|
|
))
|
|
self.styles.add(ParagraphStyle(
|
|
name='SectionHeader',
|
|
parent=self.styles['Heading1'],
|
|
fontSize=14,
|
|
spaceBefore=20,
|
|
spaceAfter=10,
|
|
textColor=colors.HexColor('#366092')
|
|
))
|
|
self.styles.add(ParagraphStyle(
|
|
name='AlertHeader',
|
|
parent=self.styles['Heading2'],
|
|
fontSize=12,
|
|
textColor=colors.red,
|
|
spaceBefore=15,
|
|
spaceAfter=8
|
|
))
|
|
self.styles.add(ParagraphStyle(
|
|
name='SmallText',
|
|
parent=self.styles['Normal'],
|
|
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(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 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']))
|
|
|
|
if kpi_df is not None and not kpi_df.empty:
|
|
# 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([
|
|
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'),
|
|
('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']))
|
|
|
|
# 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"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:
|
|
# 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),
|
|
('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)
|
|
|
|
# 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"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:
|
|
# Header with Paragraph
|
|
data = [[self.make_cell_paragraph(c, bold=True) for c in cols_to_show]]
|
|
for _, row in top5.iterrows():
|
|
data.append([
|
|
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),
|
|
('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(remove_diacritics(title), self.styles['SectionHeader']))
|
|
|
|
# Save figure to buffer
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf, format='png', dpi=200, bbox_inches='tight')
|
|
buf.seek(0)
|
|
|
|
# Add to PDF
|
|
img = Image(buf, width=16*cm, height=10*cm)
|
|
self.elements.append(img)
|
|
self.elements.append(Spacer(1, 0.5*cm))
|
|
|
|
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 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 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 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(self.make_cell_paragraph(f"{val:,.2f}"))
|
|
elif isinstance(val, int):
|
|
row_data.append(self.make_cell_paragraph(f"{val:,}"))
|
|
else:
|
|
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'),
|
|
('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"... 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):
|
|
"""Add page break"""
|
|
self.elements.append(PageBreak())
|
|
|
|
def add_consolidated_page(self, page_title: str, sections: list):
|
|
"""
|
|
Add a consolidated PDF page with multiple sections and word-wrapped cells.
|
|
|
|
Args:
|
|
page_title: Main title for the page
|
|
sections: List of dicts with keys:
|
|
- 'title': Section title (str)
|
|
- 'df': DataFrame with data
|
|
- 'columns': List of columns to display (optional)
|
|
- 'max_rows': Max rows to display (default 15)
|
|
"""
|
|
# Page title
|
|
self.elements.append(Paragraph(remove_diacritics(page_title), self.styles['SectionHeader']))
|
|
self.elements.append(Spacer(1, 0.3*cm))
|
|
|
|
for section in sections:
|
|
section_title = section.get('title', '')
|
|
df = section.get('df')
|
|
columns = section.get('columns')
|
|
max_rows = section.get('max_rows', 15)
|
|
|
|
# Sub-section title
|
|
subsection_style = ParagraphStyle(
|
|
name='SubSection',
|
|
parent=self.styles['Heading2'],
|
|
fontSize=11,
|
|
spaceBefore=10,
|
|
spaceAfter=5,
|
|
textColor=colors.HexColor('#2C3E50')
|
|
)
|
|
self.elements.append(Paragraph(remove_diacritics(section_title), subsection_style))
|
|
|
|
if df is None or df.empty:
|
|
self.elements.append(Paragraph("Nu exista date.", self.styles['Normal']))
|
|
self.elements.append(Spacer(1, 0.3*cm))
|
|
continue
|
|
|
|
# Select columns
|
|
if columns:
|
|
cols = [c for c in columns if c in df.columns]
|
|
else:
|
|
cols = list(df.columns)[:6] # Max 6 columns
|
|
|
|
if not cols:
|
|
continue
|
|
|
|
# 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(self.make_cell_paragraph(f"{val:,.2f}"))
|
|
elif isinstance(val, int):
|
|
row_data.append(self.make_cell_paragraph(f"{val:,}"))
|
|
else:
|
|
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)
|
|
|
|
# Build style with conditional row colors for status
|
|
table_style = [
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#366092')),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
|
('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')])
|
|
]
|
|
|
|
# Color status cells if STATUS column exists
|
|
if 'STATUS' in cols:
|
|
status_col_idx = cols.index('STATUS')
|
|
for row_idx, row in enumerate(df.head(max_rows).itertuples(index=False), 1):
|
|
status_val = str(row[df.columns.get_loc('STATUS')]) if 'STATUS' in df.columns else ''
|
|
if status_val == 'ALERTA':
|
|
table_style.append(('BACKGROUND', (status_col_idx, row_idx), (status_col_idx, row_idx), colors.HexColor('#FF6B6B')))
|
|
elif status_val == 'ATENTIE':
|
|
table_style.append(('BACKGROUND', (status_col_idx, row_idx), (status_col_idx, row_idx), colors.HexColor('#FFE66D')))
|
|
elif status_val == 'OK':
|
|
table_style.append(('BACKGROUND', (status_col_idx, row_idx), (status_col_idx, row_idx), colors.HexColor('#4ECDC4')))
|
|
|
|
table.setStyle(TableStyle(table_style))
|
|
self.elements.append(table)
|
|
|
|
if len(df) > max_rows:
|
|
self.elements.append(Paragraph(
|
|
f"... si inca {len(df) - max_rows} inregistrari",
|
|
self.styles['SmallText']
|
|
))
|
|
|
|
self.elements.append(Spacer(1, 0.4*cm))
|
|
|
|
def add_recommendations_section(self, recommendations_df: pd.DataFrame):
|
|
"""Add recommendations section with status colors"""
|
|
self.elements.append(Paragraph("Recomandari Cheie", self.styles['SectionHeader']))
|
|
|
|
if recommendations_df is None or recommendations_df.empty:
|
|
self.elements.append(Paragraph("Nu au fost generate recomandari.", self.styles['Normal']))
|
|
return
|
|
|
|
# Show top 7 most important recommendations (ALERTA first, then ATENTIE)
|
|
df_sorted = recommendations_df.copy()
|
|
status_order = {'ALERTA': 0, 'ATENTIE': 1, 'OK': 2}
|
|
df_sorted['_order'] = df_sorted['STATUS'].map(status_order)
|
|
df_sorted = df_sorted.sort_values('_order').head(7)
|
|
|
|
for _, row in df_sorted.iterrows():
|
|
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':
|
|
status_color = colors.HexColor('#c0392b')
|
|
bg_color = colors.HexColor('#fadbd8')
|
|
elif status == 'ATENTIE':
|
|
status_color = colors.HexColor('#d68910')
|
|
bg_color = colors.HexColor('#fef9e7')
|
|
else:
|
|
status_color = colors.HexColor('#27ae60')
|
|
bg_color = colors.HexColor('#d5f5e3')
|
|
|
|
# Create a small table for each recommendation
|
|
data = [
|
|
[f"[{status}] {indicator}: {valoare}"],
|
|
[explicatie],
|
|
[f"Actiune: {recomandare}"]
|
|
]
|
|
|
|
table = Table(data, colWidths=[16*cm])
|
|
table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), status_color),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
|
('BACKGROUND', (0, 1), (-1, -1), bg_color),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
|
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 8),
|
|
]))
|
|
self.elements.append(table)
|
|
self.elements.append(Spacer(1, 0.2*cm))
|
|
|
|
self.elements.append(Spacer(1, 0.3*cm))
|
|
|
|
|
|
def save(self):
|
|
"""Generate and save PDF"""
|
|
doc = SimpleDocTemplate(
|
|
str(self.output_path),
|
|
pagesize=A4,
|
|
rightMargin=2*cm,
|
|
leftMargin=2*cm,
|
|
topMargin=2*cm,
|
|
bottomMargin=2*cm
|
|
)
|
|
doc.build(self.elements)
|
|
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 chart - modern minimalist style"""
|
|
setup_chart_style()
|
|
if df is None or df.empty:
|
|
fig, ax = plt.subplots(figsize=(12, 5))
|
|
ax.text(0.5, 0.5, 'Nu exista date', ha='center', va='center')
|
|
return fig
|
|
|
|
fig, ax = plt.subplots(figsize=(12, 5))
|
|
|
|
x = range(len(df))
|
|
|
|
# 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)
|
|
|
|
# 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')
|
|
|
|
# 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)
|
|
|
|
# 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'])
|
|
|
|
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 chart - horizontal bars (easier to read than pie)"""
|
|
setup_chart_style()
|
|
if df is None or df.empty:
|
|
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))
|
|
|
|
top10 = df.head(10)
|
|
|
|
# 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']]
|
|
|
|
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')
|
|
|
|
# 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')
|
|
|
|
# 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(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(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 - modern minimalist style"""
|
|
setup_chart_style()
|
|
if df is None or df.empty:
|
|
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))
|
|
|
|
x = range(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, 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'))
|
|
|
|
# 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, 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 - modern minimalist style"""
|
|
setup_chart_style()
|
|
if df is None or df.empty:
|
|
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, 5))
|
|
|
|
# Extract values
|
|
indicators = df['INDICATOR'].tolist() if 'INDICATOR' in df.columns else []
|
|
zile = df['ZILE'].tolist() if 'ZILE' in df.columns else []
|
|
|
|
if not indicators or not zile:
|
|
ax1.text(0.5, 0.5, 'Date incomplete', ha='center', va='center')
|
|
ax2.text(0.5, 0.5, 'Date incomplete', ha='center', va='center')
|
|
return fig
|
|
|
|
# Simplified color scheme
|
|
colors_map = {
|
|
'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 = []
|
|
for ind in indicators:
|
|
for key, color in colors_map.items():
|
|
if key in ind.upper():
|
|
bar_colors.append(color)
|
|
break
|
|
else:
|
|
bar_colors.append(CHART_COLORS['secondary'])
|
|
|
|
# Left: Component bars
|
|
x = range(len(indicators))
|
|
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', fontsize=10)
|
|
ax1.set_title('Ciclu Conversie Cash - Componente', fontsize=11, fontweight='bold')
|
|
|
|
# Add value labels
|
|
for bar, val in zip(bars, zile):
|
|
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')
|
|
|
|
# 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
|
|
|
|
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', 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')
|
|
|
|
# Status annotation
|
|
if ccc > 60:
|
|
verdict = "Ciclu lung - capital blocat mult timp"
|
|
verdict_color = CHART_COLORS['accent']
|
|
elif ccc > 30:
|
|
verdict = "Ciclu moderat - poate fi optimizat"
|
|
verdict_color = CHART_COLORS['secondary']
|
|
else:
|
|
verdict = "Ciclu eficient - capital rotit rapid"
|
|
verdict_color = CHART_COLORS['positive']
|
|
|
|
ax2.text(0.5, -0.18, verdict, transform=ax2.transAxes,
|
|
ha='center', fontsize=10, color=verdict_color, fontweight='bold')
|
|
|
|
plt.tight_layout()
|
|
return fig
|