Files
vending_data_intelligence_r…/recommendations.py
Marius Mutu 0b732f7a7a 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>
2025-12-02 15:41:56 +02:00

582 lines
28 KiB
Python

"""
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