diff --git a/config.py b/config.py index 4d8e50b..3d7c2ad 100644 --- a/config.py +++ b/config.py @@ -56,6 +56,7 @@ RECOMMENDATION_THRESHOLDS = { # Stock indicators 'stoc_zile_max': 90, # days - slow stock threshold 'rotatie_minima': 4, # minimum annual rotation + 'aged_stock_371_pct': 0.15, # prag CONCENTRARE pentru stoc 371 >3 ani # Receivables aging 'restante_90_procent': 10, # % - max receivables > 90 days diff --git a/main.py b/main.py index 700e9c5..9a99a6a 100644 --- a/main.py +++ b/main.py @@ -280,6 +280,25 @@ class PerformanceLogger: print(f"\n📝 Log saved to: {log_file}") +def parse_aging_dates(raw: str) -> list: + """Parse --aging-dates CLI value into sorted list of data_referinta datetimes. + + Accepts 'YYYY-MM,YYYY-MM,...' (end-of-month), returns list of first-day-next-month datetimes. + Raises SystemExit on any invalid token so user sees a clear message, not a crash. + """ + dates = [] + for token in raw.split(','): + tok = token.strip() + if not tok: + continue + try: + dates.append(compute_data_referinta(tok)) + except ValueError as e: + print(f"❌ --aging-dates: token invalid '{tok}' (astept YYYY-MM). {e}") + sys.exit(1) + return sorted(set(dates)) + + def compute_data_referinta(end_month_str: str = None) -> datetime: """ Compute reference date (first day of month AFTER the last reporting month). @@ -389,6 +408,11 @@ def generate_reports(args): results = {} perf = PerformanceLogger() # Initialize performance logger + # Parse multi-date aging request (validation before DB connection) + aging_dates_list = [] + if getattr(args, 'aging_dates', None): + aging_dates_list = parse_aging_dates(args.aging_dates) + with OracleConnection() as conn: print("\n📥 Extragere date din Oracle:\n") @@ -398,6 +422,37 @@ def generate_reports(args): results[query_name] = df perf.stop(rows=len(df) if df is not None and not df.empty else 0) + # --- Evolutie multi-data stoc 371 >3 ani (optional) --- + if aging_dates_list: + print(f"\n📥 Extragere evolutie stoc 371 pe {len(aging_dates_list)} date:") + evolutie_rows = [] + sumar_info = QUERIES['stocuri_371_sumar'] + original_param = sumar_info['params'].get('data_referinta') + for dref in aging_dates_list: + perf.start(f"QUERY: stocuri_371_sumar@{dref.strftime('%Y-%m-%d')}") + sumar_info['params']['data_referinta'] = dref + df_e = execute_query(conn, f"stocuri_371_sumar@{dref.strftime('%Y-%m')}", sumar_info) + perf.stop(rows=len(df_e) if df_e is not None else 0) + if df_e is None or df_e.empty: + continue + grand = df_e[df_e['GROUPING_LEVEL'] == 3] + if grand.empty: + continue + row = grand.iloc[0] + val_total = float(row.get('VALOARE_TOTAL', 0) or 0) + val_3 = float(row.get('VAL_PESTE_3_ANI', 0) or 0) + evolutie_rows.append({ + 'DATA_REFERINTA': (dref - timedelta(days=1)).strftime('%Y-%m-%d'), + 'VALOARE_TOTAL': round(val_total, 2), + 'VALOARE_PESTE_3_ANI': round(val_3, 2), + 'PROCENT_PESTE_3_ANI': round(val_3 / val_total * 100, 2) if val_total > 0 else 0.0 + }) + # restore original data_referinta for downstream consumers + sumar_info['params']['data_referinta'] = original_param + results['stocuri_371_evolutie'] = pd.DataFrame(evolutie_rows) + else: + results['stocuri_371_evolutie'] = pd.DataFrame() + # Generate Excel Report print("\n📝 Generare raport Excel...") excel_gen = ExcelReportGenerator(excel_path) @@ -537,6 +592,9 @@ def generate_reports(args): # STOC 'stoc_curent', 'stoc_lent', + 'stocuri_371_sumar', + 'stocuri_371_detaliu', + 'stocuri_371_evolutie', 'rotatie_stocuri', # PRODUCTIE @@ -576,6 +634,28 @@ def generate_reports(args): 'VALOARE': 'Cantitate × preț achiziție', 'ZILE_FARA_MISCARE': 'Zile de la ultima ieșire (dataout) sau intrare' }, + 'stocuri_371_sumar': { + 'GROUPING_LEVEL': '0 = detaliu subgrupa, 1 = subtotal per grupa, 3 = grand total (ROLLUP)', + 'VALOARE_TOTAL': 'Total valoare stoc cont 371 la data de referinta (cost achizitie fara TVA)', + 'VAL_0_6_LUNI': 'Valoare articole cu vechime 0-180 zile', + 'VAL_6_12_LUNI': 'Valoare articole cu vechime 181-365 zile', + 'VAL_1_2_ANI': 'Valoare articole cu vechime 366-730 zile', + 'VAL_2_3_ANI': 'Valoare articole cu vechime 731-1095 zile', + 'VAL_PESTE_3_ANI': 'Valoare articole cu vechime peste 1095 zile (risc haircut bancar)' + }, + 'stocuri_371_detaliu': { + 'CANTITATE': 'Stoc final = cants + cant - cante', + 'VALOARE': 'Cantitate × pret achizitie (cost, fara TVA)', + 'ZILE_VECHIME': '(data_referinta - 1) - NVL(dataout, datain)', + 'ANI_VECHIME': 'zile_vechime / 365', + 'BUCKET_VECHIME': 'Grupare: 0-6 luni / 6-12 luni / 1-2 ani / 2-3 ani / >3 ani' + }, + 'stocuri_371_evolutie': { + 'DATA_REFERINTA': 'End-of-month pentru care s-a calculat snapshot-ul', + 'VALOARE_TOTAL': 'Total stoc 371 la acea data', + 'VALOARE_PESTE_3_ANI': 'Portiunea cu vechime >3 ani (trend an-la-an pentru banca)', + 'PROCENT_PESTE_3_ANI': '% din total care depaseste 3 ani' + }, 'rotatie_stocuri': { 'VALOARE_STOC': 'Stoc curent (cants+cant-cante) × preț achiziție', 'VANZARI_12_LUNI': 'Doar vânzări (nu transferuri/consumuri) din ultimele 12 luni', @@ -916,24 +996,37 @@ def generate_reports(args): 'grad_acoperire_datorii', 'proiectie_lichiditate' } + # Metadata pentru sheet-uri sintetice (nu sunt in QUERIES dar sunt randate) + synthetic_sheets = { + 'stocuri_371_evolutie': { + 'title': 'Stocuri Marfa 371 - Evolutie >3 ani', + 'description': 'Valoare totala 371 si portiune >3 ani la mai multe end-of-month (trend)' + } + } + for query_name in sheet_order: # Skip consolidated sheets and their source sheets if query_name in consolidated_sheets: continue - if query_name in results and query_name in QUERIES: - query_info = QUERIES[query_name] - # Create short sheet name from query name + if query_name in results and (query_name in QUERIES or query_name in synthetic_sheets): + df_sheet = results[query_name] + if df_sheet is None or df_sheet.empty: + continue + if query_name in QUERIES: + meta = QUERIES[query_name] + else: + meta = synthetic_sheets[query_name] sheet_name = query_name.replace('_', ' ').title()[:31] perf.start(f"EXCEL: {query_name} detail sheet") excel_gen.add_sheet( name=sheet_name, - df=results[query_name], - title=query_info.get('title', query_name), - description=query_info.get('description', ''), + df=df_sheet, + title=meta.get('title', query_name), + description=meta.get('description', ''), legend=legends.get(query_name) ) - df_rows = len(results[query_name]) if results[query_name] is not None else 0 + df_rows = len(df_sheet) perf.stop(rows=df_rows) perf.start("EXCEL: Save workbook") @@ -1087,6 +1180,94 @@ def generate_reports(args): max_rows=20 ) + # ========================================================================= + # Pagina dedicata BANCA: Stocuri Marfa 371 pe Vechimi + reconciliere + # ========================================================================= + df_sumar_371 = results.get('stocuri_371_sumar') + if df_sumar_371 is not None and not df_sumar_371.empty: + perf.start("PDF: Stocuri 371 bank page") + pdf_gen.add_page_break() + + nota_metodologica = ( + "METODOLOGIE: Vechimea este calculata ca numar de zile intre data de referinta " + f"({end_date.strftime('%d.%m.%Y')}) si ultima miscare a articolului in stoc " + "(iesire, sau intrare daca nu a existat nicio iesire). Valoarea este costul de " + "achizitie fara TVA (cant × pret din vstoc). Se includ toate pozitiile cu stoc " + "nenul (<> 0), inclusiv cele negative (corectii / erori de inventar), ca sa se " + "reconcilieze corect cu soldul contabil 371 din balanta (vbal, cont sintetic)." + ) + pdf_gen.add_explanation(nota_metodologica) + + pdf_gen.add_table_section( + "Raport Stocuri Marfa 371 - Sumar Vechimi (pentru banca)", + df_sumar_371, + columns=['GRUPA', 'SUBGRUPA', 'VALOARE_TOTAL', 'VAL_0_6_LUNI', + 'VAL_6_12_LUNI', 'VAL_1_2_ANI', 'VAL_2_3_ANI', 'VAL_PESTE_3_ANI'], + max_rows=30 + ) + + df_evolutie_371 = results.get('stocuri_371_evolutie') + if df_evolutie_371 is not None and not df_evolutie_371.empty: + pdf_gen.add_table_section( + "Evolutie stoc 371 >3 ani (multi-data)", + df_evolutie_371, + columns=['DATA_REFERINTA', 'VALOARE_TOTAL', 'VALOARE_PESTE_3_ANI', 'PROCENT_PESTE_3_ANI'], + max_rows=20 + ) + + df_detaliu_371 = results.get('stocuri_371_detaliu') + if df_detaliu_371 is not None and not df_detaliu_371.empty: + pdf_gen.add_table_section( + "Top 25 articole dupa vechime (detaliu 371)", + df_detaliu_371, + columns=['COD_ARTICOL', 'DENUMIRE', 'CANTITATE', 'VALOARE', + 'ZILE_VECHIME', 'BUCKET_VECHIME'], + max_rows=25 + ) + + # Reconciliere contabila + df_sold = results.get('stocuri_371_sold_contabil') + grand_total_row = df_sumar_371[df_sumar_371['GROUPING_LEVEL'] == 3] + total_raport = float(grand_total_row['VALOARE_TOTAL'].iloc[0]) if not grand_total_row.empty else 0.0 + + sold_contabil = None + if df_sold is not None and not df_sold.empty: + val = df_sold['SOLD_371'].iloc[0] + if val is not None: + sold_contabil = float(val) + + if sold_contabil is None: + recon_rows = [ + {'INDICATOR': 'Total valoare stoc 371 (raport)', 'VALOARE_RON': f'{total_raport:,.2f}'}, + {'INDICATOR': 'Sold contabil 371 (vbal)', 'VALOARE_RON': 'INDISPONIBIL (verifica balanta vbal pe luna ref)'}, + {'INDICATOR': 'Diferenta', 'VALOARE_RON': 'N/A'} + ] + else: + diferenta = total_raport - sold_contabil + pct = (diferenta / sold_contabil * 100) if sold_contabil else 0.0 + marker = ' ⚠' if abs(pct) > 1.0 else '' + recon_rows = [ + {'INDICATOR': 'Total valoare stoc 371 (raport)', 'VALOARE_RON': f'{total_raport:,.2f}'}, + {'INDICATOR': 'Sold contabil 371 (vbal, la data ref)', 'VALOARE_RON': f'{sold_contabil:,.2f}'}, + {'INDICATOR': f'Diferenta{marker}', 'VALOARE_RON': f'{diferenta:,.2f} ({pct:+.2f}%)'} + ] + + df_recon = pd.DataFrame(recon_rows) + pdf_gen.add_table_section( + "RECONCILIERE CONTABILA", + df_recon, + columns=['INDICATOR', 'VALOARE_RON'], + max_rows=5 + ) + + if sold_contabil is not None and abs((total_raport - sold_contabil) / sold_contabil * 100) > 1.0: + pdf_gen.add_explanation( + "Diferenta >1% intre raport si soldul contabil. Cauze uzuale: evaluare " + "pret mediu vs FIFO, inregistrari contabile ulterioare snapshot-ului vstoc, " + "sau cont 371 analitic (371.1, 371.2) netotalizat in vbal." + ) + perf.stop() + perf.start("PDF: Save document") pdf_gen.save() perf.stop() @@ -1131,7 +1312,14 @@ Exemple: default=REPORT_END_MONTH, help='Luna finala de raportare YYYY-MM (default: luna completa anterioara). Ex: 2026-01' ) - + + parser.add_argument( + '--aging-dates', + type=str, + default=None, + help='Lista YYYY-MM separata prin virgula pentru sectiunea evolutie stoc 371 >3 ani. Ex: 2022-12,2023-12,2024-12,2025-12' + ) + parser.add_argument( '--output-dir', '-o', type=Path, diff --git a/queries.py b/queries.py index cab59d2..54aa8ff 100644 --- a/queries.py +++ b/queries.py @@ -440,6 +440,96 @@ ORDER BY zile_fara_miscare DESC NULLS FIRST FETCH FIRST 100 ROWS ONLY """ +# ============================================================================= +# 12b. STOCURI MARFĂ 371 — SUMAR PE VECHIMI (pentru dosar credit bancă) +# ============================================================================= +STOCURI_371_SUMAR = """ +-- NOTĂ: :data_referinta = prima zi a lunii URMĂTOARE end-month (convenție proiect). +-- (:data_referinta - 1) = ultima zi a lunii de raport — folosit pentru: +-- (a) filtru snapshot vstoc: an=YEAR(data_ref-1), luna=MONTH(data_ref-1) +-- (b) calcul vechime: (data_ref - 1) - NVL(dataout, datain) +WITH s AS ( + SELECT + s.id_grupa, s.grupa, s.id_subgrupa, s.subgrupa, + (s.cants + s.cant - s.cante) * s.pret AS val, + ROUND((:data_referinta - 1) - NVL(s.dataout, s.datain)) AS zile + FROM vstoc s + WHERE s.an = EXTRACT(YEAR FROM (:data_referinta - 1)) + AND s.luna = EXTRACT(MONTH FROM (:data_referinta - 1)) + AND s.cont = '371' + AND (s.cants + s.cant - s.cante) <> 0 +) +SELECT + NVL(grupa, 'NECLASIFICAT') AS grupa, + NVL(subgrupa, '-') AS subgrupa, + GROUPING_ID(grupa, subgrupa) AS grouping_level, + ROUND(SUM(val), 2) AS valoare_total, + ROUND(SUM(CASE WHEN zile <= 180 THEN val ELSE 0 END), 2) AS val_0_6_luni, + ROUND(SUM(CASE WHEN zile > 180 AND zile <= 365 THEN val ELSE 0 END), 2) AS val_6_12_luni, + ROUND(SUM(CASE WHEN zile > 365 AND zile <= 730 THEN val ELSE 0 END), 2) AS val_1_2_ani, + ROUND(SUM(CASE WHEN zile > 730 AND zile <= 1095 THEN val ELSE 0 END), 2) AS val_2_3_ani, + ROUND(SUM(CASE WHEN zile > 1095 THEN val ELSE 0 END), 2) AS val_peste_3_ani +FROM s +GROUP BY ROLLUP(grupa, subgrupa) +ORDER BY GROUPING_ID(grupa, subgrupa), grupa, subgrupa +""" + +# ============================================================================= +# 12c. STOCURI MARFĂ 371 — DETALIU ARTICOLE +# ============================================================================= +STOCURI_371_DETALIU = """ +-- Vezi NOTA de la STOCURI_371_SUMAR. +SELECT + s.codmat AS cod_articol, + s.denumire, + s.nume_gestiune, + NVL(s.grupa, 'NECLASIFICAT') AS grupa, + NVL(s.subgrupa, '-') AS subgrupa, + s.um, + (s.cants + s.cant - s.cante) AS cantitate, + ROUND(s.pret, 4) AS pret_unitar, + ROUND((s.cants + s.cant - s.cante) * s.pret, 2) AS valoare, + s.datain AS data_intrare, + s.dataout AS data_ultima_iesire, + ROUND((:data_referinta - 1) - NVL(s.dataout, s.datain)) AS zile_vechime, + ROUND(((:data_referinta - 1) - NVL(s.dataout, s.datain)) / 365, 2) AS ani_vechime, + CASE + WHEN ROUND((:data_referinta - 1) - NVL(s.dataout, s.datain)) <= 180 THEN '0-6 luni' + WHEN ROUND((:data_referinta - 1) - NVL(s.dataout, s.datain)) <= 365 THEN '6-12 luni' + WHEN ROUND((:data_referinta - 1) - NVL(s.dataout, s.datain)) <= 730 THEN '1-2 ani' + WHEN ROUND((:data_referinta - 1) - NVL(s.dataout, s.datain)) <= 1095 THEN '2-3 ani' + ELSE '>3 ani' + END AS bucket_vechime, + s.lot, + s.adata_expirare AS data_expirare, + s.furnizor +FROM vstoc s +WHERE s.an = EXTRACT(YEAR FROM (:data_referinta - 1)) + AND s.luna = EXTRACT(MONTH FROM (:data_referinta - 1)) + AND s.cont = '371' + AND (s.cants + s.cant - s.cante) <> 0 +ORDER BY zile_vechime DESC, valoare DESC +""" + +# ============================================================================= +# 12d. SOLD CONTABIL 371 (reconciliere cu raport) +# ============================================================================= +STOCURI_371_SOLD_CONTABIL = """ +-- Sold contabil 371 la finalul lunii de raportare, din balanta de verificare (vbal). +-- Filtrul (an, luna) = ultima luna raportata, identic cu vstoc: +-- an = EXTRACT(YEAR FROM (:data_referinta - 1)) +-- luna= EXTRACT(MONTH FROM (:data_referinta - 1)) +-- 371 este cont de activ => sold debitor. Folosim (solddeb - soldcred) ca sa fie +-- robust daca exista inregistrari cu sold credit (corectii). Se aduna pe toate +-- sucursalele (id_sucursala), pentru ca raportul vstoc acopera toate gestiunile. +SELECT + ROUND(SUM(NVL(b.solddeb, 0) - NVL(b.soldcred, 0)), 2) AS sold_371 +FROM vbal b +WHERE b.cont = '371' + AND b.an = EXTRACT(YEAR FROM (:data_referinta - 1)) + AND b.luna = EXTRACT(MONTH FROM (:data_referinta - 1)) +""" + # ============================================================================= # 13. ROTAȚIE STOCURI # ============================================================================= @@ -2496,6 +2586,24 @@ QUERIES = { 'title': '⚠️ Stoc Lent (>90 zile)', 'description': 'Produse fără mișcare de peste 90 de zile' }, + 'stocuri_371_sumar': { + 'sql': STOCURI_371_SUMAR, + 'params': {}, + 'title': 'Stocuri Marfă 371 — Sumar Vechimi (pentru bancă)', + 'description': 'Valoarea stocului cont 371 pe grupe și buckets de vechime la data de referință' + }, + 'stocuri_371_detaliu': { + 'sql': STOCURI_371_DETALIU, + 'params': {}, + 'title': 'Stocuri Marfă 371 — Detaliu Articole', + 'description': 'Lista completă articole cont 371 cu vechime și valoare' + }, + 'stocuri_371_sold_contabil': { + 'sql': STOCURI_371_SOLD_CONTABIL, + 'params': {}, + 'title': 'Stocuri Marfă 371 — Sold Contabil (reconciliere)', + 'description': 'Sold 371 din rulaje la data ref pentru cross-check' + }, 'rotatie_stocuri': { 'sql': ROTATIE_STOCURI, 'params': {}, diff --git a/recommendations.py b/recommendations.py index 8b90517..9eedd1a 100644 --- a/recommendations.py +++ b/recommendations.py @@ -297,7 +297,26 @@ class RecommendationsEngine: vezi_detalii='Sheet: Rotatie Stocuri' ) - # 3. Check cash conversion cycle + # 3. Check aged stock 371 (bank collateral haircut risk) + stocuri_371 = results.get('stocuri_371_detaliu') + if stocuri_371 is not None and not stocuri_371.empty and 'VALOARE' in stocuri_371.columns: + valoare_total = stocuri_371['VALOARE'].sum() + if valoare_total > 0 and 'BUCKET_VECHIME' in stocuri_371.columns: + peste_3 = stocuri_371[stocuri_371['BUCKET_VECHIME'] == '>3 ani']['VALOARE'].sum() + procent = peste_3 / valoare_total + prag = self.thresholds.get('aged_stock_371_pct', 0.15) + if procent > prag: + self._add_recommendation( + categorie='Stoc 371', + indicator='Marfa invechita cont 371', + valoare=f'{procent*100:.1f}%', + status='CONCENTRARE', + explicatie=f'{procent*100:.1f}% din valoarea stocului 371 are >3 ani ({peste_3:,.0f} RON). Banca aplica haircut la evaluare colateral.', + recomandare='Lichidare / provizionare recomandata inainte de depunere dosar credit.', + vezi_detalii='Sheet: stocuri_371_detaliu' + ) + + # 4. 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)] diff --git a/run.bat b/run.bat index b426437..afe7922 100644 --- a/run.bat +++ b/run.bat @@ -1,5 +1,22 @@ @echo off -REM Run Data Intelligence Report Generator (Windows) +REM ============================================================================= +REM Data Intelligence Report Generator - ROMFAST +REM ============================================================================= +REM +REM Argumente disponibile: +REM --months N Numar luni pentru analiza (default: 12) +REM --end-month YYYY-MM Luna finala de raportare (default: luna precedenta) +REM Ex: --end-month 2025-12 +REM --aging-dates LIST Lista YYYY-MM pentru evolutie stoc 371 >3 ani +REM Ex: --aging-dates 2022-12,2023-12,2024-12,2025-12 +REM --output-dir PATH Director output (default: ./output) +REM +REM Exemple: +REM run.bat (raport standard) +REM run.bat --end-month 2025-12 (raport dosar banca) +REM run.bat --end-month 2025-12 --aging-dates 2022-12,2023-12,2024-12,2025-12 +REM (raport dosar banca + trend) +REM ============================================================================= cd /d "%~dp0" call .venv\Scripts\activate.bat