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