Raport stocuri marfa 371 pe vechimi (dosar credit banca)

Sectiune dedicata pentru banca cu sumar pe grupe x bucket vechime
(0-6 luni, 6-12 luni, 1-2 ani, 2-3 ani, >3 ani), detaliu articole
si reconciliere contra sold contabil 371 din balanta (vbal).

- queries.py: STOCURI_371_SUMAR (ROLLUP grupa/subgrupa), STOCURI_371_DETALIU,
  STOCURI_371_SOLD_CONTABIL (sold sintetic din vbal, an/luna snapshot).
  Filtru stoc (cants+cant-cante) <> 0 pentru acoperire cu soldul contabil.
- main.py: CLI --aging-dates pentru evolutie multi-data a stocului >3 ani,
  pagina PDF dedicata cu nota metodologica + reconciliere (marker la diff >1%).
- recommendations.py: alerta CONCENTRARE cand stoc 371 >3 ani depaseste prag.
- config.py: threshold aged_stock_371_pct (default 15%).
- run.bat: header documentar cu argumentele disponibile + exemple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-23 15:34:14 +00:00
parent 3f410c3fb8
commit 7afacd6664
5 changed files with 343 additions and 10 deletions

204
main.py
View File

@@ -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,