Initial commit: Data Intelligence Report Generator

- Oracle ERP ROA integration with sales analytics and margin analysis
- Excel multi-sheet reports with conditional formatting
- PDF executive summaries with charts via ReportLab
- Optimized SQL queries (no cartesian products)
- Docker support for cross-platform deployment
- Configurable alert thresholds for business intelligence

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 15:41:56 +02:00
commit 0b732f7a7a
15 changed files with 5420 additions and 0 deletions

581
recommendations.py Normal file
View File

@@ -0,0 +1,581 @@
"""
Recommendations Engine Module
Generates automatic business recommendations based on data analysis
"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional
import pandas as pd
@dataclass
class Recommendation:
"""Single business recommendation with context"""
categorie: str # Marja | Clienti | Stoc | Financiar
indicator: str # Indicator name
valoare: str # Current value
status: str # OK | ATENTIE | ALERTA
status_icon: str # OK | ATENTIE | ALERTA (for Excel without emojis)
explicatie: str # Explanation for entrepreneur
recomandare: str # Action suggestion
vezi_detalii: str # Reference to detail sheet
class RecommendationsEngine:
"""Engine that analyzes data and generates recommendations"""
def __init__(self, thresholds: dict):
"""Initialize with threshold configuration"""
self.thresholds = thresholds
self.recommendations: List[Recommendation] = []
def analyze_all(self, results: Dict[str, pd.DataFrame]) -> pd.DataFrame:
"""Run all analyses and return recommendations as DataFrame"""
self.recommendations = []
# Run all analysis modules
self._analyze_margin(results)
self._analyze_clients(results)
self._analyze_stock(results)
self._analyze_financial(results)
self._analyze_general(results)
self._analyze_liquidity(results)
# Convert to DataFrame
if not self.recommendations:
return pd.DataFrame(columns=[
'CATEGORIE', 'INDICATOR', 'VALOARE', 'STATUS',
'EXPLICATIE', 'RECOMANDARE', 'VEZI_DETALII'
])
return pd.DataFrame([
{
'CATEGORIE': r.categorie,
'INDICATOR': r.indicator,
'VALOARE': r.valoare,
'STATUS': r.status_icon,
'EXPLICATIE': r.explicatie,
'RECOMANDARE': r.recomandare,
'VEZI_DETALII': r.vezi_detalii
}
for r in self.recommendations
])
def _add_recommendation(self, categorie: str, indicator: str, valoare: str,
status: str, explicatie: str, recomandare: str,
vezi_detalii: str):
"""Add a recommendation with proper status icon"""
status_icons = {
'OK': 'OK',
'ATENTIE': 'ATENTIE',
'ALERTA': 'ALERTA'
}
self.recommendations.append(Recommendation(
categorie=categorie,
indicator=indicator,
valoare=valoare,
status=status,
status_icon=status_icons.get(status, status),
explicatie=explicatie,
recomandare=recomandare,
vezi_detalii=vezi_detalii
))
def _analyze_margin(self, results: Dict[str, pd.DataFrame]):
"""Analyze margin indicators"""
# 1. Check average margin from sumar_executiv
sumar = results.get('sumar_executiv')
if sumar is not None and not sumar.empty:
marja_row = sumar[sumar['INDICATOR'].str.contains('marj', case=False, na=False)]
if not marja_row.empty:
try:
marja_val = float(str(marja_row['VALOARE'].iloc[0]).strip().replace(',', '.'))
marja_tinta = self.thresholds.get('marja_tinta', 20)
marja_minima = self.thresholds.get('marja_minima', 15)
if marja_val < marja_minima:
self._add_recommendation(
categorie='Marja',
indicator='Marja medie globala',
valoare=f'{marja_val:.1f}%',
status='ALERTA',
explicatie=f'Marja medie de {marja_val:.1f}% este sub pragul minim de {marja_minima}%',
recomandare='Revizuieste urgent preturile sau renegociaza cu furnizorii',
vezi_detalii='Sheet: Marja Per Client'
)
elif marja_val < marja_tinta:
self._add_recommendation(
categorie='Marja',
indicator='Marja medie globala',
valoare=f'{marja_val:.1f}%',
status='ATENTIE',
explicatie=f'Marja medie de {marja_val:.1f}% este sub tinta de {marja_tinta}%',
recomandare='Analizeaza clientii cu marja mica si negociaza preturi',
vezi_detalii='Sheet: Clienti Marja Mica'
)
except (ValueError, IndexError):
pass
# 2. Check sales below cost
vanzari_sub_cost = results.get('vanzari_sub_cost')
if vanzari_sub_cost is not None and not vanzari_sub_cost.empty:
count = len(vanzari_sub_cost)
total_pierdere = abs(vanzari_sub_cost['PIERDERE'].sum()) if 'PIERDERE' in vanzari_sub_cost.columns else 0
self._add_recommendation(
categorie='Marja',
indicator='Vanzari sub cost',
valoare=f'{count} tranzactii',
status='ALERTA',
explicatie=f'{count} produse vandute sub cost cu pierdere totala de {total_pierdere:,.0f} RON',
recomandare='Verifica preturile si opreste vanzarile in pierdere',
vezi_detalii='Sheet: Vanzari Sub Cost'
)
# 3. Check clients with low margin
clienti_marja_mica = results.get('clienti_marja_mica')
if clienti_marja_mica is not None and not clienti_marja_mica.empty:
count = len(clienti_marja_mica)
total_vanzari = clienti_marja_mica['VANZARI_FARA_TVA'].sum() if 'VANZARI_FARA_TVA' in clienti_marja_mica.columns else 0
self._add_recommendation(
categorie='Marja',
indicator='Clienti cu marja mica',
valoare=f'{count} clienti',
status='ATENTIE',
explicatie=f'{count} clienti cu marja sub 15% totalizeaza {total_vanzari:,.0f} RON vanzari',
recomandare='Renegociaza preturile sau ajusteaza conditiile comerciale',
vezi_detalii='Sheet: Clienti Marja Mica'
)
def _analyze_clients(self, results: Dict[str, pd.DataFrame]):
"""Analyze client-related indicators"""
# 1. Check client concentration risk
concentrare_risc = results.get('concentrare_risc')
if concentrare_risc is not None and not concentrare_risc.empty:
# Top 1 client concentration
top1_max = self.thresholds.get('concentrare_top1_max', 25)
top5_max = self.thresholds.get('concentrare_top5_max', 60)
top1_row = concentrare_risc[concentrare_risc['INDICATOR'].str.contains('Top 1', case=False, na=False)]
if not top1_row.empty:
try:
top1_val = float(top1_row['PROCENT'].iloc[0])
if top1_val > top1_max:
self._add_recommendation(
categorie='Clienti',
indicator='Concentrare Top 1 client',
valoare=f'{top1_val:.1f}%',
status='ALERTA',
explicatie=f'Un singur client reprezinta {top1_val:.1f}% din vanzari (risc major)',
recomandare='Diversifica portofoliul urgent, atrage clienti noi',
vezi_detalii='Sheet: Concentrare Risc'
)
except (ValueError, IndexError):
pass
top5_row = concentrare_risc[concentrare_risc['INDICATOR'].str.contains('Top 5', case=False, na=False)]
if not top5_row.empty:
try:
top5_val = float(top5_row['PROCENT'].iloc[0])
if top5_val > top5_max:
self._add_recommendation(
categorie='Clienti',
indicator='Concentrare Top 5 clienti',
valoare=f'{top5_val:.1f}%',
status='ATENTIE',
explicatie=f'Doar 5 clienti genereaza {top5_val:.1f}% din vanzari',
recomandare='Reduce dependenta de clientii mari prin diversificare',
vezi_detalii='Sheet: Concentrare Risc'
)
except (ValueError, IndexError):
pass
# 2. Check from concentrare_clienti if concentrare_risc not available
concentrare_clienti = results.get('concentrare_clienti')
if concentrare_clienti is not None and not concentrare_clienti.empty:
top5_max = self.thresholds.get('concentrare_top5_max', 60)
if len(concentrare_clienti) >= 5 and 'PROCENT_CUMULAT' in concentrare_clienti.columns:
top5_pct = concentrare_clienti['PROCENT_CUMULAT'].iloc[4] if len(concentrare_clienti) > 4 else 0
if top5_pct > top5_max and not any(r.indicator == 'Concentrare Top 5 clienti' for r in self.recommendations):
self._add_recommendation(
categorie='Clienti',
indicator='Concentrare Top 5 clienti',
valoare=f'{top5_pct:.1f}%',
status='ATENTIE',
explicatie=f'{top5_pct:.1f}% din vanzari vin de la doar 5 clienti',
recomandare='Diversifica portofoliul, atrage clienti noi',
vezi_detalii='Sheet: Concentrare Clienti'
)
# 3. Check portfolio health
portofoliu = results.get('portofoliu_clienti')
if portofoliu is not None and not portofoliu.empty:
clienti_pierduti = portofoliu[portofoliu['INDICATOR'].str.contains('pierdut', case=False, na=False)]
if not clienti_pierduti.empty:
try:
pierduti_val = int(clienti_pierduti['VALOARE'].iloc[0])
if pierduti_val > 5:
self._add_recommendation(
categorie='Clienti',
indicator='Clienti pierduti',
valoare=f'{pierduti_val} clienti',
status='ATENTIE',
explicatie=f'{pierduti_val} clienti nu au mai cumparat de peste 6 luni',
recomandare='Contacteaza clientii inactivi pentru reactivare',
vezi_detalii='Sheet: Portofoliu Clienti'
)
except (ValueError, IndexError):
pass
# 4. Check trending clients for negative trends
trending = results.get('trending_clienti')
if trending is not None and not trending.empty and 'TREND' in trending.columns:
pierduti = trending[trending['TREND'] == 'PIERDUT']
scadere = trending[trending['TREND'] == 'SCADERE']
if len(pierduti) > 3:
self._add_recommendation(
categorie='Clienti',
indicator='Clienti pierduti YoY',
valoare=f'{len(pierduti)} clienti',
status='ALERTA',
explicatie=f'{len(pierduti)} clienti activi anul trecut nu au mai cumparat',
recomandare='Investigheaza motivele pierderii si incearca recuperarea',
vezi_detalii='Sheet: Trending Clienti'
)
if len(scadere) > 5:
self._add_recommendation(
categorie='Clienti',
indicator='Clienti in scadere',
valoare=f'{len(scadere)} clienti',
status='ATENTIE',
explicatie=f'{len(scadere)} clienti au scazut achizitiile cu peste 20%',
recomandare='Contacteaza clientii pentru a intelege motivele',
vezi_detalii='Sheet: Trending Clienti'
)
def _analyze_stock(self, results: Dict[str, pd.DataFrame]):
"""Analyze stock-related indicators"""
# 1. Check slow moving stock
stoc_lent = results.get('stoc_lent')
if stoc_lent is not None and not stoc_lent.empty:
stoc_zile_max = self.thresholds.get('stoc_zile_max', 90)
count = len(stoc_lent)
total_valoare = stoc_lent['VALOARE'].sum() if 'VALOARE' in stoc_lent.columns else 0
if count > 10 or total_valoare > 50000:
self._add_recommendation(
categorie='Stoc',
indicator='Stoc lent',
valoare=f'{total_valoare:,.0f} RON',
status='ATENTIE',
explicatie=f'{count} produse fara miscare de peste {stoc_zile_max} zile, valoare {total_valoare:,.0f} RON',
recomandare='Lichideaza stocul lent prin promotii sau retururi la furnizori',
vezi_detalii='Sheet: Stoc Lent'
)
# 2. Check stock rotation
rotatie = results.get('rotatie_stocuri')
if rotatie is not None and not rotatie.empty and 'ZILE_STOC' in rotatie.columns:
stoc_foarte_lent = rotatie[rotatie['ZILE_STOC'] > 365]
if len(stoc_foarte_lent) > 5:
total_val = stoc_foarte_lent['VALOARE_STOC'].sum() if 'VALOARE_STOC' in stoc_foarte_lent.columns else 0
self._add_recommendation(
categorie='Stoc',
indicator='Rotatie foarte lenta',
valoare=f'{len(stoc_foarte_lent)} produse',
status='ALERTA',
explicatie=f'{len(stoc_foarte_lent)} produse cu stoc pentru peste 1 an, valoare {total_val:,.0f} RON',
recomandare='Revizuieste politica de aprovizionare si lichideaza surplusul',
vezi_detalii='Sheet: Rotatie Stocuri'
)
# 3. Check cash conversion cycle
ciclu = results.get('ciclu_conversie_cash')
if ciclu is not None and not ciclu.empty:
dio_row = ciclu[ciclu['INDICATOR'].str.contains('stoc|DIO', case=False, na=False)]
if not dio_row.empty:
try:
dio_val = float(dio_row['ZILE'].iloc[0])
if dio_val > 90:
self._add_recommendation(
categorie='Stoc',
indicator='Zile stoc (DIO)',
valoare=f'{dio_val:.0f} zile',
status='ATENTIE',
explicatie=f'Stocul sta in medie {dio_val:.0f} zile inainte de vanzare',
recomandare='Optimizeaza aprovizionarea pentru rotatie mai rapida',
vezi_detalii='Sheet: Ciclu Conversie Cash'
)
except (ValueError, IndexError):
pass
def _analyze_financial(self, results: Dict[str, pd.DataFrame]):
"""Analyze financial indicators"""
dso_target = self.thresholds.get('dso_target', 45)
dso_alert = self.thresholds.get('dso_alert', 60)
restante_90_procent = self.thresholds.get('restante_90_procent', 10)
# 1. Check DSO (Days Sales Outstanding)
dso_dpo = results.get('dso_dpo')
if dso_dpo is not None and not dso_dpo.empty:
dso_row = dso_dpo[dso_dpo['INDICATOR'].str.contains('DSO|incasare', case=False, na=False)]
if not dso_row.empty:
try:
dso_val = float(dso_row['ZILE'].iloc[0])
if dso_val > dso_alert:
self._add_recommendation(
categorie='Financiar',
indicator='DSO (Zile incasare)',
valoare=f'{dso_val:.0f} zile',
status='ALERTA',
explicatie=f'Incasezi in medie in {dso_val:.0f} zile, cu mult peste tinta de {dso_target} zile',
recomandare='Implementeaza urmarire stricta a incasarilor si penalitati',
vezi_detalii='Sheet: DSO DPO'
)
elif dso_val > dso_target:
self._add_recommendation(
categorie='Financiar',
indicator='DSO (Zile incasare)',
valoare=f'{dso_val:.0f} zile',
status='ATENTIE',
explicatie=f'Incasezi in medie in {dso_val:.0f} zile, peste tinta de {dso_target} zile',
recomandare='Ofera discount pentru plata rapida (ex: 2% la 10 zile)',
vezi_detalii='Sheet: DSO DPO'
)
except (ValueError, IndexError):
pass
# 2. Check aging receivables
aging = results.get('aging_creante')
if aging is not None and not aging.empty:
if 'PESTE_90_ZILE' in aging.columns and 'TOTAL_SOLD' in aging.columns:
total_creante = aging['TOTAL_SOLD'].sum()
peste_90 = aging['PESTE_90_ZILE'].sum()
if total_creante > 0:
procent_90 = (peste_90 / total_creante) * 100
if procent_90 > restante_90_procent:
self._add_recommendation(
categorie='Financiar',
indicator='Creante >90 zile',
valoare=f'{peste_90:,.0f} RON ({procent_90:.1f}%)',
status='ALERTA',
explicatie=f'{procent_90:.1f}% din creante sunt restante peste 90 zile',
recomandare='Initiaza proceduri de recuperare pentru restantele vechi',
vezi_detalii='Sheet: Aging Creante'
)
# 3. Check overdue invoices
facturi_restante = results.get('facturi_restante')
if facturi_restante is not None and not facturi_restante.empty:
count = len(facturi_restante)
total_restant = facturi_restante['SUMA_RESTANTA'].sum() if 'SUMA_RESTANTA' in facturi_restante.columns else 0
if count > 0:
zile_max = facturi_restante['ZILE_INTARZIERE'].max() if 'ZILE_INTARZIERE' in facturi_restante.columns else 0
status = 'ALERTA' if zile_max > 90 or total_restant > 100000 else 'ATENTIE'
self._add_recommendation(
categorie='Financiar',
indicator='Facturi restante',
valoare=f'{total_restant:,.0f} RON',
status=status,
explicatie=f'{count} facturi restante, cea mai veche de {zile_max:.0f} zile',
recomandare='Prioritizeaza incasarea facturilor cele mai vechi',
vezi_detalii='Sheet: Facturi Restante'
)
# 4. Check cash conversion cycle
ciclu = results.get('ciclu_conversie_cash')
if ciclu is not None and not ciclu.empty:
ciclu_row = ciclu[ciclu['INDICATOR'].str.contains('ciclu|total|CCC', case=False, na=False)]
if not ciclu_row.empty:
try:
ciclu_val = float(ciclu_row['ZILE'].iloc[0])
if ciclu_val > 90:
self._add_recommendation(
categorie='Financiar',
indicator='Ciclu conversie cash',
valoare=f'{ciclu_val:.0f} zile',
status='ALERTA',
explicatie=f'Dureaza {ciclu_val:.0f} zile de la plata furnizor pana la incasare',
recomandare='Negociaza termene mai lungi cu furnizorii si mai scurte cu clientii',
vezi_detalii='Sheet: Ciclu Conversie Cash'
)
elif ciclu_val > 60:
self._add_recommendation(
categorie='Financiar',
indicator='Ciclu conversie cash',
valoare=f'{ciclu_val:.0f} zile',
status='ATENTIE',
explicatie=f'Ciclul de conversie cash este de {ciclu_val:.0f} zile',
recomandare='Optimizeaza stocurile si accelereaza incasarile',
vezi_detalii='Sheet: Ciclu Conversie Cash'
)
except (ValueError, IndexError):
pass
# 5. Check client balances for large debtors
solduri_clienti = results.get('solduri_clienti')
if solduri_clienti is not None and not solduri_clienti.empty:
total_creante = solduri_clienti['SOLD_CURENT'].sum() if 'SOLD_CURENT' in solduri_clienti.columns else 0
if total_creante > 0 and len(solduri_clienti) > 0:
top_client = solduri_clienti.iloc[0]
top_sold = top_client.get('SOLD_CURENT', 0)
top_name = top_client.get('CLIENT', 'N/A')
procent_top = (top_sold / total_creante) * 100 if total_creante > 0 else 0
if procent_top > 30:
self._add_recommendation(
categorie='Financiar',
indicator='Concentrare creante',
valoare=f'{procent_top:.1f}% la un client',
status='ATENTIE',
explicatie=f'{top_name[:30]} datoreaza {top_sold:,.0f} RON ({procent_top:.1f}% din total)',
recomandare='Monitorizeaza indeaproape si seteaza limite de credit',
vezi_detalii='Sheet: Solduri Clienti'
)
def _analyze_general(self, results: Dict[str, pd.DataFrame]):
"""Analyze general business indicators (grad indatorare, ROA, autonomie financiara)"""
indicatori_generali = results.get('indicatori_generali')
if indicatori_generali is None or indicatori_generali.empty:
return
# Iterate through each indicator
for _, row in indicatori_generali.iterrows():
try:
indicator = row.get('INDICATOR', '')
valoare = row.get('VALOARE', 0)
status = row.get('STATUS', 'OK')
recomandare_db = row.get('RECOMANDARE', '')
# Skip if no alert needed
if status == 'OK':
continue
# Map indicator to category
if isinstance(valoare, (int, float)):
valoare_str = f'{valoare:.2f}'
else:
valoare_str = str(valoare)
self._add_recommendation(
categorie='General',
indicator=indicator,
valoare=valoare_str,
status=status,
explicatie=row.get('INTERPRETARE', ''),
recomandare=recomandare_db,
vezi_detalii='Sheet: Indicatori Generali'
)
except (ValueError, IndexError, KeyError):
pass
def _analyze_liquidity(self, results: Dict[str, pd.DataFrame]):
"""Analyze liquidity indicators"""
# 1. Check basic liquidity indicators
indicatori_lichiditate = results.get('indicatori_lichiditate')
if indicatori_lichiditate is not None and not indicatori_lichiditate.empty:
for _, row in indicatori_lichiditate.iterrows():
try:
indicator = row.get('INDICATOR', '')
valoare = row.get('VALOARE', 0)
status = row.get('STATUS', 'OK')
recomandare_db = row.get('RECOMANDARE', '')
if status == 'OK':
continue
if isinstance(valoare, (int, float)):
valoare_str = f'{valoare:.2f}'
else:
valoare_str = str(valoare)
self._add_recommendation(
categorie='Lichiditate',
indicator=indicator,
valoare=valoare_str,
status=status,
explicatie=row.get('INTERPRETARE', ''),
recomandare=recomandare_db,
vezi_detalii='Sheet: Indicatori Lichiditate'
)
except (ValueError, IndexError, KeyError):
pass
# 2. Check coverage ratio
grad_acoperire = results.get('grad_acoperire_datorii')
if grad_acoperire is not None and not grad_acoperire.empty:
# Look for the coverage ratio row
acoperire_row = grad_acoperire[grad_acoperire['INDICATOR'].str.contains('GRAD ACOPERIRE', case=False, na=False)]
if not acoperire_row.empty:
try:
acoperire_val = float(acoperire_row['VALOARE'].iloc[0])
acoperire_status = acoperire_row['ACOPERIRE'].iloc[0]
if acoperire_status == 'DEFICIT':
# Look for financing need
necesar_row = grad_acoperire[grad_acoperire['INDICATOR'].str.contains('NECESAR FINANTARE', case=False, na=False)]
necesar_val = float(necesar_row['VALOARE'].iloc[0]) if not necesar_row.empty else 0
self._add_recommendation(
categorie='Lichiditate',
indicator='Grad acoperire datorii 30 zile',
valoare=f'{acoperire_val:.2f}',
status='ALERTA',
explicatie=f'Cash + incasari asteptate nu acopera datoriile scadente. Deficit: {necesar_val:,.0f} RON',
recomandare='Urgentati incasarile sau obtineti finantare pe termen scurt',
vezi_detalii='Sheet: Grad Acoperire Datorii'
)
elif acoperire_status == 'ATENTIE':
self._add_recommendation(
categorie='Lichiditate',
indicator='Grad acoperire datorii 30 zile',
valoare=f'{acoperire_val:.2f}',
status='ATENTIE',
explicatie='Acoperirea datoriilor este la limita',
recomandare='Mentineti rezerve suplimentare pentru imprevaute',
vezi_detalii='Sheet: Grad Acoperire Datorii'
)
except (ValueError, IndexError, KeyError):
pass
# 3. Check cash projection
proiectie = results.get('proiectie_lichiditate')
if proiectie is not None and not proiectie.empty:
for _, row in proiectie.iterrows():
try:
perioada = row.get('PERIOADA', '')
sold = row.get('SOLD_PROIECTAT', 0)
status = row.get('STATUS', 'OK')
if status == 'ALERTA' and 'Proiectie' in perioada:
self._add_recommendation(
categorie='Lichiditate',
indicator=f'Proiectie cash: {perioada}',
valoare=f'{sold:,.0f} RON',
status='ALERTA',
explicatie=f'Sold cash proiectat negativ la {perioada}',
recomandare='Planificati finantare sau accelerati incasarile pentru a evita criza de lichiditate',
vezi_detalii='Sheet: Proiectie Lichiditate'
)
break # Only report first negative projection
except (ValueError, IndexError, KeyError):
pass