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>
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Oracle connection settings
|
||||||
|
# ----------------------------
|
||||||
|
# For Windows native: use 127.0.0.1 or localhost
|
||||||
|
# For WSL: use Windows IP (run: cat /etc/resolv.conf | grep nameserver)
|
||||||
|
# For Docker: use host.docker.internal (automatic in docker-compose)
|
||||||
|
|
||||||
|
ORACLE_HOST=127.0.0.1
|
||||||
|
ORACLE_PORT=1521
|
||||||
|
ORACLE_SERVICE=XEPDB1
|
||||||
|
ORACLE_USER=FIRMA
|
||||||
|
ORACLE_PASSWORD=PAROLA
|
||||||
|
|
||||||
|
# Output settings
|
||||||
|
OUTPUT_DIR=./output
|
||||||
|
COMPANY_NAME=Data Intelligence Report
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Virtual environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Environment variables (contains secrets)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Output files
|
||||||
|
output/
|
||||||
|
*.xlsx
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
93
CLAUDE.md
Normal file
93
CLAUDE.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Data Intelligence Report Generator for ERP ROA (Oracle Database). Generates Excel and PDF business intelligence reports with sales analytics, margin analysis, stock tracking, and alerts.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Option 1: Virtual Environment (WSL or Windows)
|
||||||
|
```bash
|
||||||
|
# Create and activate virtual environment
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Linux/WSL
|
||||||
|
# or: .venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run report
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Docker (Windows Docker Desktop / Linux)
|
||||||
|
```bash
|
||||||
|
# Copy and configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your Oracle credentials
|
||||||
|
|
||||||
|
# Run with docker-compose
|
||||||
|
docker-compose run --rm report-generator
|
||||||
|
|
||||||
|
# Or with custom months
|
||||||
|
docker-compose run --rm report-generator python main.py --months 6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Options
|
||||||
|
```bash
|
||||||
|
# Run with custom period
|
||||||
|
python main.py --months 6
|
||||||
|
|
||||||
|
# Custom output directory
|
||||||
|
python main.py --output-dir /path/to/output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Oracle Connection from Different Environments
|
||||||
|
|
||||||
|
| Environment | ORACLE_HOST value |
|
||||||
|
|-------------|-------------------|
|
||||||
|
| Windows native | `127.0.0.1` |
|
||||||
|
| WSL | Windows IP (run: `cat /etc/resolv.conf \| grep nameserver`) |
|
||||||
|
| Docker | `host.docker.internal` (automatic in docker-compose) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Entry point**: `main.py` - CLI interface, orchestrates query execution and report generation
|
||||||
|
|
||||||
|
**Data flow**:
|
||||||
|
1. `config.py` loads Oracle connection settings from `.env` file
|
||||||
|
2. `queries.py` contains all SQL queries in a `QUERIES` dictionary with metadata (title, description, params)
|
||||||
|
3. `main.py` executes queries via `OracleConnection` context manager, stores results in `results` dict
|
||||||
|
4. `report_generator.py` receives dataframes and generates:
|
||||||
|
- `ExcelReportGenerator`: Multi-sheet workbook with conditional formatting
|
||||||
|
- `PDFReportGenerator`: Executive summary with charts via ReportLab
|
||||||
|
|
||||||
|
**Key patterns**:
|
||||||
|
- Queries use parameterized `:months` for configurable analysis period
|
||||||
|
- Sheet order in `main.py:sheet_order` controls Excel tab sequence
|
||||||
|
- Charts are generated via matplotlib, converted to images for PDF
|
||||||
|
|
||||||
|
## Oracle Database Schema
|
||||||
|
|
||||||
|
Required views: `fact_vfacturi2`, `fact_vfacturi_detalii`, `vnom_articole`, `vnom_parteneri`, `vstoc`, `vrul`
|
||||||
|
|
||||||
|
Filter conventions:
|
||||||
|
- `sters = 0` excludes deleted records
|
||||||
|
- `tip NOT IN (7, 8, 9, 24)` excludes returns/credit notes
|
||||||
|
- Account codes: `341`, `345` = own production; `301` = raw materials
|
||||||
|
|
||||||
|
## Adding New Reports
|
||||||
|
|
||||||
|
1. Add SQL query constant in `queries.py`
|
||||||
|
2. Add entry to `QUERIES` dict with `sql`, `params`, `title`, `description`
|
||||||
|
3. Add query name to `sheet_order` list in `main.py` (line ~143)
|
||||||
|
4. For PDF inclusion, add rendering logic in `main.py:generate_reports()`
|
||||||
|
|
||||||
|
## Alert Thresholds (in config.py)
|
||||||
|
|
||||||
|
- Low margin: < 15%
|
||||||
|
- Price variation: > 20%
|
||||||
|
- Slow stock: > 90 days without movement
|
||||||
|
- Minimum sales for analysis: 1000 RON
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY *.py ./
|
||||||
|
COPY .env* ./
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
RUN mkdir -p /app/output
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["python", "main.py"]
|
||||||
448
PLAN_FIXES_2025_11_28.md
Normal file
448
PLAN_FIXES_2025_11_28.md
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
# Plan: Corectii Report Generator - 28.11.2025
|
||||||
|
|
||||||
|
## Probleme de Rezolvat
|
||||||
|
|
||||||
|
1. **Analiza Prajitorie** - intrarile si iesirile apar pe randuri diferite in loc de coloane
|
||||||
|
2. **Query-uri Financiare "No Data"** - DSO/DPO, Solduri clienti/furnizori, Aging, Pozitia Cash, Ciclu Conversie Cash nu afiseaza date (user confirma ca datele EXISTA)
|
||||||
|
3. **Recomandari in Sumar Executiv** - trebuie incluse sub KPIs in sheet-ul Sumar Executiv
|
||||||
|
4. **Reordonare Sheet-uri** - agregatele (indicatori_agregati, portofoliu_clienti, concentrare_risc) trebuie mutate imediat dupa Sumar Executiv
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ISSUE 1: Analiza Prajitorie - Restructurare din Randuri in Coloane
|
||||||
|
|
||||||
|
### Fisier: `queries.py` liniile 450-478
|
||||||
|
|
||||||
|
### Problema Curenta
|
||||||
|
Query-ul grupeaza dupa `tip_miscare` (Intrare/Iesire/Transformare), creand randuri separate:
|
||||||
|
```
|
||||||
|
luna | tip | tip_miscare | cantitate_intrata | cantitate_iesita
|
||||||
|
2024-01 | Materii prime | Intrare | 1000 | 0
|
||||||
|
2024-01 | Materii prime | Iesire | 0 | 800
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Cerut
|
||||||
|
Un rand per Luna + Tip cu coloane separate pentru Intrari si Iesiri:
|
||||||
|
```
|
||||||
|
luna | tip | cantitate_intrari | valoare_intrari | cantitate_iesiri | valoare_iesiri | sold_net
|
||||||
|
2024-01 | Materii prime | 1000 | 50000 | 800 | 40000 | 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solutia: Inlocuieste ANALIZA_PRAJITORIE (liniile 450-478)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ANALIZA_PRAJITORIE = """
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(r.dataact, 'YYYY-MM') AS luna,
|
||||||
|
CASE
|
||||||
|
WHEN r.cont = '301' THEN 'Materii prime'
|
||||||
|
WHEN r.cont = '341' THEN 'Semifabricate'
|
||||||
|
WHEN r.cont = '345' THEN 'Produse finite'
|
||||||
|
ELSE 'Altele'
|
||||||
|
END AS tip,
|
||||||
|
-- Intrari: cantitate > 0 AND cante = 0
|
||||||
|
ROUND(SUM(CASE WHEN r.cant > 0 AND NVL(r.cante, 0) = 0 THEN r.cant ELSE 0 END), 2) AS cantitate_intrari,
|
||||||
|
ROUND(SUM(CASE WHEN r.cant > 0 AND NVL(r.cante, 0) = 0 THEN r.cant * NVL(r.pret, 0) ELSE 0 END), 2) AS valoare_intrari,
|
||||||
|
-- Iesiri: cant = 0 AND cante > 0
|
||||||
|
ROUND(SUM(CASE WHEN NVL(r.cant, 0) = 0 AND r.cante > 0 THEN r.cante ELSE 0 END), 2) AS cantitate_iesiri,
|
||||||
|
ROUND(SUM(CASE WHEN NVL(r.cant, 0) = 0 AND r.cante > 0 THEN r.cante * NVL(r.pret, 0) ELSE 0 END), 2) AS valoare_iesiri,
|
||||||
|
-- Transformari: cant > 0 AND cante > 0 (intrare si iesire simultan)
|
||||||
|
ROUND(SUM(CASE WHEN r.cant > 0 AND r.cante > 0 THEN r.cant ELSE 0 END), 2) AS cantitate_transformari_in,
|
||||||
|
ROUND(SUM(CASE WHEN r.cant > 0 AND r.cante > 0 THEN r.cante ELSE 0 END), 2) AS cantitate_transformari_out,
|
||||||
|
-- Sold net
|
||||||
|
ROUND(SUM(NVL(r.cant, 0) - NVL(r.cante, 0)), 2) AS sold_net_cantitate,
|
||||||
|
ROUND(SUM((NVL(r.cant, 0) - NVL(r.cante, 0)) * NVL(r.pret, 0)), 2) AS sold_net_valoare
|
||||||
|
FROM vrul r
|
||||||
|
WHERE r.cont IN ('301', '341', '345')
|
||||||
|
AND r.dataact >= ADD_MONTHS(TRUNC(SYSDATE), -:months)
|
||||||
|
GROUP BY TO_CHAR(r.dataact, 'YYYY-MM'),
|
||||||
|
CASE WHEN r.cont = '301' THEN 'Materii prime'
|
||||||
|
WHEN r.cont = '341' THEN 'Semifabricate'
|
||||||
|
WHEN r.cont = '345' THEN 'Produse finite'
|
||||||
|
ELSE 'Altele' END
|
||||||
|
ORDER BY luna, tip
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modificari Cheie
|
||||||
|
1. **Eliminat** `tip_miscare` din SELECT si GROUP BY
|
||||||
|
2. **Agregare conditionala** cu `CASE WHEN ... THEN ... ELSE 0 END` in SUM()
|
||||||
|
3. **Coloane separate** pentru fiecare tip de miscare
|
||||||
|
4. **Adaugat coloane valoare** pe langa cantitati
|
||||||
|
|
||||||
|
### Update Legends in main.py (in jurul liniei 224)
|
||||||
|
Adauga in dictionarul `legends`:
|
||||||
|
```python
|
||||||
|
'analiza_prajitorie': {
|
||||||
|
'CANTITATE_INTRARI': 'Cantitate intrata (cant > 0, cante = 0)',
|
||||||
|
'VALOARE_INTRARI': 'Valoare intrari = cantitate x pret',
|
||||||
|
'CANTITATE_IESIRI': 'Cantitate iesita (cant = 0, cante > 0)',
|
||||||
|
'VALOARE_IESIRI': 'Valoare iesiri = cantitate x pret',
|
||||||
|
'CANTITATE_TRANSFORMARI_IN': 'Cantitate intrata in transformari',
|
||||||
|
'CANTITATE_TRANSFORMARI_OUT': 'Cantitate iesita din transformari',
|
||||||
|
'SOLD_NET_CANTITATE': 'Sold net = Total intrari - Total iesiri',
|
||||||
|
'SOLD_NET_VALOARE': 'Valoare neta a soldului'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ISSUE 2: Query-uri Financiare "No Data" - DIAGNOSTIC NECESAR
|
||||||
|
|
||||||
|
### Query-uri Afectate
|
||||||
|
|
||||||
|
| Query | View Folosit | Linie in queries.py | Filtru Curent |
|
||||||
|
|-------|--------------|---------------------|---------------|
|
||||||
|
| DSO_DPO | vbalanta_parteneri | 796-844 | `an = EXTRACT(YEAR FROM SYSDATE) AND luna = EXTRACT(MONTH FROM SYSDATE)` |
|
||||||
|
| SOLDURI_CLIENTI | vbalanta_parteneri | 636-654 | Acelasi + `cont LIKE '4111%'` |
|
||||||
|
| SOLDURI_FURNIZORI | vbalanta_parteneri | 659-677 | Acelasi + `cont LIKE '401%'` |
|
||||||
|
| AGING_CREANTE | vireg_parteneri | 682-714 | `cont LIKE '4111%' OR '461%'` |
|
||||||
|
| FACTURI_RESTANTE | vireg_parteneri | 719-734 | Acelasi + `datascad < SYSDATE` |
|
||||||
|
| POZITIA_CASH | vbal | 849-872 | `cont LIKE '512%' OR '531%'` |
|
||||||
|
| CICLU_CONVERSIE_CASH | Multiple | 877-940 | Combina toate de mai sus |
|
||||||
|
|
||||||
|
### User-ul confirma ca DATELE EXISTA - trebuie diagnosticat problema
|
||||||
|
|
||||||
|
### Cauze Posibile
|
||||||
|
1. Numele view-urilor difera in baza de date
|
||||||
|
2. Numele coloanelor difera (`an`, `luna`, `solddeb`, `soldcred`)
|
||||||
|
3. Prefixele codurilor de cont nu se potrivesc (4111%, 401%, 512%)
|
||||||
|
4. Pragurile HAVING sunt prea restrictive (`> 1`, `> 100`)
|
||||||
|
|
||||||
|
### FIX IMEDIAT: Relaxeaza Pragurile HAVING
|
||||||
|
|
||||||
|
**SOLDURI_CLIENTI** (linia 652):
|
||||||
|
```sql
|
||||||
|
-- DE LA:
|
||||||
|
HAVING ABS(SUM(b.solddeb - b.soldcred)) > 1
|
||||||
|
-- LA:
|
||||||
|
HAVING ABS(SUM(b.solddeb - b.soldcred)) > 0.01
|
||||||
|
```
|
||||||
|
|
||||||
|
**SOLDURI_FURNIZORI** (linia 675):
|
||||||
|
```sql
|
||||||
|
-- DE LA:
|
||||||
|
HAVING ABS(SUM(b.soldcred - b.solddeb)) > 1
|
||||||
|
-- LA:
|
||||||
|
HAVING ABS(SUM(b.soldcred - b.solddeb)) > 0.01
|
||||||
|
```
|
||||||
|
|
||||||
|
**AGING_CREANTE** (linia 712):
|
||||||
|
```sql
|
||||||
|
-- DE LA:
|
||||||
|
HAVING SUM(sold_ramas) > 100
|
||||||
|
-- LA:
|
||||||
|
HAVING SUM(sold_ramas) > 0.01
|
||||||
|
```
|
||||||
|
|
||||||
|
**AGING_DATORII** (linia 770):
|
||||||
|
```sql
|
||||||
|
-- DE LA:
|
||||||
|
HAVING SUM(sold_ramas) > 100
|
||||||
|
-- LA:
|
||||||
|
HAVING SUM(sold_ramas) > 0.01
|
||||||
|
```
|
||||||
|
|
||||||
|
**POZITIA_CASH** (linia 870):
|
||||||
|
```sql
|
||||||
|
-- DE LA:
|
||||||
|
HAVING ABS(SUM(b.solddeb - b.soldcred)) > 0.01
|
||||||
|
-- Deja OK, dar verifica daca vbal exista
|
||||||
|
```
|
||||||
|
|
||||||
|
### Daca Tot Nu Functioneaza - Verifica View-urile
|
||||||
|
|
||||||
|
Ruleaza in Oracle:
|
||||||
|
```sql
|
||||||
|
-- Verifica daca view-urile exista
|
||||||
|
SELECT view_name FROM user_views
|
||||||
|
WHERE view_name IN ('VBALANTA_PARTENERI', 'VIREG_PARTENERI', 'VBAL', 'VRUL');
|
||||||
|
|
||||||
|
-- Verifica daca exista date pentru luna curenta
|
||||||
|
SELECT an, luna, COUNT(*)
|
||||||
|
FROM vbalanta_parteneri
|
||||||
|
WHERE an = EXTRACT(YEAR FROM SYSDATE)
|
||||||
|
GROUP BY an, luna
|
||||||
|
ORDER BY luna DESC;
|
||||||
|
|
||||||
|
-- Verifica prefixele de cont existente
|
||||||
|
SELECT DISTINCT SUBSTR(cont, 1, 4) AS prefix_cont
|
||||||
|
FROM vbalanta_parteneri
|
||||||
|
WHERE an = EXTRACT(YEAR FROM SYSDATE);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ISSUE 3: Recomandari in Sumar Executiv
|
||||||
|
|
||||||
|
### Stare Curenta
|
||||||
|
- Sheet `sumar_executiv` (linia 166) - contine doar KPIs
|
||||||
|
- Sheet `recomandari` (linia 168) - sheet separat cu toate recomandarile
|
||||||
|
|
||||||
|
### Solutia: Metoda noua in report_generator.py
|
||||||
|
|
||||||
|
### Adauga metoda noua in clasa `ExcelReportGenerator` (dupa linia 167 in report_generator.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_sheet_with_recommendations(self, name: str, df: pd.DataFrame,
|
||||||
|
recommendations_df: pd.DataFrame,
|
||||||
|
title: str = None, description: str = None,
|
||||||
|
legend: dict = None, top_n_recommendations: int = 5):
|
||||||
|
"""Adauga sheet formatat cu KPIs si top recomandari dedesubt"""
|
||||||
|
sheet_name = name[:31]
|
||||||
|
ws = self.wb.create_sheet(title=sheet_name)
|
||||||
|
|
||||||
|
start_row = 1
|
||||||
|
|
||||||
|
# Adauga titlu
|
||||||
|
if title:
|
||||||
|
ws.cell(row=start_row, column=1, value=title)
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=14)
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Adauga descriere
|
||||||
|
if description:
|
||||||
|
ws.cell(row=start_row, column=1, value=description)
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(italic=True, size=10, color='666666')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Adauga timestamp
|
||||||
|
ws.cell(row=start_row, column=1, value=f"Generat: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(size=9, color='999999')
|
||||||
|
start_row += 2
|
||||||
|
|
||||||
|
# === SECTIUNEA 1: KPIs ===
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
# Header
|
||||||
|
for col_idx, col_name in enumerate(df.columns, 1):
|
||||||
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
||||||
|
cell.font = self.header_font
|
||||||
|
cell.fill = self.header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
|
cell.border = self.border
|
||||||
|
|
||||||
|
# Date
|
||||||
|
for row_idx, row in enumerate(df.itertuples(index=False), start_row + 1):
|
||||||
|
for col_idx, value in enumerate(row, 1):
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = self.border
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
cell.number_format = '#,##0.00' if isinstance(value, float) else '#,##0'
|
||||||
|
cell.alignment = Alignment(horizontal='right')
|
||||||
|
|
||||||
|
start_row = start_row + len(df) + 3
|
||||||
|
|
||||||
|
# === SECTIUNEA 2: TOP RECOMANDARI ===
|
||||||
|
if recommendations_df is not None and not recommendations_df.empty:
|
||||||
|
ws.cell(row=start_row, column=1, value="Top Recomandari Prioritare")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=12, color='366092')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Sorteaza dupa prioritate (ALERTA primul, apoi ATENTIE, apoi OK)
|
||||||
|
df_sorted = recommendations_df.copy()
|
||||||
|
status_order = {'ALERTA': 0, 'ATENTIE': 1, 'OK': 2}
|
||||||
|
df_sorted['_order'] = df_sorted['STATUS'].map(status_order).fillna(3)
|
||||||
|
df_sorted = df_sorted.sort_values('_order').head(top_n_recommendations)
|
||||||
|
df_sorted = df_sorted.drop(columns=['_order'])
|
||||||
|
|
||||||
|
# Coloane de afisat
|
||||||
|
display_cols = ['STATUS', 'CATEGORIE', 'INDICATOR', 'VALOARE', 'RECOMANDARE']
|
||||||
|
display_cols = [c for c in display_cols if c in df_sorted.columns]
|
||||||
|
|
||||||
|
# Header cu background mov
|
||||||
|
for col_idx, col_name in enumerate(display_cols, 1):
|
||||||
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
||||||
|
cell.font = self.header_font
|
||||||
|
cell.fill = PatternFill(start_color='8E44AD', end_color='8E44AD', fill_type='solid')
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
|
cell.border = self.border
|
||||||
|
|
||||||
|
# Randuri cu colorare dupa status
|
||||||
|
for row_idx, (_, row) in enumerate(df_sorted.iterrows(), start_row + 1):
|
||||||
|
status = row.get('STATUS', 'OK')
|
||||||
|
for col_idx, col_name in enumerate(display_cols, 1):
|
||||||
|
value = row.get(col_name, '')
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = self.border
|
||||||
|
cell.alignment = Alignment(wrap_text=True)
|
||||||
|
|
||||||
|
# Colorare condiționata
|
||||||
|
if status == 'ALERTA':
|
||||||
|
cell.fill = PatternFill(start_color='FADBD8', end_color='FADBD8', fill_type='solid')
|
||||||
|
elif status == 'ATENTIE':
|
||||||
|
cell.fill = PatternFill(start_color='FCF3CF', end_color='FCF3CF', fill_type='solid')
|
||||||
|
else:
|
||||||
|
cell.fill = PatternFill(start_color='D5F5E3', end_color='D5F5E3', fill_type='solid')
|
||||||
|
|
||||||
|
# Auto-adjust latime coloane
|
||||||
|
for col_idx in range(1, 8):
|
||||||
|
ws.column_dimensions[get_column_letter(col_idx)].width = 22
|
||||||
|
|
||||||
|
ws.freeze_panes = ws.cell(row=5, column=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifica main.py - Loop-ul de Creare Sheet-uri (in jurul liniei 435)
|
||||||
|
|
||||||
|
```python
|
||||||
|
for query_name in sheet_order:
|
||||||
|
if query_name in results:
|
||||||
|
# Tratare speciala pentru 'sumar_executiv' - adauga recomandari sub KPIs
|
||||||
|
if query_name == 'sumar_executiv':
|
||||||
|
query_info = QUERIES.get(query_name, {})
|
||||||
|
excel_gen.add_sheet_with_recommendations(
|
||||||
|
name='Sumar Executiv',
|
||||||
|
df=results['sumar_executiv'],
|
||||||
|
recommendations_df=results.get('recomandari'),
|
||||||
|
title=query_info.get('title', 'Sumar Executiv'),
|
||||||
|
description=query_info.get('description', ''),
|
||||||
|
legend=legends.get('sumar_executiv'),
|
||||||
|
top_n_recommendations=5
|
||||||
|
)
|
||||||
|
# Pastreaza sheet-ul complet de recomandari
|
||||||
|
elif query_name == 'recomandari':
|
||||||
|
excel_gen.add_sheet(
|
||||||
|
name='RECOMANDARI',
|
||||||
|
df=results['recomandari'],
|
||||||
|
title='Recomandari Automate (Lista Completa)',
|
||||||
|
description='Toate insight-urile si actiunile sugerate bazate pe analiza datelor',
|
||||||
|
legend=legends.get('recomandari')
|
||||||
|
)
|
||||||
|
elif query_name in QUERIES:
|
||||||
|
# ... logica existenta neschimbata
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ISSUE 4: Reordonare Sheet-uri
|
||||||
|
|
||||||
|
### Fisier: `main.py` liniile 165-221
|
||||||
|
|
||||||
|
### Noul sheet_order (inlocuieste complet liniile 165-221)
|
||||||
|
|
||||||
|
```python
|
||||||
|
sheet_order = [
|
||||||
|
# SUMAR EXECUTIV
|
||||||
|
'sumar_executiv',
|
||||||
|
'sumar_executiv_yoy',
|
||||||
|
'recomandari',
|
||||||
|
|
||||||
|
# INDICATORI AGREGATI (MUTATI SUS - imagine de ansamblu)
|
||||||
|
'indicatori_agregati_venituri',
|
||||||
|
'indicatori_agregati_venituri_yoy',
|
||||||
|
'portofoliu_clienti',
|
||||||
|
'concentrare_risc',
|
||||||
|
'concentrare_risc_yoy',
|
||||||
|
'sezonalitate_lunara',
|
||||||
|
|
||||||
|
# INDICATORI GENERALI & LICHIDITATE
|
||||||
|
'indicatori_generali',
|
||||||
|
'indicatori_lichiditate',
|
||||||
|
'clasificare_datorii',
|
||||||
|
'grad_acoperire_datorii',
|
||||||
|
'proiectie_lichiditate',
|
||||||
|
|
||||||
|
# ALERTE
|
||||||
|
'vanzari_sub_cost',
|
||||||
|
'clienti_marja_mica',
|
||||||
|
|
||||||
|
# CICLU CASH
|
||||||
|
'ciclu_conversie_cash',
|
||||||
|
|
||||||
|
# ANALIZA CLIENTI
|
||||||
|
'marja_per_client',
|
||||||
|
'clienti_ranking_profit',
|
||||||
|
'frecventa_clienti',
|
||||||
|
'concentrare_clienti',
|
||||||
|
'trending_clienti',
|
||||||
|
'marja_client_categorie',
|
||||||
|
|
||||||
|
# PRODUSE
|
||||||
|
'top_produse',
|
||||||
|
'marja_per_categorie',
|
||||||
|
'marja_per_gestiune',
|
||||||
|
'articole_negestionabile',
|
||||||
|
'productie_vs_revanzare',
|
||||||
|
|
||||||
|
# PRETURI
|
||||||
|
'dispersie_preturi',
|
||||||
|
'clienti_sub_medie',
|
||||||
|
'evolutie_discount',
|
||||||
|
|
||||||
|
# FINANCIAR
|
||||||
|
'dso_dpo',
|
||||||
|
'dso_dpo_yoy',
|
||||||
|
'solduri_clienti',
|
||||||
|
'aging_creante',
|
||||||
|
'facturi_restante',
|
||||||
|
'solduri_furnizori',
|
||||||
|
'aging_datorii',
|
||||||
|
'facturi_restante_furnizori',
|
||||||
|
'pozitia_cash',
|
||||||
|
|
||||||
|
# ISTORIC
|
||||||
|
'vanzari_lunare',
|
||||||
|
|
||||||
|
# STOC
|
||||||
|
'stoc_curent',
|
||||||
|
'stoc_lent',
|
||||||
|
'rotatie_stocuri',
|
||||||
|
|
||||||
|
# PRODUCTIE
|
||||||
|
'analiza_prajitorie',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordinea de Implementare
|
||||||
|
|
||||||
|
### Pasul 1: queries.py
|
||||||
|
1. Inlocuieste ANALIZA_PRAJITORIE (liniile 450-478) cu versiunea cu agregare conditionala
|
||||||
|
2. Relaxeaza pragurile HAVING in:
|
||||||
|
- SOLDURI_CLIENTI (linia 652): `> 1` -> `> 0.01`
|
||||||
|
- SOLDURI_FURNIZORI (linia 675): `> 1` -> `> 0.01`
|
||||||
|
- AGING_CREANTE (linia 712): `> 100` -> `> 0.01`
|
||||||
|
- AGING_DATORII (linia 770): `> 100` -> `> 0.01`
|
||||||
|
|
||||||
|
### Pasul 2: report_generator.py
|
||||||
|
1. Adauga metoda `add_sheet_with_recommendations()` dupa linia 167
|
||||||
|
2. Asigura-te ca importurile includ `PatternFill`, `get_column_letter` din openpyxl
|
||||||
|
|
||||||
|
### Pasul 3: main.py
|
||||||
|
1. Inlocuieste array-ul `sheet_order` (liniile 165-221)
|
||||||
|
2. Modifica loop-ul de creare sheet-uri pentru `sumar_executiv` (in jurul liniei 435)
|
||||||
|
3. Adauga legend pentru `analiza_prajitorie` in dictionarul `legends`
|
||||||
|
|
||||||
|
### Pasul 4: Testare
|
||||||
|
1. Ruleaza cu `python main.py --months 1` pentru test rapid
|
||||||
|
2. Verifica sheet-ul `analiza_prajitorie` - format columnar
|
||||||
|
3. Verifica query-urile financiare - trebuie sa returneze date
|
||||||
|
4. Verifica `Sumar Executiv` - sectiune recomandari dedesubt
|
||||||
|
5. Verifica ordinea sheet-urilor - agregatele dupa sumar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fisiere Critice
|
||||||
|
|
||||||
|
| Fisier | Ce se modifica | Linii |
|
||||||
|
|--------|----------------|-------|
|
||||||
|
| `queries.py` | ANALIZA_PRAJITORIE SQL | 450-478 |
|
||||||
|
| `queries.py` | HAVING thresholds | 652, 675, 712, 770 |
|
||||||
|
| `report_generator.py` | Metoda noua | dupa 167 |
|
||||||
|
| `main.py` | sheet_order array | 165-221 |
|
||||||
|
| `main.py` | Loop creare sheet-uri | ~435 |
|
||||||
|
| `main.py` | legends dict | ~224 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note pentru Sesiunea Viitoare
|
||||||
|
|
||||||
|
1. **Prioritate ALERTA**: Query-urile financiare "no data" - user-ul a confirmat ca datele EXISTA. Daca relaxarea HAVING nu rezolva, trebuie verificate numele view-urilor si coloanelor in Oracle.
|
||||||
|
|
||||||
|
2. **Import necesar** in report_generator.py:
|
||||||
|
```python
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.styles import PatternFill
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Testare**: Dupa implementare, ruleaza raportul si verifica fiecare din cele 4 fix-uri.
|
||||||
176
README.md
Normal file
176
README.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Data Intelligence Report Generator
|
||||||
|
|
||||||
|
Generator automat de rapoarte de Business Intelligence pentru ERP ROA (Oracle Database).
|
||||||
|
|
||||||
|
## 📋 Funcționalități
|
||||||
|
|
||||||
|
Scriptul generează automat:
|
||||||
|
|
||||||
|
### Excel (multiple sheet-uri):
|
||||||
|
- **Sumar Executiv** - KPIs principale
|
||||||
|
- **🚨 Vânzări sub Cost** - Alertă critică pentru pierderi
|
||||||
|
- **⚠️ Clienți Marjă Mică** - Clienți care necesită renegociere
|
||||||
|
- **Marja per Client** - Profitabilitate per client
|
||||||
|
- **Concentrare Clienți** - Analiza dependenței de clienți mari
|
||||||
|
- **Trending Clienți** - Evoluție YoY (creștere/scădere)
|
||||||
|
- **Top Produse** - Cele mai vândute produse
|
||||||
|
- **Marja per Categorie** - Profitabilitate per subgrupă
|
||||||
|
- **Marja per Gestiune** - Profitabilitate per depozit
|
||||||
|
- **Producție vs Revânzare** - Comparație marje
|
||||||
|
- **Dispersie Prețuri** - Produse cu variație mare de preț
|
||||||
|
- **Clienți sub Medie** - Cine cumpără prea ieftin
|
||||||
|
- **Vânzări Lunare** - Sezonalitate (24 luni)
|
||||||
|
- **Stoc Curent** - Valoare stoc per gestiune
|
||||||
|
- **Stoc Lent** - Produse fără mișcare >90 zile
|
||||||
|
- **Rotație Stocuri** - Viteza de rotație
|
||||||
|
- **Analiză Prăjitorie** - Fluxul producției
|
||||||
|
|
||||||
|
### PDF (Sumar Executiv):
|
||||||
|
- Grafice interactive
|
||||||
|
- Tabele rezumat
|
||||||
|
- Alerte evidențiate vizual
|
||||||
|
- Perfect pentru prezentări
|
||||||
|
|
||||||
|
## 🚀 Instalare
|
||||||
|
|
||||||
|
### Opțiunea 1: Virtual Environment (recomandat)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clonează/copiază directorul
|
||||||
|
cd data_intelligence_report
|
||||||
|
|
||||||
|
# 2. Creează mediu virtual
|
||||||
|
python -m venv .venv
|
||||||
|
|
||||||
|
# 3. Activează mediul virtual
|
||||||
|
source .venv/bin/activate # Linux/WSL/Mac
|
||||||
|
# sau
|
||||||
|
.venv\Scripts\activate # Windows CMD
|
||||||
|
# sau
|
||||||
|
.venv\Scripts\Activate.ps1 # Windows PowerShell
|
||||||
|
|
||||||
|
# 4. Instalează dependențele
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 5. Configurează conexiunea
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # sau editează cu alt editor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opțiunea 2: Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configurează conexiunea
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 2. Rulează cu docker-compose
|
||||||
|
docker-compose run --rm report-generator
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configurare
|
||||||
|
|
||||||
|
Copiază `.env.example` în `.env` și editează:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ORACLE_HOST=127.0.0.1
|
||||||
|
ORACLE_PORT=1521
|
||||||
|
ORACLE_SERVICE=XEPDB1
|
||||||
|
ORACLE_USER=FIRMA
|
||||||
|
ORACLE_PASSWORD=PAROLA
|
||||||
|
|
||||||
|
OUTPUT_DIR=./output
|
||||||
|
COMPANY_NAME=Numele Companiei
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conexiune Oracle din diferite medii
|
||||||
|
|
||||||
|
| Mediu | ORACLE_HOST |
|
||||||
|
|-------|-------------|
|
||||||
|
| Windows nativ | `127.0.0.1` sau `localhost` |
|
||||||
|
| WSL | IP Windows (vezi mai jos) |
|
||||||
|
| Docker | `host.docker.internal` (automat în docker-compose) |
|
||||||
|
|
||||||
|
**Pentru WSL**, obține IP-ul Windows:
|
||||||
|
```bash
|
||||||
|
cat /etc/resolv.conf | grep nameserver | awk '{print $2}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Utilizare
|
||||||
|
|
||||||
|
### Cu Virtual Environment
|
||||||
|
```bash
|
||||||
|
# Activează mediul (dacă nu e activ)
|
||||||
|
source .venv/bin/activate # Linux/WSL
|
||||||
|
# sau: .venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Raport standard (ultimele 12 luni)
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Raport pentru ultimele 6 luni
|
||||||
|
python main.py --months 6
|
||||||
|
|
||||||
|
# Salvare în alt director
|
||||||
|
python main.py --output-dir /path/to/output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cu Docker
|
||||||
|
```bash
|
||||||
|
# Raport standard
|
||||||
|
docker-compose run --rm report-generator
|
||||||
|
|
||||||
|
# Cu parametri custom
|
||||||
|
docker-compose run --rm report-generator python main.py --months 6
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Structura Output
|
||||||
|
|
||||||
|
```
|
||||||
|
output/
|
||||||
|
├── data_intelligence_report_20251126_143022.xlsx
|
||||||
|
└── data_intelligence_report_20251126_143022.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Cerințe Sistem
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- Oracle Client (sau oracledb în thin mode)
|
||||||
|
- Acces la schema Oracle cu view-urile:
|
||||||
|
- `fact_vfacturi2`
|
||||||
|
- `fact_vfacturi_detalii`
|
||||||
|
- `vnom_articole`
|
||||||
|
- `vnom_parteneri`
|
||||||
|
- `vstoc`
|
||||||
|
- `vrul`
|
||||||
|
|
||||||
|
## 📈 Query-uri Incluse
|
||||||
|
|
||||||
|
Toate query-urile sunt în `queries.py` și pot fi personalizate:
|
||||||
|
|
||||||
|
1. **Praguri de alertă**:
|
||||||
|
- Marjă mică: < 15%
|
||||||
|
- Variație preț: > 20%
|
||||||
|
- Stoc lent: > 90 zile
|
||||||
|
|
||||||
|
2. **Parametri configurabili**:
|
||||||
|
- Perioada de analiză (luni)
|
||||||
|
- Vânzări minime pentru analiză
|
||||||
|
- Număr maxim de rezultate
|
||||||
|
|
||||||
|
## 🤝 Personalizare
|
||||||
|
|
||||||
|
Pentru a adăuga query-uri noi:
|
||||||
|
|
||||||
|
1. Adaugă SQL-ul în `queries.py`
|
||||||
|
2. Adaugă entry în dicționarul `QUERIES`
|
||||||
|
3. (Opțional) Adaugă în `sheet_order` din `main.py`
|
||||||
|
|
||||||
|
## 📝 Note
|
||||||
|
|
||||||
|
- Raportul folosește view-urile existente din ERP
|
||||||
|
- Datele șterse (sters=0) sunt excluse automat
|
||||||
|
- Retururile și credit note-urile sunt excluse din analiză
|
||||||
|
- Producția proprie e identificată după cont (341, 345)
|
||||||
|
|
||||||
|
---
|
||||||
|
Generator creat pentru ROMFAST SRL - ERP ROA
|
||||||
85
config.py
Normal file
85
config.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Configuration module - reads settings from .env file
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load .env file from the same directory as this script
|
||||||
|
env_path = Path(__file__).parent / '.env'
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
# Oracle connection settings
|
||||||
|
ORACLE_CONFIG = {
|
||||||
|
'host': os.getenv('ORACLE_HOST', '127.0.0.1'),
|
||||||
|
'port': os.getenv('ORACLE_PORT', '1521'),
|
||||||
|
'service': os.getenv('ORACLE_SERVICE', 'XEPDB1'),
|
||||||
|
'user': os.getenv('ORACLE_USER', 'FIRMA'),
|
||||||
|
'password': os.getenv('ORACLE_PASSWORD', 'PAROLA'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build DSN string
|
||||||
|
def get_dsn():
|
||||||
|
return f"{ORACLE_CONFIG['host']}:{ORACLE_CONFIG['port']}/{ORACLE_CONFIG['service']}"
|
||||||
|
|
||||||
|
# Output settings
|
||||||
|
OUTPUT_DIR = Path(os.getenv('OUTPUT_DIR', './output'))
|
||||||
|
COMPANY_NAME = os.getenv('COMPANY_NAME', 'Data Intelligence Report')
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Analysis parameters
|
||||||
|
ANALYSIS_MONTHS = 12 # Default: last 12 months
|
||||||
|
MIN_SALES_FOR_ANALYSIS = 1000 # Minimum RON to include client in analysis
|
||||||
|
LOW_MARGIN_THRESHOLD = 15 # Alert if margin below 15%
|
||||||
|
PRICE_VARIATION_THRESHOLD = 20 # Alert if price varies > 20% for same product
|
||||||
|
|
||||||
|
# Recommendation thresholds for business intelligence
|
||||||
|
RECOMMENDATION_THRESHOLDS = {
|
||||||
|
# Margin thresholds
|
||||||
|
'marja_minima': 15, # % - below this = problem
|
||||||
|
'marja_tinta': 20, # % - target margin
|
||||||
|
|
||||||
|
# Client concentration risk
|
||||||
|
'concentrare_top5_max': 60, # % - risk if top 5 clients > 60%
|
||||||
|
'concentrare_top1_max': 25, # % - risk if single client > 25%
|
||||||
|
|
||||||
|
# Financial indicators
|
||||||
|
'dso_target': 45, # days - DSO target
|
||||||
|
'dso_alert': 60, # days - DSO alert threshold
|
||||||
|
'dpo_target': 30, # days - DPO target
|
||||||
|
|
||||||
|
# Stock indicators
|
||||||
|
'stoc_zile_max': 90, # days - slow stock threshold
|
||||||
|
'rotatie_minima': 4, # minimum annual rotation
|
||||||
|
|
||||||
|
# Receivables aging
|
||||||
|
'restante_90_procent': 10, # % - max receivables > 90 days
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# NEW: Indicatori generali de business (PLAN_INDICATORI_LICHIDITATE_YOY)
|
||||||
|
# ==========================================================================
|
||||||
|
'grad_indatorare_max': 1.0, # Datorii/Capital propriu - OK sub 1
|
||||||
|
'grad_indatorare_alert': 2.0, # Alertă peste 2.0
|
||||||
|
'autonomie_financiara_min': 0.5, # Capital propriu/Activ total - OK peste 0.5
|
||||||
|
'autonomie_financiara_alert': 0.3, # Alertă sub 0.3
|
||||||
|
'rata_datoriilor_max': 0.5, # Datorii/Activ total - OK sub 0.5
|
||||||
|
'rata_datoriilor_alert': 0.7, # Alertă peste 0.7
|
||||||
|
'marja_neta_min': 5, # % - Profit/Vânzări - OK peste 5%
|
||||||
|
'marja_neta_alert': 3, # Alertă sub 3%
|
||||||
|
'roa_min': 5, # % - Profit/Activ total - OK peste 5%
|
||||||
|
'roa_alert': 2, # Alertă sub 2%
|
||||||
|
'rotatia_activelor_min': 1.0, # Vânzări/Activ - OK peste 1.0
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# NEW: Indicatori de lichiditate
|
||||||
|
# ==========================================================================
|
||||||
|
'lichiditate_curenta_min': 1.0, # (Cash+Creanțe+Stoc)/Datorii curente - minim
|
||||||
|
'lichiditate_curenta_target': 1.5, # Ținta ideală
|
||||||
|
'lichiditate_rapida_min': 0.7, # (Cash+Creanțe)/Datorii curente - minim
|
||||||
|
'lichiditate_rapida_target': 1.0, # Ținta ideală
|
||||||
|
'cash_ratio_min': 0.1, # Cash/Datorii curente - minim
|
||||||
|
'cash_ratio_target': 0.2, # Ținta ideală
|
||||||
|
'grad_acoperire_min': 1.0, # (Cash+Încasări 30z)/Datorii 30z - minim
|
||||||
|
}
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
report-generator:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
# Mount output directory for generated reports
|
||||||
|
- ./output:/app/output
|
||||||
|
# Mount .env file (create from .env.example)
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
environment:
|
||||||
|
# For Docker Desktop on Windows: host.docker.internal resolves to Windows host
|
||||||
|
# Override ORACLE_HOST if needed
|
||||||
|
- ORACLE_HOST=${ORACLE_HOST:-host.docker.internal}
|
||||||
|
- ORACLE_PORT=${ORACLE_PORT:-1521}
|
||||||
|
- ORACLE_SERVICE=${ORACLE_SERVICE:-XEPDB1}
|
||||||
|
- ORACLE_USER=${ORACLE_USER:-FIRMA}
|
||||||
|
- ORACLE_PASSWORD=${ORACLE_PASSWORD:-PAROLA}
|
||||||
|
# Add host.docker.internal for Linux Docker (not needed on Windows/Mac)
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
670
main.py
Normal file
670
main.py
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Data Intelligence Report Generator
|
||||||
|
===================================
|
||||||
|
Generates comprehensive Excel and PDF reports with financial analytics
|
||||||
|
for ERP data stored in Oracle Database.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python main.py [--months 12] [--output-dir ./output]
|
||||||
|
|
||||||
|
Author: Claude AI for ROMFAST SRL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import warnings
|
||||||
|
warnings.filterwarnings('ignore')
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
def check_dependencies():
|
||||||
|
"""Check if all required packages are installed"""
|
||||||
|
missing = []
|
||||||
|
packages = {
|
||||||
|
'oracledb': 'oracledb',
|
||||||
|
'pandas': 'pandas',
|
||||||
|
'openpyxl': 'openpyxl',
|
||||||
|
'matplotlib': 'matplotlib',
|
||||||
|
'reportlab': 'reportlab',
|
||||||
|
'dotenv': 'python-dotenv'
|
||||||
|
}
|
||||||
|
|
||||||
|
for import_name, pip_name in packages.items():
|
||||||
|
try:
|
||||||
|
__import__(import_name)
|
||||||
|
except ImportError:
|
||||||
|
missing.append(pip_name)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print("❌ Pachete lipsă. Rulează:")
|
||||||
|
print(f" pip install {' '.join(missing)} --break-system-packages")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
check_dependencies()
|
||||||
|
|
||||||
|
import oracledb
|
||||||
|
import pandas as pd
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
ORACLE_CONFIG, get_dsn, OUTPUT_DIR, COMPANY_NAME,
|
||||||
|
ANALYSIS_MONTHS, MIN_SALES_FOR_ANALYSIS, LOW_MARGIN_THRESHOLD,
|
||||||
|
RECOMMENDATION_THRESHOLDS
|
||||||
|
)
|
||||||
|
from queries import QUERIES
|
||||||
|
from report_generator import (
|
||||||
|
ExcelReportGenerator, PDFReportGenerator,
|
||||||
|
create_monthly_chart, create_client_concentration_chart, create_production_chart,
|
||||||
|
create_cash_cycle_chart
|
||||||
|
)
|
||||||
|
from recommendations import RecommendationsEngine
|
||||||
|
|
||||||
|
|
||||||
|
class OracleConnection:
|
||||||
|
"""Context manager for Oracle database connection"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
print(f"🔌 Conectare la Oracle: {get_dsn()}...")
|
||||||
|
self.connection = oracledb.connect(
|
||||||
|
user=ORACLE_CONFIG['user'],
|
||||||
|
password=ORACLE_CONFIG['password'],
|
||||||
|
dsn=get_dsn()
|
||||||
|
)
|
||||||
|
print("✓ Conectat cu succes!")
|
||||||
|
return self.connection
|
||||||
|
except oracledb.Error as e:
|
||||||
|
print(f"❌ Eroare conexiune Oracle: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if self.connection:
|
||||||
|
self.connection.close()
|
||||||
|
print("✓ Conexiune închisă.")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_query(connection, query_name: str, query_info: dict) -> pd.DataFrame:
|
||||||
|
"""Execute a query and return results as DataFrame"""
|
||||||
|
try:
|
||||||
|
sql = query_info['sql']
|
||||||
|
params = query_info.get('params', {})
|
||||||
|
|
||||||
|
print(f" 📊 Executare: {query_name}...", end=" ")
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows, columns=columns)
|
||||||
|
print(f"✓ ({len(df)} rânduri)")
|
||||||
|
return df
|
||||||
|
|
||||||
|
except oracledb.Error as e:
|
||||||
|
print(f"❌ Eroare: {e}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reports(args):
|
||||||
|
"""Main function to generate all reports"""
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
excel_path = OUTPUT_DIR / f"data_intelligence_report_{timestamp}.xlsx"
|
||||||
|
pdf_path = OUTPUT_DIR / f"data_intelligence_report_{timestamp}.pdf"
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(" DATA INTELLIGENCE REPORT GENERATOR")
|
||||||
|
print("="*60)
|
||||||
|
print(f" Perioada: Ultimele {args.months} luni")
|
||||||
|
print(f" Output: {OUTPUT_DIR}")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
# Update parameters with command line arguments
|
||||||
|
for query_info in QUERIES.values():
|
||||||
|
if 'months' in query_info.get('params', {}):
|
||||||
|
query_info['params']['months'] = args.months
|
||||||
|
|
||||||
|
# Calculate reporting period string
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - relativedelta(months=args.months)
|
||||||
|
period_str = f"Perioada: {start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||||
|
|
||||||
|
# Add period to descriptions for queries with months parameter
|
||||||
|
for query_name, query_info in QUERIES.items():
|
||||||
|
if 'months' in query_info.get('params', {}):
|
||||||
|
original_desc = query_info.get('description', '')
|
||||||
|
query_info['description'] = f"{original_desc}\n{period_str}"
|
||||||
|
|
||||||
|
# Connect and execute queries
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
with OracleConnection() as conn:
|
||||||
|
print("\n📥 Extragere date din Oracle:\n")
|
||||||
|
|
||||||
|
for query_name, query_info in QUERIES.items():
|
||||||
|
df = execute_query(conn, query_name, query_info)
|
||||||
|
results[query_name] = df
|
||||||
|
|
||||||
|
# Generate Excel Report
|
||||||
|
print("\n📝 Generare raport Excel...")
|
||||||
|
excel_gen = ExcelReportGenerator(excel_path)
|
||||||
|
|
||||||
|
# Generate recommendations based on all data
|
||||||
|
print("\n🔍 Generare recomandări automate...")
|
||||||
|
recommendations_engine = RecommendationsEngine(RECOMMENDATION_THRESHOLDS)
|
||||||
|
recommendations_df = recommendations_engine.analyze_all(results)
|
||||||
|
results['recomandari'] = recommendations_df
|
||||||
|
print(f"✓ {len(recommendations_df)} recomandări generate")
|
||||||
|
|
||||||
|
# Add sheets in logical order (updated per PLAN_INDICATORI_LICHIDITATE_YOY.md)
|
||||||
|
sheet_order = [
|
||||||
|
# SUMAR EXECUTIV
|
||||||
|
'sumar_executiv',
|
||||||
|
'sumar_executiv_yoy',
|
||||||
|
'recomandari',
|
||||||
|
|
||||||
|
# INDICATORI AGREGATI (MUTATI SUS - imagine de ansamblu)
|
||||||
|
'indicatori_agregati_venituri',
|
||||||
|
'indicatori_agregati_venituri_yoy',
|
||||||
|
'portofoliu_clienti',
|
||||||
|
'concentrare_risc',
|
||||||
|
'concentrare_risc_yoy',
|
||||||
|
'sezonalitate_lunara',
|
||||||
|
|
||||||
|
# INDICATORI GENERALI & LICHIDITATE
|
||||||
|
'indicatori_generali',
|
||||||
|
'indicatori_lichiditate',
|
||||||
|
'clasificare_datorii',
|
||||||
|
'grad_acoperire_datorii',
|
||||||
|
'proiectie_lichiditate',
|
||||||
|
|
||||||
|
# ALERTE
|
||||||
|
'vanzari_sub_cost',
|
||||||
|
'clienti_marja_mica',
|
||||||
|
|
||||||
|
# CICLU CASH
|
||||||
|
'ciclu_conversie_cash',
|
||||||
|
|
||||||
|
# ANALIZA CLIENTI
|
||||||
|
'marja_per_client',
|
||||||
|
'clienti_ranking_profit',
|
||||||
|
'frecventa_clienti',
|
||||||
|
'concentrare_clienti',
|
||||||
|
'trending_clienti',
|
||||||
|
'marja_client_categorie',
|
||||||
|
|
||||||
|
# PRODUSE
|
||||||
|
'top_produse',
|
||||||
|
'marja_per_categorie',
|
||||||
|
'marja_per_gestiune',
|
||||||
|
'articole_negestionabile',
|
||||||
|
'productie_vs_revanzare',
|
||||||
|
|
||||||
|
# PRETURI
|
||||||
|
'dispersie_preturi',
|
||||||
|
'clienti_sub_medie',
|
||||||
|
'evolutie_discount',
|
||||||
|
|
||||||
|
# FINANCIAR
|
||||||
|
'dso_dpo',
|
||||||
|
'dso_dpo_yoy',
|
||||||
|
'solduri_clienti',
|
||||||
|
'aging_creante',
|
||||||
|
'facturi_restante',
|
||||||
|
'solduri_furnizori',
|
||||||
|
'aging_datorii',
|
||||||
|
'facturi_restante_furnizori',
|
||||||
|
'pozitia_cash',
|
||||||
|
|
||||||
|
# ISTORIC
|
||||||
|
'vanzari_lunare',
|
||||||
|
|
||||||
|
# STOC
|
||||||
|
'stoc_curent',
|
||||||
|
'stoc_lent',
|
||||||
|
'rotatie_stocuri',
|
||||||
|
|
||||||
|
# PRODUCTIE
|
||||||
|
'analiza_prajitorie',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Legends for each sheet explaining column calculations
|
||||||
|
legends = {
|
||||||
|
'recomandari': {
|
||||||
|
'CATEGORIE': 'Domeniu: Marja, Clienti, Stoc, Financiar',
|
||||||
|
'STATUS': 'OK = bine, ATENTIE = necesita atentie, ALERTA = actiune urgenta',
|
||||||
|
'VEZI_DETALII': 'Sheet-ul cu date detaliate pentru acest indicator'
|
||||||
|
},
|
||||||
|
'marja_per_client': {
|
||||||
|
'VANZARI_FARA_TVA': 'SUM(cantitate × preț) din fact_vfacturi_detalii',
|
||||||
|
'COST_TOTAL': 'SUM(cantitate × pret_achizitie)',
|
||||||
|
'MARJA_BRUTA': 'Vânzări - Cost = SUM(cantitate × (preț - pret_achizitie))',
|
||||||
|
'PROCENT_MARJA': 'Marja Brută / Vânzări × 100'
|
||||||
|
},
|
||||||
|
'clienti_marja_mica': {
|
||||||
|
'VANZARI_FARA_TVA': 'SUM(cantitate × preț) pentru client',
|
||||||
|
'MARJA_BRUTA': 'Vânzări - Cost',
|
||||||
|
'PROCENT_MARJA': 'Marja / Vânzări × 100 (sub 15% = alertă)'
|
||||||
|
},
|
||||||
|
'vanzari_sub_cost': {
|
||||||
|
'PRET_VANZARE': 'Preț unitar din factură (fact_vfacturi_detalii.pret)',
|
||||||
|
'COST': 'Preț achiziție (pret_achizitie)',
|
||||||
|
'PIERDERE': '(Preț vânzare - Cost) × Cantitate (negativ = pierdere)'
|
||||||
|
},
|
||||||
|
'stoc_curent': {
|
||||||
|
'TIP_GESTIUNE': 'Preț vânzare (nr_pag=7) sau Preț achiziție',
|
||||||
|
'VALOARE_STOC_ACHIZITIE': '(cants+cant-cante) × pret din vstoc',
|
||||||
|
'VALOARE_STOC_VANZARE': 'Doar pentru gestiuni preț vânzare, altfel gol'
|
||||||
|
},
|
||||||
|
'stoc_lent': {
|
||||||
|
'CANTITATE': 'Stoc final = cants + cant - cante',
|
||||||
|
'VALOARE': 'Cantitate × preț achiziție',
|
||||||
|
'ZILE_FARA_MISCARE': 'Zile de la ultima ieșire (dataout) sau intrare'
|
||||||
|
},
|
||||||
|
'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',
|
||||||
|
'ROTATIE': 'Vânzări / Stoc (de câte ori s-a rotit stocul)',
|
||||||
|
'ZILE_STOC': 'La ritmul actual, în câte zile se epuizează'
|
||||||
|
},
|
||||||
|
'dispersie_preturi': {
|
||||||
|
'NR_TRANZACTII': 'Număr total linii factură pentru acest produs',
|
||||||
|
'VARIATIE_PROCENT': '(Preț Max - Preț Min) / Preț Mediu × 100',
|
||||||
|
'NR_LA_PRET_MIN': 'Câte tranzacții au fost la prețul minim',
|
||||||
|
'CLIENT_PRET_MIN': 'Primul client care a cumpărat la preț minim'
|
||||||
|
},
|
||||||
|
'top_produse': {
|
||||||
|
'VALOARE_VANZARI': 'SUM(cantitate × preț)',
|
||||||
|
'MARJA_BRUTA': 'SUM(cantitate × (preț - pret_achizitie))',
|
||||||
|
'PROCENT_MARJA': 'Marja / Vânzări × 100'
|
||||||
|
},
|
||||||
|
'marja_per_categorie': {
|
||||||
|
'VANZARI_FARA_TVA': 'Total vânzări pe subgrupă',
|
||||||
|
'COST_TOTAL': 'Total cost achiziție pe subgrupă',
|
||||||
|
'PROCENT_MARJA': 'Marja / Vânzări × 100'
|
||||||
|
},
|
||||||
|
'marja_per_gestiune': {
|
||||||
|
'VANZARI_FARA_TVA': 'Total vânzări pe gestiune (doar articole gestionabile)',
|
||||||
|
'MARJA_BRUTA': 'Total marjă pe gestiune',
|
||||||
|
'PROCENT_MARJA': 'Marja / Vânzări × 100'
|
||||||
|
},
|
||||||
|
'articole_negestionabile': {
|
||||||
|
'DENUMIRE': 'Nume articol negestionabil (in_stoc=0)',
|
||||||
|
'VANZARI_FARA_TVA': 'Total vânzări pentru articole care nu se țin pe stoc',
|
||||||
|
'MARJA_BRUTA': 'Vânzări - Cost',
|
||||||
|
'PROCENT_MARJA': 'Marja / Vânzări × 100'
|
||||||
|
},
|
||||||
|
'vanzari_lunare': {
|
||||||
|
'VANZARI_FARA_TVA': 'Total vânzări în lună',
|
||||||
|
'MARJA_BRUTA': 'Total marjă în lună',
|
||||||
|
'NR_FACTURI': 'Număr facturi emise',
|
||||||
|
'NR_CLIENTI': 'Clienți unici activi'
|
||||||
|
},
|
||||||
|
# NEW legends for financial and aggregated sheets
|
||||||
|
'indicatori_agregati_venituri': {
|
||||||
|
'LINIE_BUSINESS': 'Producție proprie / Materii prime / Marfă revândută',
|
||||||
|
'PROCENT_VENITURI': 'Contribuția la totalul vânzărilor',
|
||||||
|
'CONTRIBUTIE_PROFIT': 'Contribuția la profitul total (%)'
|
||||||
|
},
|
||||||
|
'sezonalitate_lunara': {
|
||||||
|
'MEDIE_VANZARI': 'Media vânzărilor pe 24 luni pentru această lună',
|
||||||
|
'DEVIERE_PROCENT': 'Cât de mult deviază de la media globală',
|
||||||
|
'CLASIFICARE': 'LUNĂ PUTERNICĂ / LUNĂ SLABĂ / NORMAL'
|
||||||
|
},
|
||||||
|
'portofoliu_clienti': {
|
||||||
|
'VALOARE': 'Numărul de clienți în fiecare categorie',
|
||||||
|
'EXPLICATIE': 'Definiția categoriei de clienți'
|
||||||
|
},
|
||||||
|
'concentrare_risc': {
|
||||||
|
'PROCENT': 'Procentul din vânzări pentru Top N clienți',
|
||||||
|
'STATUS': 'OK / ATENTIE / RISC MARE'
|
||||||
|
},
|
||||||
|
'ciclu_conversie_cash': {
|
||||||
|
'INDICATOR': 'DIO (zile stoc) + DSO (zile încasare) - DPO (zile plată)',
|
||||||
|
'ZILE': 'Numărul de zile pentru fiecare component',
|
||||||
|
'EXPLICATIE': 'Ce reprezintă fiecare indicator'
|
||||||
|
},
|
||||||
|
'clienti_ranking_profit': {
|
||||||
|
'RANG_PROFIT': 'Poziția clientului după profit (nu vânzări)',
|
||||||
|
'RANG_VANZARI': 'Poziția clientului după vânzări',
|
||||||
|
'PROFIT_BRUT': 'Vânzări - Cost = profitul efectiv adus'
|
||||||
|
},
|
||||||
|
'frecventa_clienti': {
|
||||||
|
'COMENZI_PE_LUNA': 'Media comenzilor pe lună',
|
||||||
|
'VALOARE_MEDIE_COMANDA': 'Valoarea medie per comandă',
|
||||||
|
'EVOLUTIE_FRECVENTA_YOY': 'Schimbarea frecvenței față de anul trecut'
|
||||||
|
},
|
||||||
|
'marja_client_categorie': {
|
||||||
|
'STATUS_MARJA': 'OK / MARJĂ MICĂ (<15%) / PIERDERE (negativă)',
|
||||||
|
'CATEGORIA': 'Grupa de produse',
|
||||||
|
'PROCENT_MARJA': 'Marja pentru acest client la această categorie'
|
||||||
|
},
|
||||||
|
'evolutie_discount': {
|
||||||
|
'PRET_INITIAL': 'Prețul mediu în primele 6 luni',
|
||||||
|
'PRET_ACTUAL': 'Prețul mediu în ultimele 6 luni',
|
||||||
|
'VARIATIE_PRET_PROCENT': 'Scăderea/creșterea prețului (negativ = discount)'
|
||||||
|
},
|
||||||
|
'dso_dpo': {
|
||||||
|
'DSO': 'Days Sales Outstanding - zile medii încasare clienți',
|
||||||
|
'DPO': 'Days Payables Outstanding - zile medii plată furnizori',
|
||||||
|
'STATUS': 'OK / ATENTIE / ALERTA'
|
||||||
|
},
|
||||||
|
'solduri_clienti': {
|
||||||
|
'SOLD_CURENT': 'Suma de încasat de la client (din cont 4111)',
|
||||||
|
'TIP_SOLD': 'Creanță (ne datorează) sau Avans client (am încasat în avans)'
|
||||||
|
},
|
||||||
|
'aging_creante': {
|
||||||
|
'NEAJUNS_SCADENTA': 'Facturi nescadente încă',
|
||||||
|
'ZILE_1_30': 'Restanțe 1-30 zile',
|
||||||
|
'PESTE_90_ZILE': 'Restanțe critice >90 zile - risc de neîncasare'
|
||||||
|
},
|
||||||
|
'facturi_restante': {
|
||||||
|
'ZILE_INTARZIERE': 'Zile de la scadență',
|
||||||
|
'SUMA_RESTANTA': 'Valoarea rămasă de încasat'
|
||||||
|
},
|
||||||
|
'aging_datorii': {
|
||||||
|
'NEAJUNS_SCADENTA': 'Datorii neajunse la scadență',
|
||||||
|
'ZILE_1_30': 'Restanțe 1-30 zile',
|
||||||
|
'ZILE_31_60': 'Restanțe 31-60 zile',
|
||||||
|
'ZILE_61_90': 'Restanțe 61-90 zile',
|
||||||
|
'PESTE_90_ZILE': 'Restanțe critice >90 zile',
|
||||||
|
'TOTAL_SOLD': 'Total datorii către furnizor'
|
||||||
|
},
|
||||||
|
'facturi_restante_furnizori': {
|
||||||
|
'ZILE_INTARZIERE': 'Zile de la scadență',
|
||||||
|
'SUMA_RESTANTA': 'Valoarea rămasă de plătit'
|
||||||
|
},
|
||||||
|
'solduri_furnizori': {
|
||||||
|
'SOLD_CURENT': 'Suma de plătit furnizorului (din cont 401)',
|
||||||
|
'TIP_SOLD': 'Datorie (trebuie să plătim) sau Avans (am plătit în avans)'
|
||||||
|
},
|
||||||
|
'pozitia_cash': {
|
||||||
|
'SOLD_CURENT': 'Disponibilul curent în cont/casă',
|
||||||
|
'DESCRIERE': 'Tipul contului (bancă/casă, lei/valută)'
|
||||||
|
},
|
||||||
|
# =====================================================================
|
||||||
|
# NEW: Legends for Indicatori Generali, Lichiditate, YoY sheets
|
||||||
|
# =====================================================================
|
||||||
|
'indicatori_generali': {
|
||||||
|
'INDICATOR': 'Grad îndatorare, autonomie financiară, ROA, marjă netă',
|
||||||
|
'VALOARE': 'Valoarea calculată a indicatorului',
|
||||||
|
'STATUS': 'OK / ATENȚIE / ALERTĂ bazat pe praguri standard',
|
||||||
|
'RECOMANDARE': 'Acțiune sugerată pentru îmbunătățire'
|
||||||
|
},
|
||||||
|
'indicatori_lichiditate': {
|
||||||
|
'INDICATOR': 'Lichiditate curentă, rapidă, cash ratio, fond de rulment',
|
||||||
|
'VALOARE': 'Valoarea calculată (rată sau sumă RON)',
|
||||||
|
'STATUS': 'OK / ATENȚIE / ALERTĂ',
|
||||||
|
'INTERPRETARE': 'Ce înseamnă valoarea pentru business'
|
||||||
|
},
|
||||||
|
'clasificare_datorii': {
|
||||||
|
'CATEGORIE': 'Termen scurt (<30z) / mediu (31-90z) / lung (>90z)',
|
||||||
|
'VALOARE': 'Suma datoriilor în categoria respectivă',
|
||||||
|
'NR_FACTURI': 'Numărul facturilor în acea categorie'
|
||||||
|
},
|
||||||
|
'grad_acoperire_datorii': {
|
||||||
|
'VALOARE': 'Cash disponibil + încasări așteptate vs plăți scadente',
|
||||||
|
'ACOPERIRE': 'OK / ATENȚIE / DEFICIT - dacă puteți plăti datoriile',
|
||||||
|
'EXPLICATIE': 'Ce înseamnă pentru fluxul de numerar'
|
||||||
|
},
|
||||||
|
'proiectie_lichiditate': {
|
||||||
|
'PERIOADA': 'Azi / 30 zile / 60 zile / 90 zile',
|
||||||
|
'SOLD_PROIECTAT': 'Cash estimat la sfârșitul perioadei',
|
||||||
|
'FLUX_NET': 'Încasări - Plăți pentru perioada respectivă',
|
||||||
|
'STATUS': 'OK dacă sold pozitiv, ALERTĂ dacă negativ'
|
||||||
|
},
|
||||||
|
'sumar_executiv_yoy': {
|
||||||
|
'VALOARE_CURENTA': 'Valoarea din ultimele 12 luni',
|
||||||
|
'VALOARE_ANTERIOARA': 'Valoarea din anul anterior (12-24 luni)',
|
||||||
|
'VARIATIE_PROCENT': 'Creștere/scădere procentuală',
|
||||||
|
'TREND': 'CREȘTERE / SCĂDERE / STABIL'
|
||||||
|
},
|
||||||
|
'dso_dpo_yoy': {
|
||||||
|
'VALOARE_CURENTA': 'Zile încasare/plată actuale',
|
||||||
|
'VALOARE_ANTERIOARA': 'Zile în perioada anterioară',
|
||||||
|
'VARIATIE_ZILE': 'Diferența în zile (+ = mai rău pentru DSO, mai bine pentru DPO)',
|
||||||
|
'TREND': 'ÎMBUNĂTĂȚIRE / DETERIORARE / STABIL'
|
||||||
|
},
|
||||||
|
'concentrare_risc_yoy': {
|
||||||
|
'PROCENT_CURENT': '% vânzări la Top N clienți - an curent',
|
||||||
|
'PROCENT_ANTERIOR': '% vânzări la Top N clienți - an trecut',
|
||||||
|
'VARIATIE': 'Schimbarea în puncte procentuale',
|
||||||
|
'TREND': 'DIVERSIFICARE (bine) / CONCENTRARE (risc) / STABIL'
|
||||||
|
},
|
||||||
|
'indicatori_agregati_venituri_yoy': {
|
||||||
|
'LINIE_BUSINESS': 'Producție proprie / Materii prime / Marfă',
|
||||||
|
'VANZARI_CURENTE': 'Vânzări în ultimele 12 luni',
|
||||||
|
'VANZARI_ANTERIOARE': 'Vânzări în perioada anterioară',
|
||||||
|
'VARIATIE_PROCENT': 'Creștere/scădere procentuală',
|
||||||
|
'TREND': 'CREȘTERE / SCĂDERE / STABIL'
|
||||||
|
},
|
||||||
|
'analiza_prajitorie': {
|
||||||
|
'CANTITATE_INTRARI': 'Cantitate intrata (cant > 0, cante = 0)',
|
||||||
|
'VALOARE_INTRARI': 'Valoare intrari = cantitate x pret',
|
||||||
|
'CANTITATE_IESIRI': 'Cantitate iesita (cant = 0, cante > 0)',
|
||||||
|
'VALOARE_IESIRI': 'Valoare iesiri = cantitate x pret',
|
||||||
|
'CANTITATE_TRANSFORMARI_IN': 'Cantitate intrata in transformari',
|
||||||
|
'CANTITATE_TRANSFORMARI_OUT': 'Cantitate iesita din transformari',
|
||||||
|
'SOLD_NET_CANTITATE': 'Sold net = Total intrari - Total iesiri',
|
||||||
|
'SOLD_NET_VALOARE': 'Valoare neta a soldului'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for query_name in sheet_order:
|
||||||
|
if query_name in results:
|
||||||
|
# Tratare speciala pentru 'sumar_executiv' - adauga recomandari sub KPIs
|
||||||
|
if query_name == 'sumar_executiv':
|
||||||
|
query_info = QUERIES.get(query_name, {})
|
||||||
|
excel_gen.add_sheet_with_recommendations(
|
||||||
|
name='Sumar Executiv',
|
||||||
|
df=results['sumar_executiv'],
|
||||||
|
recommendations_df=results.get('recomandari'),
|
||||||
|
title=query_info.get('title', 'Sumar Executiv'),
|
||||||
|
description=query_info.get('description', ''),
|
||||||
|
legend=legends.get('sumar_executiv'),
|
||||||
|
top_n_recommendations=5
|
||||||
|
)
|
||||||
|
# Pastreaza sheet-ul complet de recomandari
|
||||||
|
elif query_name == 'recomandari':
|
||||||
|
excel_gen.add_sheet(
|
||||||
|
name='RECOMANDARI',
|
||||||
|
df=results['recomandari'],
|
||||||
|
title='Recomandari Automate (Lista Completa)',
|
||||||
|
description='Toate insight-urile si actiunile sugerate bazate pe analiza datelor',
|
||||||
|
legend=legends.get('recomandari')
|
||||||
|
)
|
||||||
|
elif query_name in QUERIES:
|
||||||
|
query_info = QUERIES[query_name]
|
||||||
|
# Create short sheet name from query name
|
||||||
|
sheet_name = query_name.replace('_', ' ').title()[:31]
|
||||||
|
excel_gen.add_sheet(
|
||||||
|
name=sheet_name,
|
||||||
|
df=results[query_name],
|
||||||
|
title=query_info.get('title', query_name),
|
||||||
|
description=query_info.get('description', ''),
|
||||||
|
legend=legends.get(query_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
excel_gen.save()
|
||||||
|
|
||||||
|
# Generate PDF Report
|
||||||
|
print("\n📄 Generare raport PDF...")
|
||||||
|
pdf_gen = PDFReportGenerator(pdf_path, company_name=COMPANY_NAME)
|
||||||
|
|
||||||
|
# Title page
|
||||||
|
pdf_gen.add_title_page()
|
||||||
|
|
||||||
|
# KPIs
|
||||||
|
pdf_gen.add_kpi_section(results.get('sumar_executiv'))
|
||||||
|
|
||||||
|
# NEW: Indicatori Generali section
|
||||||
|
if 'indicatori_generali' in results and not results['indicatori_generali'].empty:
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Indicatori Generali de Business",
|
||||||
|
results.get('indicatori_generali'),
|
||||||
|
columns=['INDICATOR', 'VALOARE', 'STATUS', 'RECOMANDARE'],
|
||||||
|
max_rows=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# NEW: Indicatori Lichiditate section
|
||||||
|
if 'indicatori_lichiditate' in results and not results['indicatori_lichiditate'].empty:
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Indicatori de Lichiditate",
|
||||||
|
results.get('indicatori_lichiditate'),
|
||||||
|
columns=['INDICATOR', 'VALOARE', 'STATUS', 'RECOMANDARE'],
|
||||||
|
max_rows=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# NEW: Proiecție Lichiditate
|
||||||
|
if 'proiectie_lichiditate' in results and not results['proiectie_lichiditate'].empty:
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Proiecție Cash Flow 30/60/90 zile",
|
||||||
|
results.get('proiectie_lichiditate'),
|
||||||
|
columns=['PERIOADA', 'SOLD_PROIECTAT', 'INCASARI', 'PLATI', 'STATUS'],
|
||||||
|
max_rows=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# NEW: Recommendations section (top priorities)
|
||||||
|
if 'recomandari' in results and not results['recomandari'].empty:
|
||||||
|
pdf_gen.add_recommendations_section(results['recomandari'])
|
||||||
|
|
||||||
|
# Alerts
|
||||||
|
pdf_gen.add_alerts_section({
|
||||||
|
'vanzari_sub_cost': results.get('vanzari_sub_cost', pd.DataFrame()),
|
||||||
|
'clienti_marja_mica': results.get('clienti_marja_mica', pd.DataFrame())
|
||||||
|
})
|
||||||
|
|
||||||
|
pdf_gen.add_page_break()
|
||||||
|
|
||||||
|
# Monthly chart
|
||||||
|
if 'vanzari_lunare' in results and not results['vanzari_lunare'].empty:
|
||||||
|
fig = create_monthly_chart(results['vanzari_lunare'])
|
||||||
|
pdf_gen.add_chart_image(fig, "Evoluția Vânzărilor și Marjei")
|
||||||
|
|
||||||
|
# Client concentration
|
||||||
|
if 'concentrare_clienti' in results and not results['concentrare_clienti'].empty:
|
||||||
|
fig = create_client_concentration_chart(results['concentrare_clienti'])
|
||||||
|
pdf_gen.add_chart_image(fig, "Concentrare Clienți")
|
||||||
|
|
||||||
|
pdf_gen.add_page_break()
|
||||||
|
|
||||||
|
# NEW: Cash Conversion Cycle chart
|
||||||
|
if 'ciclu_conversie_cash' in results and not results['ciclu_conversie_cash'].empty:
|
||||||
|
fig = create_cash_cycle_chart(results['ciclu_conversie_cash'])
|
||||||
|
pdf_gen.add_chart_image(fig, "Ciclu Conversie Cash (DIO + DSO - DPO)")
|
||||||
|
|
||||||
|
# Production vs Resale
|
||||||
|
if 'productie_vs_revanzare' in results and not results['productie_vs_revanzare'].empty:
|
||||||
|
fig = create_production_chart(results['productie_vs_revanzare'])
|
||||||
|
pdf_gen.add_chart_image(fig, "Producție Proprie vs Revânzare")
|
||||||
|
|
||||||
|
# Top clients table
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Top 15 Clienți după Vânzări",
|
||||||
|
results.get('marja_per_client'),
|
||||||
|
columns=['CLIENT', 'VANZARI_FARA_TVA', 'MARJA_BRUTA', 'PROCENT_MARJA'],
|
||||||
|
max_rows=15
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_gen.add_page_break()
|
||||||
|
|
||||||
|
# Top products
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Top 15 Produse după Vânzări",
|
||||||
|
results.get('top_produse'),
|
||||||
|
columns=['PRODUS', 'VALOARE_VANZARI', 'MARJA_BRUTA', 'PROCENT_MARJA'],
|
||||||
|
max_rows=15
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trending clients
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Trending Clienți (YoY)",
|
||||||
|
results.get('trending_clienti'),
|
||||||
|
columns=['CLIENT', 'VANZARI_12_LUNI', 'VANZARI_AN_ANTERIOR', 'VARIATIE_PROCENT', 'TREND'],
|
||||||
|
max_rows=15
|
||||||
|
)
|
||||||
|
|
||||||
|
# NEW: Aging Creanțe table
|
||||||
|
if 'aging_creante' in results and not results['aging_creante'].empty:
|
||||||
|
pdf_gen.add_page_break()
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Aging Creanțe (Vechime Facturi Neîncasate)",
|
||||||
|
results.get('aging_creante'),
|
||||||
|
columns=['CLIENT', 'NEAJUNS_SCADENTA', 'ZILE_1_30', 'ZILE_31_60', 'PESTE_90_ZILE', 'TOTAL_SOLD'],
|
||||||
|
max_rows=15
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stoc lent
|
||||||
|
if 'stoc_lent' in results and not results['stoc_lent'].empty:
|
||||||
|
pdf_gen.add_page_break()
|
||||||
|
pdf_gen.add_table_section(
|
||||||
|
"Stoc Lent (>90 zile fără mișcare)",
|
||||||
|
results.get('stoc_lent'),
|
||||||
|
columns=['PRODUS', 'NUME_GESTIUNE', 'CANTITATE', 'VALOARE', 'ZILE_FARA_MISCARE'],
|
||||||
|
max_rows=20
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_gen.save()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(" ✅ RAPOARTE GENERATE CU SUCCES!")
|
||||||
|
print("="*60)
|
||||||
|
print(f"\n 📊 Excel: {excel_path}")
|
||||||
|
print(f" 📄 PDF: {pdf_path}")
|
||||||
|
print("\n" + "="*60)
|
||||||
|
|
||||||
|
return excel_path, pdf_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Data Intelligence Report Generator',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Exemple:
|
||||||
|
python main.py # Raport pentru ultimele 12 luni
|
||||||
|
python main.py --months 6 # Raport pentru ultimele 6 luni
|
||||||
|
python main.py --output-dir /tmp # Salvare în alt director
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--months', '-m',
|
||||||
|
type=int,
|
||||||
|
default=ANALYSIS_MONTHS,
|
||||||
|
help=f'Numărul de luni pentru analiză (default: {ANALYSIS_MONTHS})'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-dir', '-o',
|
||||||
|
type=Path,
|
||||||
|
default=OUTPUT_DIR,
|
||||||
|
help=f'Directorul pentru output (default: {OUTPUT_DIR})'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
generate_reports(args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Întrerupt de utilizator.")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Eroare: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
2480
queries.py
Normal file
2480
queries.py
Normal file
File diff suppressed because it is too large
Load Diff
581
recommendations.py
Normal file
581
recommendations.py
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
773
report_generator.py
Normal file
773
report_generator.py
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
"""
|
||||||
|
Report Generator Module
|
||||||
|
Generates Excel and PDF reports from query results
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg') # Non-interactive backend
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.ticker as ticker
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||||
|
from openpyxl.utils.dataframe import dataframe_to_rows
|
||||||
|
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import A4, landscape
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import cm, mm
|
||||||
|
from reportlab.platypus import (
|
||||||
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||||
|
PageBreak, Image, KeepTogether
|
||||||
|
)
|
||||||
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelReportGenerator:
|
||||||
|
"""Generate Excel reports with multiple sheets and formatting"""
|
||||||
|
|
||||||
|
def __init__(self, output_path: Path):
|
||||||
|
self.output_path = output_path
|
||||||
|
self.wb = Workbook()
|
||||||
|
# Remove default sheet
|
||||||
|
self.wb.remove(self.wb.active)
|
||||||
|
|
||||||
|
# Define styles
|
||||||
|
self.header_font = Font(bold=True, color='FFFFFF', size=11)
|
||||||
|
self.header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
|
||||||
|
self.alert_fill = PatternFill(start_color='FF6B6B', end_color='FF6B6B', fill_type='solid')
|
||||||
|
self.warning_fill = PatternFill(start_color='FFE66D', end_color='FFE66D', fill_type='solid')
|
||||||
|
self.good_fill = PatternFill(start_color='4ECDC4', end_color='4ECDC4', fill_type='solid')
|
||||||
|
self.border = Border(
|
||||||
|
left=Side(style='thin'),
|
||||||
|
right=Side(style='thin'),
|
||||||
|
top=Side(style='thin'),
|
||||||
|
bottom=Side(style='thin')
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_sheet(self, name: str, df: pd.DataFrame, title: str = None, description: str = None, legend: dict = None):
|
||||||
|
"""Add a formatted sheet to the workbook with optional legend"""
|
||||||
|
# Truncate sheet name to 31 chars (Excel limit)
|
||||||
|
sheet_name = name[:31]
|
||||||
|
ws = self.wb.create_sheet(title=sheet_name)
|
||||||
|
|
||||||
|
start_row = 1
|
||||||
|
|
||||||
|
# Add title if provided
|
||||||
|
if title:
|
||||||
|
ws.cell(row=start_row, column=1, value=title)
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=14)
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Add description if provided
|
||||||
|
if description:
|
||||||
|
ws.cell(row=start_row, column=1, value=description)
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(italic=True, size=10, color='666666')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Add timestamp
|
||||||
|
ws.cell(row=start_row, column=1, value=f"Generat: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(size=9, color='999999')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Add legend if provided
|
||||||
|
if legend:
|
||||||
|
start_row += 1
|
||||||
|
ws.cell(row=start_row, column=1, value="Explicații calcule:")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=9, color='336699')
|
||||||
|
start_row += 1
|
||||||
|
for col_name, explanation in legend.items():
|
||||||
|
ws.cell(row=start_row, column=1, value=f"• {col_name}: {explanation}")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(size=8, color='666666')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
if df is None or df.empty:
|
||||||
|
ws.cell(row=start_row, column=1, value="Nu există date pentru această analiză.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Write headers
|
||||||
|
for col_idx, col_name in enumerate(df.columns, 1):
|
||||||
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
||||||
|
cell.font = self.header_font
|
||||||
|
cell.fill = self.header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
|
cell.border = self.border
|
||||||
|
|
||||||
|
# Write data
|
||||||
|
for row_idx, row in enumerate(df.itertuples(index=False), start_row + 1):
|
||||||
|
for col_idx, value in enumerate(row, 1):
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = self.border
|
||||||
|
|
||||||
|
# Format numbers
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
cell.number_format = '#,##0.00' if isinstance(value, float) else '#,##0'
|
||||||
|
cell.alignment = Alignment(horizontal='right')
|
||||||
|
|
||||||
|
# Highlight negative values
|
||||||
|
if isinstance(value, (int, float)) and value < 0:
|
||||||
|
cell.fill = self.alert_fill
|
||||||
|
|
||||||
|
# Highlight low margins
|
||||||
|
col_name = df.columns[col_idx - 1].lower()
|
||||||
|
if 'procent' in col_name or 'marja' in col_name:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
if value < 10:
|
||||||
|
cell.fill = self.alert_fill
|
||||||
|
elif value < 15:
|
||||||
|
cell.fill = self.warning_fill
|
||||||
|
elif value > 25:
|
||||||
|
cell.fill = self.good_fill
|
||||||
|
|
||||||
|
# Highlight TREND column for YoY sheets
|
||||||
|
if col_name == 'trend':
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value in ('CRESTERE', 'IMBUNATATIRE', 'DIVERSIFICARE'):
|
||||||
|
cell.fill = self.good_fill
|
||||||
|
elif value in ('SCADERE', 'DETERIORARE', 'CONCENTRARE', 'PIERDUT'):
|
||||||
|
cell.fill = self.alert_fill
|
||||||
|
elif value == 'ATENTIE':
|
||||||
|
cell.fill = self.warning_fill
|
||||||
|
|
||||||
|
# Highlight STATUS column
|
||||||
|
if col_name == 'status' or col_name == 'acoperire':
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value == 'OK':
|
||||||
|
cell.fill = self.good_fill
|
||||||
|
elif value in ('ATENTIE', 'NECESAR'):
|
||||||
|
cell.fill = self.warning_fill
|
||||||
|
elif value in ('ALERTA', 'DEFICIT', 'RISC MARE'):
|
||||||
|
cell.fill = self.alert_fill
|
||||||
|
|
||||||
|
# Highlight variatie columns (positive = green, negative = red)
|
||||||
|
if 'variatie' in col_name:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
if value > 0:
|
||||||
|
cell.fill = self.good_fill
|
||||||
|
elif value < 0:
|
||||||
|
cell.fill = self.alert_fill
|
||||||
|
|
||||||
|
# Auto-adjust column widths
|
||||||
|
for col_idx, col_name in enumerate(df.columns, 1):
|
||||||
|
max_length = len(str(col_name))
|
||||||
|
for row in df.itertuples(index=False):
|
||||||
|
cell_value = row[col_idx - 1]
|
||||||
|
if cell_value:
|
||||||
|
max_length = max(max_length, len(str(cell_value)))
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[get_column_letter(col_idx)].width = adjusted_width
|
||||||
|
|
||||||
|
# Freeze header row
|
||||||
|
ws.freeze_panes = ws.cell(row=start_row + 1, column=1)
|
||||||
|
|
||||||
|
def add_sheet_with_recommendations(self, name: str, df: pd.DataFrame,
|
||||||
|
recommendations_df: pd.DataFrame,
|
||||||
|
title: str = None, description: str = None,
|
||||||
|
legend: dict = None, top_n_recommendations: int = 5):
|
||||||
|
"""Adauga sheet formatat cu KPIs si top recomandari dedesubt"""
|
||||||
|
sheet_name = name[:31]
|
||||||
|
ws = self.wb.create_sheet(title=sheet_name)
|
||||||
|
|
||||||
|
start_row = 1
|
||||||
|
|
||||||
|
# Adauga titlu
|
||||||
|
if title:
|
||||||
|
ws.cell(row=start_row, column=1, value=title)
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=14)
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Adauga descriere
|
||||||
|
if description:
|
||||||
|
ws.cell(row=start_row, column=1, value=description)
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(italic=True, size=10, color='666666')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Adauga timestamp
|
||||||
|
ws.cell(row=start_row, column=1, value=f"Generat: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(size=9, color='999999')
|
||||||
|
start_row += 2
|
||||||
|
|
||||||
|
# === SECTIUNEA 1: KPIs ===
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
# Header
|
||||||
|
for col_idx, col_name in enumerate(df.columns, 1):
|
||||||
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
||||||
|
cell.font = self.header_font
|
||||||
|
cell.fill = self.header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
|
cell.border = self.border
|
||||||
|
|
||||||
|
# Date
|
||||||
|
for row_idx, row in enumerate(df.itertuples(index=False), start_row + 1):
|
||||||
|
for col_idx, value in enumerate(row, 1):
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = self.border
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
cell.number_format = '#,##0.00' if isinstance(value, float) else '#,##0'
|
||||||
|
cell.alignment = Alignment(horizontal='right')
|
||||||
|
|
||||||
|
start_row = start_row + len(df) + 3
|
||||||
|
|
||||||
|
# === SECTIUNEA 2: TOP RECOMANDARI ===
|
||||||
|
if recommendations_df is not None and not recommendations_df.empty:
|
||||||
|
ws.cell(row=start_row, column=1, value="Top Recomandari Prioritare")
|
||||||
|
ws.cell(row=start_row, column=1).font = Font(bold=True, size=12, color='366092')
|
||||||
|
start_row += 1
|
||||||
|
|
||||||
|
# Sorteaza dupa prioritate (ALERTA primul, apoi ATENTIE, apoi OK)
|
||||||
|
df_sorted = recommendations_df.copy()
|
||||||
|
status_order = {'ALERTA': 0, 'ATENTIE': 1, 'OK': 2}
|
||||||
|
df_sorted['_order'] = df_sorted['STATUS'].map(status_order).fillna(3)
|
||||||
|
df_sorted = df_sorted.sort_values('_order').head(top_n_recommendations)
|
||||||
|
df_sorted = df_sorted.drop(columns=['_order'])
|
||||||
|
|
||||||
|
# Coloane de afisat
|
||||||
|
display_cols = ['STATUS', 'CATEGORIE', 'INDICATOR', 'VALOARE', 'RECOMANDARE']
|
||||||
|
display_cols = [c for c in display_cols if c in df_sorted.columns]
|
||||||
|
|
||||||
|
# Header cu background mov
|
||||||
|
rec_header_fill = PatternFill(start_color='8E44AD', end_color='8E44AD', fill_type='solid')
|
||||||
|
for col_idx, col_name in enumerate(display_cols, 1):
|
||||||
|
cell = ws.cell(row=start_row, column=col_idx, value=col_name)
|
||||||
|
cell.font = self.header_font
|
||||||
|
cell.fill = rec_header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||||
|
cell.border = self.border
|
||||||
|
|
||||||
|
# Randuri cu colorare dupa status
|
||||||
|
for row_idx, (_, row) in enumerate(df_sorted.iterrows(), start_row + 1):
|
||||||
|
status = row.get('STATUS', 'OK')
|
||||||
|
for col_idx, col_name in enumerate(display_cols, 1):
|
||||||
|
value = row.get(col_name, '')
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = self.border
|
||||||
|
cell.alignment = Alignment(wrap_text=True)
|
||||||
|
|
||||||
|
# Colorare conditionala
|
||||||
|
if status == 'ALERTA':
|
||||||
|
cell.fill = PatternFill(start_color='FADBD8', end_color='FADBD8', fill_type='solid')
|
||||||
|
elif status == 'ATENTIE':
|
||||||
|
cell.fill = PatternFill(start_color='FCF3CF', end_color='FCF3CF', fill_type='solid')
|
||||||
|
else:
|
||||||
|
cell.fill = PatternFill(start_color='D5F5E3', end_color='D5F5E3', fill_type='solid')
|
||||||
|
|
||||||
|
# Auto-adjust latime coloane
|
||||||
|
for col_idx in range(1, 8):
|
||||||
|
ws.column_dimensions[get_column_letter(col_idx)].width = 22
|
||||||
|
|
||||||
|
ws.freeze_panes = ws.cell(row=5, column=1)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Save the workbook"""
|
||||||
|
self.wb.save(self.output_path)
|
||||||
|
print(f"✓ Excel salvat: {self.output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
class PDFReportGenerator:
|
||||||
|
"""Generate PDF executive summary with charts"""
|
||||||
|
|
||||||
|
def __init__(self, output_path: Path, company_name: str = "Data Intelligence Report"):
|
||||||
|
self.output_path = output_path
|
||||||
|
self.company_name = company_name
|
||||||
|
self.elements = []
|
||||||
|
self.styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
# Custom styles
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
name='CustomTitle',
|
||||||
|
parent=self.styles['Title'],
|
||||||
|
fontSize=24,
|
||||||
|
spaceAfter=30,
|
||||||
|
alignment=TA_CENTER
|
||||||
|
))
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
name='SectionHeader',
|
||||||
|
parent=self.styles['Heading1'],
|
||||||
|
fontSize=14,
|
||||||
|
spaceBefore=20,
|
||||||
|
spaceAfter=10,
|
||||||
|
textColor=colors.HexColor('#366092')
|
||||||
|
))
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
name='AlertHeader',
|
||||||
|
parent=self.styles['Heading2'],
|
||||||
|
fontSize=12,
|
||||||
|
textColor=colors.red,
|
||||||
|
spaceBefore=15,
|
||||||
|
spaceAfter=8
|
||||||
|
))
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
name='SmallText',
|
||||||
|
parent=self.styles['Normal'],
|
||||||
|
fontSize=8,
|
||||||
|
textColor=colors.gray
|
||||||
|
))
|
||||||
|
|
||||||
|
def add_title_page(self, report_date: datetime = None):
|
||||||
|
"""Add title page"""
|
||||||
|
if report_date is None:
|
||||||
|
report_date = datetime.now()
|
||||||
|
|
||||||
|
self.elements.append(Spacer(1, 3*cm))
|
||||||
|
self.elements.append(Paragraph(self.company_name, self.styles['CustomTitle']))
|
||||||
|
self.elements.append(Spacer(1, 1*cm))
|
||||||
|
self.elements.append(Paragraph(
|
||||||
|
f"Raport generat: {report_date.strftime('%d %B %Y, %H:%M')}",
|
||||||
|
self.styles['Normal']
|
||||||
|
))
|
||||||
|
self.elements.append(Paragraph(
|
||||||
|
"Perioada analizată: Ultimele 12 luni",
|
||||||
|
self.styles['Normal']
|
||||||
|
))
|
||||||
|
self.elements.append(PageBreak())
|
||||||
|
|
||||||
|
def add_kpi_section(self, kpi_df: pd.DataFrame):
|
||||||
|
"""Add KPI summary section"""
|
||||||
|
self.elements.append(Paragraph("📊 Sumar Executiv - KPIs", self.styles['SectionHeader']))
|
||||||
|
|
||||||
|
if kpi_df is not None and not kpi_df.empty:
|
||||||
|
data = [['Indicator', 'Valoare', 'UM']]
|
||||||
|
for _, row in kpi_df.iterrows():
|
||||||
|
data.append([
|
||||||
|
str(row.get('INDICATOR', '')),
|
||||||
|
str(row.get('VALOARE', '')),
|
||||||
|
str(row.get('UM', ''))
|
||||||
|
])
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[8*cm, 4*cm, 2*cm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#366092')),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||||
|
('ALIGN', (1, 1), (1, -1), 'RIGHT'),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.gray),
|
||||||
|
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f0f0f0')])
|
||||||
|
]))
|
||||||
|
self.elements.append(table)
|
||||||
|
|
||||||
|
self.elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
def add_alerts_section(self, alerts_data: dict):
|
||||||
|
"""Add critical alerts section"""
|
||||||
|
self.elements.append(Paragraph("🚨 Alerte Critice", self.styles['SectionHeader']))
|
||||||
|
|
||||||
|
# Vânzări sub cost
|
||||||
|
if 'vanzari_sub_cost' in alerts_data and not alerts_data['vanzari_sub_cost'].empty:
|
||||||
|
df = alerts_data['vanzari_sub_cost']
|
||||||
|
count = len(df)
|
||||||
|
total_loss = df['PIERDERE'].sum() if 'PIERDERE' in df.columns else 0
|
||||||
|
|
||||||
|
self.elements.append(Paragraph(
|
||||||
|
f"⛔ VÂNZĂRI SUB COST: {count} tranzacții cu pierdere totală de {abs(total_loss):,.2f} RON",
|
||||||
|
self.styles['AlertHeader']
|
||||||
|
))
|
||||||
|
|
||||||
|
# Show top 5
|
||||||
|
top5 = df.head(5)
|
||||||
|
if not top5.empty:
|
||||||
|
cols_to_show = ['FACTURA', 'CLIENT', 'PRODUS', 'PIERDERE']
|
||||||
|
cols_to_show = [c for c in cols_to_show if c in top5.columns]
|
||||||
|
if cols_to_show:
|
||||||
|
data = [cols_to_show] + top5[cols_to_show].values.tolist()
|
||||||
|
table = Table(data, colWidths=[3*cm, 4*cm, 5*cm, 2*cm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#c0392b')),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.gray),
|
||||||
|
]))
|
||||||
|
self.elements.append(table)
|
||||||
|
|
||||||
|
# Clienți cu marjă mică
|
||||||
|
if 'clienti_marja_mica' in alerts_data and not alerts_data['clienti_marja_mica'].empty:
|
||||||
|
df = alerts_data['clienti_marja_mica']
|
||||||
|
count = len(df)
|
||||||
|
|
||||||
|
self.elements.append(Spacer(1, 0.3*cm))
|
||||||
|
self.elements.append(Paragraph(
|
||||||
|
f"⚠️ CLIENȚI CU MARJĂ MICĂ (<15%): {count} clienți necesită renegociere",
|
||||||
|
self.styles['AlertHeader']
|
||||||
|
))
|
||||||
|
|
||||||
|
top5 = df.head(5)
|
||||||
|
if not top5.empty:
|
||||||
|
cols_to_show = ['CLIENT', 'VANZARI_FARA_TVA', 'PROCENT_MARJA']
|
||||||
|
cols_to_show = [c for c in cols_to_show if c in top5.columns]
|
||||||
|
if cols_to_show:
|
||||||
|
data = [cols_to_show]
|
||||||
|
for _, row in top5.iterrows():
|
||||||
|
data.append([
|
||||||
|
str(row.get('CLIENT', ''))[:30],
|
||||||
|
f"{row.get('VANZARI_FARA_TVA', 0):,.0f}",
|
||||||
|
f"{row.get('PROCENT_MARJA', 0):.1f}%"
|
||||||
|
])
|
||||||
|
table = Table(data, colWidths=[6*cm, 3*cm, 2*cm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e67e22')),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.gray),
|
||||||
|
]))
|
||||||
|
self.elements.append(table)
|
||||||
|
|
||||||
|
self.elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
def add_chart_image(self, fig, title: str):
|
||||||
|
"""Add a matplotlib figure as image"""
|
||||||
|
self.elements.append(Paragraph(title, self.styles['SectionHeader']))
|
||||||
|
|
||||||
|
# Save figure to buffer
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
# Add to PDF
|
||||||
|
img = Image(buf, width=16*cm, height=10*cm)
|
||||||
|
self.elements.append(img)
|
||||||
|
self.elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
def add_table_section(self, title: str, df: pd.DataFrame, columns: list = None, max_rows: int = 15):
|
||||||
|
"""Add a data table section"""
|
||||||
|
self.elements.append(Paragraph(title, self.styles['SectionHeader']))
|
||||||
|
|
||||||
|
if df is None or df.empty:
|
||||||
|
self.elements.append(Paragraph("Nu există date.", self.styles['Normal']))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Select columns
|
||||||
|
if columns:
|
||||||
|
cols = [c for c in columns if c in df.columns]
|
||||||
|
else:
|
||||||
|
cols = list(df.columns)[:6] # Max 6 columns for PDF
|
||||||
|
|
||||||
|
if not cols:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prepare data
|
||||||
|
data = [cols]
|
||||||
|
for _, row in df.head(max_rows).iterrows():
|
||||||
|
row_data = []
|
||||||
|
for col in cols:
|
||||||
|
val = row.get(col, '')
|
||||||
|
if isinstance(val, float):
|
||||||
|
row_data.append(f"{val:,.2f}")
|
||||||
|
elif isinstance(val, int):
|
||||||
|
row_data.append(f"{val:,}")
|
||||||
|
else:
|
||||||
|
row_data.append(str(val)[:25]) # Truncate long strings
|
||||||
|
data.append(row_data)
|
||||||
|
|
||||||
|
# Calculate column widths
|
||||||
|
n_cols = len(cols)
|
||||||
|
col_width = 16*cm / n_cols
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[col_width] * n_cols)
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#366092')),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 7),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.gray),
|
||||||
|
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f5f5f5')])
|
||||||
|
]))
|
||||||
|
|
||||||
|
self.elements.append(table)
|
||||||
|
|
||||||
|
if len(df) > max_rows:
|
||||||
|
self.elements.append(Paragraph(
|
||||||
|
f"... și încă {len(df) - max_rows} înregistrări (vezi Excel pentru lista completă)",
|
||||||
|
self.styles['SmallText']
|
||||||
|
))
|
||||||
|
|
||||||
|
self.elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
def add_page_break(self):
|
||||||
|
"""Add page break"""
|
||||||
|
self.elements.append(PageBreak())
|
||||||
|
|
||||||
|
def add_recommendations_section(self, recommendations_df: pd.DataFrame):
|
||||||
|
"""Add recommendations section with status colors"""
|
||||||
|
self.elements.append(Paragraph("Recomandari Cheie", self.styles['SectionHeader']))
|
||||||
|
|
||||||
|
if recommendations_df is None or recommendations_df.empty:
|
||||||
|
self.elements.append(Paragraph("Nu au fost generate recomandari.", self.styles['Normal']))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show top 7 most important recommendations (ALERTA first, then ATENTIE)
|
||||||
|
df_sorted = recommendations_df.copy()
|
||||||
|
status_order = {'ALERTA': 0, 'ATENTIE': 1, 'OK': 2}
|
||||||
|
df_sorted['_order'] = df_sorted['STATUS'].map(status_order)
|
||||||
|
df_sorted = df_sorted.sort_values('_order').head(7)
|
||||||
|
|
||||||
|
for _, row in df_sorted.iterrows():
|
||||||
|
status = row.get('STATUS', 'OK')
|
||||||
|
indicator = row.get('INDICATOR', '')
|
||||||
|
valoare = row.get('VALOARE', '')
|
||||||
|
explicatie = row.get('EXPLICATIE', '')
|
||||||
|
recomandare = row.get('RECOMANDARE', '')
|
||||||
|
|
||||||
|
# Color based on status
|
||||||
|
if status == 'ALERTA':
|
||||||
|
status_color = colors.HexColor('#c0392b')
|
||||||
|
bg_color = colors.HexColor('#fadbd8')
|
||||||
|
elif status == 'ATENTIE':
|
||||||
|
status_color = colors.HexColor('#d68910')
|
||||||
|
bg_color = colors.HexColor('#fef9e7')
|
||||||
|
else:
|
||||||
|
status_color = colors.HexColor('#27ae60')
|
||||||
|
bg_color = colors.HexColor('#d5f5e3')
|
||||||
|
|
||||||
|
# Create a small table for each recommendation
|
||||||
|
data = [
|
||||||
|
[f"[{status}] {indicator}: {valoare}"],
|
||||||
|
[explicatie],
|
||||||
|
[f"Actiune: {recomandare}"]
|
||||||
|
]
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[16*cm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), status_color),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('BACKGROUND', (0, 1), (-1, -1), bg_color),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 8),
|
||||||
|
]))
|
||||||
|
self.elements.append(table)
|
||||||
|
self.elements.append(Spacer(1, 0.2*cm))
|
||||||
|
|
||||||
|
self.elements.append(Spacer(1, 0.3*cm))
|
||||||
|
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Generate and save PDF"""
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
str(self.output_path),
|
||||||
|
pagesize=A4,
|
||||||
|
rightMargin=2*cm,
|
||||||
|
leftMargin=2*cm,
|
||||||
|
topMargin=2*cm,
|
||||||
|
bottomMargin=2*cm
|
||||||
|
)
|
||||||
|
doc.build(self.elements)
|
||||||
|
print(f"✓ PDF salvat: {self.output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_monthly_chart(df: pd.DataFrame) -> plt.Figure:
|
||||||
|
"""Create monthly sales and margin chart"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 6))
|
||||||
|
ax.text(0.5, 0.5, 'Nu există date', ha='center', va='center')
|
||||||
|
return fig
|
||||||
|
|
||||||
|
fig, ax1 = plt.subplots(figsize=(12, 6))
|
||||||
|
|
||||||
|
x = range(len(df))
|
||||||
|
|
||||||
|
# Bar chart for sales
|
||||||
|
bars = ax1.bar(x, df['VANZARI_FARA_TVA'], color='#366092', alpha=0.7, label='Vânzări')
|
||||||
|
ax1.set_xlabel('Luna')
|
||||||
|
ax1.set_ylabel('Vânzări (RON)', color='#366092')
|
||||||
|
ax1.tick_params(axis='y', labelcolor='#366092')
|
||||||
|
ax1.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k'))
|
||||||
|
|
||||||
|
# Line chart for margin
|
||||||
|
ax2 = ax1.twinx()
|
||||||
|
line = ax2.plot(x, df['MARJA_BRUTA'], color='#e74c3c', linewidth=2, marker='o', label='Marja')
|
||||||
|
ax2.set_ylabel('Marja (RON)', color='#e74c3c')
|
||||||
|
ax2.tick_params(axis='y', labelcolor='#e74c3c')
|
||||||
|
ax2.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k'))
|
||||||
|
|
||||||
|
# X-axis labels
|
||||||
|
ax1.set_xticks(x)
|
||||||
|
ax1.set_xticklabels(df['LUNA'], rotation=45, ha='right')
|
||||||
|
|
||||||
|
# Legend
|
||||||
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
|
||||||
|
|
||||||
|
plt.title('Evoluția Vânzărilor și Marjei Lunare')
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def create_client_concentration_chart(df: pd.DataFrame) -> plt.Figure:
|
||||||
|
"""Create client concentration pie chart"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 8))
|
||||||
|
ax.text(0.5, 0.5, 'Nu există date', ha='center', va='center')
|
||||||
|
return fig
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
|
||||||
|
|
||||||
|
# Pie chart - Top 10 vs Others
|
||||||
|
top10 = df.head(10)
|
||||||
|
others_pct = 100 - top10['PROCENT_CUMULAT'].iloc[-1] if len(top10) >= 10 else 0
|
||||||
|
|
||||||
|
sizes = list(top10['PROCENT_DIN_TOTAL'])
|
||||||
|
if others_pct > 0:
|
||||||
|
sizes.append(others_pct)
|
||||||
|
labels = list(top10['CLIENT'].str[:20]) # Truncate names
|
||||||
|
if others_pct > 0:
|
||||||
|
labels.append('Alții')
|
||||||
|
|
||||||
|
colors_list = plt.cm.Set3(range(len(sizes)))
|
||||||
|
|
||||||
|
ax1.pie(sizes, labels=None, colors=colors_list, autopct='%1.1f%%', startangle=90)
|
||||||
|
ax1.set_title('Concentrare Top 10 Clienți')
|
||||||
|
ax1.legend(labels, loc='center left', bbox_to_anchor=(1, 0.5), fontsize=8)
|
||||||
|
|
||||||
|
# Bar chart - Pareto
|
||||||
|
ax2.bar(range(len(top10)), top10['VANZARI'], color='#366092', alpha=0.7)
|
||||||
|
ax2_twin = ax2.twinx()
|
||||||
|
ax2_twin.plot(range(len(top10)), top10['PROCENT_CUMULAT'], 'r-o', linewidth=2)
|
||||||
|
ax2_twin.axhline(y=80, color='green', linestyle='--', alpha=0.5, label='80%')
|
||||||
|
|
||||||
|
ax2.set_xticks(range(len(top10)))
|
||||||
|
ax2.set_xticklabels([c[:15] for c in top10['CLIENT']], rotation=45, ha='right', fontsize=8)
|
||||||
|
ax2.set_ylabel('Vânzări (RON)')
|
||||||
|
ax2_twin.set_ylabel('% Cumulat')
|
||||||
|
ax2.set_title('Analiză Pareto Clienți')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def create_production_chart(df: pd.DataFrame) -> plt.Figure:
|
||||||
|
"""Create production vs resale comparison chart"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
ax.text(0.5, 0.5, 'Nu există date', ha='center', va='center')
|
||||||
|
return fig
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
|
||||||
|
|
||||||
|
# Bar chart - Sales by type
|
||||||
|
x = range(len(df))
|
||||||
|
ax1.bar(x, df['VANZARI_FARA_TVA'], color=['#366092', '#e74c3c', '#2ecc71'][:len(df)])
|
||||||
|
ax1.set_xticks(x)
|
||||||
|
ax1.set_xticklabels(df['TIP_PRODUS'], rotation=15)
|
||||||
|
ax1.set_ylabel('Vânzări (RON)')
|
||||||
|
ax1.set_title('Vânzări per Tip Produs')
|
||||||
|
ax1.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x/1000:,.0f}k'))
|
||||||
|
|
||||||
|
# Bar chart - Margin %
|
||||||
|
colors = ['#2ecc71' if m > 20 else '#e67e22' if m > 15 else '#e74c3c' for m in df['PROCENT_MARJA']]
|
||||||
|
ax2.bar(x, df['PROCENT_MARJA'], color=colors)
|
||||||
|
ax2.set_xticks(x)
|
||||||
|
ax2.set_xticklabels(df['TIP_PRODUS'], rotation=15)
|
||||||
|
ax2.set_ylabel('Marjă (%)')
|
||||||
|
ax2.set_title('Marjă per Tip Produs')
|
||||||
|
ax2.axhline(y=15, color='red', linestyle='--', alpha=0.5, label='Prag minim 15%')
|
||||||
|
ax2.legend()
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def create_cash_cycle_chart(df: pd.DataFrame) -> plt.Figure:
|
||||||
|
"""Create cash conversion cycle visualization"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
ax.text(0.5, 0.5, 'Nu exista date', ha='center', va='center')
|
||||||
|
return fig
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
|
||||||
|
|
||||||
|
# Extract values
|
||||||
|
indicators = df['INDICATOR'].tolist() if 'INDICATOR' in df.columns else []
|
||||||
|
zile = df['ZILE'].tolist() if 'ZILE' in df.columns else []
|
||||||
|
|
||||||
|
if not indicators or not zile:
|
||||||
|
ax1.text(0.5, 0.5, 'Date incomplete', ha='center', va='center')
|
||||||
|
ax2.text(0.5, 0.5, 'Date incomplete', ha='center', va='center')
|
||||||
|
return fig
|
||||||
|
|
||||||
|
# Colors for each component
|
||||||
|
colors_map = {
|
||||||
|
'DIO': '#3498db', # Blue for inventory
|
||||||
|
'DSO': '#e74c3c', # Red for receivables
|
||||||
|
'DPO': '#2ecc71', # Green for payables
|
||||||
|
'CCC': '#9b59b6' # Purple for total cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
bar_colors = []
|
||||||
|
for ind in indicators:
|
||||||
|
for key, color in colors_map.items():
|
||||||
|
if key in ind.upper():
|
||||||
|
bar_colors.append(color)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
bar_colors.append('#95a5a6')
|
||||||
|
|
||||||
|
# Bar chart
|
||||||
|
x = range(len(indicators))
|
||||||
|
bars = ax1.bar(x, zile, color=bar_colors, alpha=0.8)
|
||||||
|
ax1.set_xticks(x)
|
||||||
|
ax1.set_xticklabels([ind[:20] for ind in indicators], rotation=45, ha='right', fontsize=9)
|
||||||
|
ax1.set_ylabel('Zile')
|
||||||
|
ax1.set_title('Ciclu Conversie Cash - Componente')
|
||||||
|
|
||||||
|
# Add value labels on bars
|
||||||
|
for bar, val in zip(bars, zile):
|
||||||
|
height = bar.get_height()
|
||||||
|
ax1.text(bar.get_x() + bar.get_width()/2., height,
|
||||||
|
f'{int(val)}',
|
||||||
|
ha='center', va='bottom', fontsize=10, fontweight='bold')
|
||||||
|
|
||||||
|
# Waterfall-style visualization
|
||||||
|
# DIO + DSO - DPO = CCC
|
||||||
|
dio = next((z for i, z in zip(indicators, zile) if 'DIO' in i.upper()), 0)
|
||||||
|
dso = next((z for i, z in zip(indicators, zile) if 'DSO' in i.upper() and 'DIO' not in i.upper()), 0)
|
||||||
|
dpo = next((z for i, z in zip(indicators, zile) if 'DPO' in i.upper()), 0)
|
||||||
|
ccc = dio + dso - dpo
|
||||||
|
|
||||||
|
waterfall_labels = ['DIO\n(Zile Stoc)', 'DSO\n(Zile Incasare)', 'DPO\n(Zile Plata)', 'CCC\n(Ciclu Total)']
|
||||||
|
waterfall_values = [dio, dso, -dpo, ccc]
|
||||||
|
waterfall_colors = ['#3498db', '#e74c3c', '#2ecc71', '#9b59b6']
|
||||||
|
|
||||||
|
# Calculate positions for waterfall
|
||||||
|
cumulative = [0]
|
||||||
|
for i, v in enumerate(waterfall_values[:-1]):
|
||||||
|
cumulative.append(cumulative[-1] + v)
|
||||||
|
|
||||||
|
ax2.bar([0, 1, 2], [dio, dso, dpo], color=['#3498db', '#e74c3c', '#2ecc71'], alpha=0.8)
|
||||||
|
ax2.axhline(y=ccc, color='#9b59b6', linewidth=3, linestyle='--', label=f'CCC = {int(ccc)} zile')
|
||||||
|
ax2.set_xticks([0, 1, 2])
|
||||||
|
ax2.set_xticklabels(['DIO\n(+Stoc)', 'DSO\n(+Incasare)', 'DPO\n(-Plata)'], fontsize=9)
|
||||||
|
ax2.set_ylabel('Zile')
|
||||||
|
ax2.set_title('Formula: DIO + DSO - DPO = CCC')
|
||||||
|
ax2.legend(loc='upper right')
|
||||||
|
|
||||||
|
# Add annotation explaining the result
|
||||||
|
if ccc > 60:
|
||||||
|
verdict = "Ciclu lung - capital blocat mult timp"
|
||||||
|
verdict_color = '#c0392b'
|
||||||
|
elif ccc > 30:
|
||||||
|
verdict = "Ciclu moderat - poate fi optimizat"
|
||||||
|
verdict_color = '#d68910'
|
||||||
|
else:
|
||||||
|
verdict = "Ciclu eficient - capital rotit rapid"
|
||||||
|
verdict_color = '#27ae60'
|
||||||
|
|
||||||
|
ax2.text(0.5, -0.15, verdict, transform=ax2.transAxes,
|
||||||
|
ha='center', fontsize=10, color=verdict_color, fontweight='bold')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
return fig
|
||||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Data Intelligence Report - Dependencies
|
||||||
|
# Install with: pip install -r requirements.txt --break-system-packages
|
||||||
|
|
||||||
|
# Oracle Database connection
|
||||||
|
oracledb>=1.4.0
|
||||||
|
|
||||||
|
# Data processing
|
||||||
|
pandas>=2.0.0
|
||||||
|
|
||||||
|
# Excel generation
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
|
||||||
|
# PDF generation
|
||||||
|
reportlab>=4.0.0
|
||||||
|
|
||||||
|
# Charts and visualization
|
||||||
|
matplotlib>=3.7.0
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
python-dotenv>=1.0.0
|
||||||
6
run.bat
Normal file
6
run.bat
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@echo off
|
||||||
|
REM Run Data Intelligence Report Generator (Windows)
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
call .venv\Scripts\activate.bat
|
||||||
|
python main.py %*
|
||||||
Reference in New Issue
Block a user