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

View File

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

202
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()
@@ -1132,6 +1313,13 @@ Exemple:
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,

View File

@@ -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': {},

View File

@@ -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)]

19
run.bat
View File

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