generowanie raportów z wyborem preagregatów

This commit is contained in:
zzdrojewskipaw
2025-09-29 00:19:15 +02:00
parent a1ddb05402
commit fb9da812de
2 changed files with 696 additions and 160 deletions

View File

@@ -2,60 +2,117 @@
# -*- coding: utf-8 -*-
"""
analysisAI.py — pobiera dane z MySQL, liczy preagregaty, renderuje HTML i dodaje analizę AI.
analysisAI.py — pobiera dane z MySQL, liczy wyłącznie WSKAZANE preagregaty,
renderuje HTML i (opcjonalnie) dodaje analizę AI — tylko jeśli ją zaznaczysz.
ZMIENNE ŚRODOWISKOWE (wszystkie mają domyślne wartości):
OPENAI_API_KEY - klucz do OpenAI (gdy pusty -> fallback bez AI)
OPENAI_MODEL - np. gpt-4.1 (domyślnie), alternatywnie gpt-4.1-mini
MYSQL_HOST - host MySQL (domyślnie: twinpol-mysql56 lub localhost)
MYSQL_USER - użytkownik MySQL (domyślnie: root)
MYSQL_PASSWORD - hasło MySQL (domyślnie: rootpassword)
MYSQL_DATABASE - nazwa bazy (domyślnie: preDb_0dcc87940d3655fa574b253df04ca1c3)
MYSQL_PORT - port MySQL (domyślnie: 3306)
PERIOD_FROM - data od (YYYY-MM-DD); gdy brak -> poprzedni pełny miesiąc
PERIOD_TO - data do (YYYY-MM-DD, exclusive); gdy brak -> 1. dzień bieżącego miesiąca
INVOICE_TYPE - typ dokumentu (domyślnie: normal)
Parametry CLI (z formularza PHP):
--date-from YYYY-MM-DD
--date-to YYYY-MM-DD (zamieniane wewnętrznie na +1 dzień, bo SQL ma warunek '< date_to')
--metric NAZWA (można podać wiele razy: --metric a --metric b ...)
--metrics CSV (opcjonalnie alternatywnie: --metrics a,b,c)
--ai true|false (czy uruchomić analizę AI — tylko gdy są preagregaty z danymi)
Preagregaty:
- kpis (aliasy: basic, basic_totals) — podstawowe KPI: sprzedaż, ilość, dokumenty, ASP
- daily_sales, product_summary, customer_summary, product_daily,
top10_products_by_sales, top10_customers_by_sales (z preaggregates.py)
"""
import os, sys, json, math, time, warnings
from datetime import date, timedelta
import os, sys, json, math, time, warnings, argparse, traceback, html
from datetime import date, timedelta, datetime
API_KEY = "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"
#5 pro
#API_KEY = "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"
# ──(1) Wycisz ostrzeżenia urllib3 (LibreSSL / stary OpenSSL) ───────────────────
# (1) Wycisza ostrzeżenia urllib3 (LibreSSL / stary OpenSSL)
try:
from urllib3.exceptions import NotOpenSSLWarning
warnings.filterwarnings("ignore", category=NotOpenSSLWarning)
except Exception:
pass
# ──(2) Importy zewnętrzne ──────────────────────────────────────────────────────
# (2) Importy zewnętrzne
import requests
import mysql.connector
import pandas as pd
# Twoje preagregaty (muszą być w tym samym katalogu / PYTHONPATH)
from preaggregates import compute_preaggregates, serialize_for_ai
LOOKER_URL = "https://lookerstudio.google.com/u/0/reporting/107d4ccc-e7eb-4c38-8dce-00700b44f60e/page/ba1YF"
# ──(3) Konfiguracja klucza AI ──────────────────────────────────────────────────
# Wpisz tutaj klucz jeśli chcesz mieć go „na sztywno”, inaczej zostaw pusty:
API_KEY_HARDCODE = API_KEY # np. "sk-xxxx..." (NIEZALECANE w produkcji)
# ========== KONFIGURACJA KLUCZA AI ==========
API_KEY = "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"
API_KEY_HARDCODE = API_KEY
# === Import preagregatów ===
from preaggregates import serialize_for_ai
import preaggregates as pre # pre.AGGREGATORS, pre.to_df
# ========== UTILKI ==========
def html_fatal(msg, title="Błąd"):
sys.stdout.write(
'<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;'
'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;'
'border-radius:12px;background:#fff5f5;color:#991b1b;">'
f'<h3 style="margin:0 0 8px;font-size:18px;">{html.escape(title)}</h3>'
f'<pre style="white-space:pre-wrap;margin:0;">{html.escape(msg)}</pre>'
'</div>'
)
sys.exit(1)
def connect_html_or_die(cfg, label="MySQL"):
try:
return mysql.connector.connect(**cfg)
except mysql.connector.Error as e:
host = cfg.get("host"); port = cfg.get("port"); user = cfg.get("user")
base = (f"[{label}] Błąd połączenia ({host}:{port} jako '{user}').\n"
f"errno={getattr(e,'errno',None)} sqlstate={getattr(e,'sqlstate',None)}\n"
f"msg={getattr(e,'msg',str(e))}")
if os.environ.get("DEBUG"):
base += "\n\n" + traceback.format_exc()
html_fatal(base, title="Błąd połączenia MySQL")
# ──(4) Utils ───────────────────────────────────────────────────────────────────
def getenv(k, d=None):
return os.environ.get(k, d)
def last_full_month_bounds():
"""Zwraca (from_iso, to_iso) dla poprzedniego pełnego miesiąca."""
today_first = date.today().replace(day=1)
to_dt = today_first
prev_last = today_first - timedelta(days=1)
from_dt = prev_last.replace(day=1)
return from_dt.isoformat(), to_dt.isoformat()
def add_one_day(iso_date):
try:
return (datetime.strptime(iso_date, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
except Exception:
return iso_date # w razie czego oddaj wejście
def safe_num(v, ndigits=None):
try:
f = float(v)
if not math.isfinite(f):
return None
return round(f, ndigits) if ndigits is not None else f
except Exception:
return None
def safe_date(v):
if v is None:
return None
try:
if hasattr(v, "date"):
return str(v.date())
s = str(v)
if len(s) >= 10 and s[4] == '-' and s[7] == '-':
return s[:10]
return s
except Exception:
return None
def fmt_money(v):
try:
return "{:,.2f}".format(float(v)).replace(",", " ").replace(".", ",")
except Exception:
return str(v)
def compact_table(table, limit=30):
"""Przytnij listę rekordów (list[dict]) i znormalizuj liczby (NaN/Inf -> None)."""
out = []
if not table:
return out
@@ -71,21 +128,8 @@ def compact_table(table, limit=30):
out.append(new)
return out
def build_ai_payload(serialized, period_label):
"""Kompaktowy JSON do AI (rozmiar przycięty, ale zawiera wszystkie główne tabele)."""
return {
"kpis_hint": {"period_label": period_label},
"daily_sales": compact_table(serialized.get("daily_sales"), 60),
"product_summary": compact_table(serialized.get("product_summary"), 100),
"customer_summary": compact_table(serialized.get("customer_summary"), 100),
"top10_products_by_sales": compact_table(serialized.get("top10_products_by_sales"), 10),
"top10_customers_by_sales": compact_table(serialized.get("top10_customers_by_sales"), 10),
"product_daily_sample": compact_table(serialized.get("product_daily"), 100),
}
def call_openai_chat(api_key, model, system_prompt, user_payload_json,
temperature=0.3, connect_timeout=10, read_timeout=90, max_retries=3):
"""Wywołanie Chat Completions (retry + backoff). Zwraca HTML (sekcję) od AI."""
url = "https://api.openai.com/v1/chat/completions"
headers = {"Authorization": "Bearer " + api_key, "Content-Type": "application/json"}
body = {
@@ -95,7 +139,6 @@ def call_openai_chat(api_key, model, system_prompt, user_payload_json,
{"role": "user", "content": "Dane (JSON):\n\n" + user_payload_json},
],
"temperature": temperature,
# "max_tokens": 1200, # opcjonalnie ogranicz długość odpowiedzi
}
last_err = None
for attempt in range(1, int(max_retries) + 1):
@@ -110,14 +153,7 @@ def call_openai_chat(api_key, model, system_prompt, user_payload_json,
time.sleep(min(2 ** attempt, 10))
raise RuntimeError("OpenAI request failed: {}".format(last_err))
def fmt_money(v):
try:
return "{:,.2f}".format(float(v)).replace(",", " ").replace(".", ",")
except Exception:
return str(v)
def html_table(records, title=None, max_rows=20):
"""Proste generowanie tabeli HTML z listy dict-ów (lekki CSS inline w <style> poniżej)."""
if not records:
return '<div class="empty">Brak danych</div>'
cols = list(records[0].keys())
@@ -144,7 +180,6 @@ def html_table(records, title=None, max_rows=20):
)
def render_report_html(period_label, kpis, parts, ai_section, model_alias):
"""Składa finalny jeden <div> z lekkim CSS inline."""
css = (
"font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;"
"max-width:1200px;margin:24px auto;padding:16px 20px;border:1px solid #e5e7eb;"
@@ -165,10 +200,19 @@ def render_report_html(period_label, kpis, parts, ai_section, model_alias):
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:12px 0 20px;">
{kpi_html}
</div>
{sections_html}
{sections_html if sections_html.strip() else '<div class="empty">Nie wybrano żadnych preagregatów — brak sekcji do wyświetlenia.</div>'}
<div style="margin-top:20px;border-top:1px solid #e5e7eb;padding-top:16px;">
<h3 style="margin:0 0 8px;font-size:18px;">Analiza i rekomendacje (AI{(' · ' + model_alias) if model_alias else ''})</h3>
{ai_section if ai_section else '<div style="color:#6b7280">Brak odpowiedzi AI</div>'}
<h3 style="margin:0 0 8px;font-size:18px;">Analiza i rekomendacje{(' (AI · ' + model_alias + ')') if model_alias else ''}</h3>
{ai_section if ai_section else '<div style="color:#6b7280">Analiza AI wyłączona lub brak danych.</div>'}
</div>
<!-- STOPKA z linkiem do Looker Studio -->
<div style="margin-top:20px;border-top:1px dashed #e5e7eb;padding-top:12px;display:flex;justify-content:flex-end;">
<a href="{LOOKER_URL}" target="_blank" rel="noopener"
style="text-decoration:none;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;
background:#f9fafb;color:#111827;font-weight:600;">
→ Otwórz pełny raport w Looker Studio
</a>
</div>
</div>
<style>
@@ -186,9 +230,280 @@ def render_report_html(period_label, kpis, parts, ai_section, model_alias):
</style>
"""
# ──(5) Główna logika ───────────────────────────────────────────────────────────
# ========== UPSerTY DO REPORTING (jak u Ciebie) ==========
def _ensure_rank_and_share(items, key_sales="sales"):
if not items: return
total_sales = sum((x.get(key_sales) or 0) for x in items)
sorted_items = sorted(
items,
key=lambda x: ((x.get(key_sales) or 0), str(x.get("product_code") or x.get("customer_name") or "")),
reverse=True
)
rank_map, rank = {}, 1
for x in sorted_items:
key = x.get("product_code") or x.get("customer_name") or ""
if key not in rank_map:
rank_map[key] = rank
rank += 1
for x in items:
key = x.get("product_code") or x.get("customer_name") or ""
if not x.get("rank_in_period"):
x["rank_in_period"] = rank_map.get(key, 0)
if "mix_share_sales" not in x:
x["mix_share_sales"] = ((x.get(key_sales) or 0) / total_sales) if total_sales else 0.0
def upsert_daily_sales(cur, daily):
if not daily: return 0
sql = """
INSERT INTO reporting_daily_sales
(period_date, qty, sales, docs, asp, sales_rolling7, sales_dod_pct)
VALUES (%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
qty=VALUES(qty), sales=VALUES(sales), docs=VALUES(docs),
asp=VALUES(asp), sales_rolling7=VALUES(sales_rolling7), sales_dod_pct=VALUES(sales_dod_pct),
generated_at=CURRENT_TIMESTAMP
"""
rows = []
for r in daily:
period_date = safe_date(r.get("register_date") or r.get("period_date") or r.get("date"))
rows.append((
period_date,
safe_num(r.get("qty")),
safe_num(r.get("sales")),
safe_num(r.get("docs")),
safe_num(r.get("asp"), 6),
safe_num(r.get("sales_rolling7"), 6),
safe_num(r.get("sales_pct_change_dod") or r.get("sales_dod_pct"), 6),
))
cur.executemany(sql, rows)
return len(rows)
def upsert_product_summary(cur, prod, period_from, period_to):
if not prod: return 0
_ensure_rank_and_share(prod, key_sales="sales")
sql = """
INSERT INTO reporting_product_summary
(period_start, period_end, product_code, product_name, qty, sales, docs,
asp_weighted, mix_share_sales, rank_in_period)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
qty=VALUES(qty), sales=VALUES(sales), docs=VALUES(docs),
asp_weighted=VALUES(asp_weighted), mix_share_sales=VALUES(mix_share_sales),
rank_in_period=VALUES(rank_in_period), generated_at=CURRENT_TIMESTAMP
"""
rows = []
for r in prod:
rows.append((
period_from, period_to,
r.get("product_code"), r.get("product_name"),
safe_num(r.get("qty")),
safe_num(r.get("sales")),
safe_num(r.get("docs")),
safe_num(r.get("asp_weighted"), 6),
safe_num(r.get("mix_share_sales"), 6),
int(r.get("rank_in_period") or 0),
))
cur.executemany(sql, rows)
return len(rows)
def upsert_customer_summary(cur, cust, period_from, period_to):
if not cust: return 0
_ensure_rank_and_share(cust, key_sales="sales")
sql = """
INSERT INTO reporting_customer_summary
(period_start, period_end, customer_name, qty, sales, docs,
asp_weighted, mix_share_sales, rank_in_period)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
qty=VALUES(qty), sales=VALUES(sales), docs=VALUES(docs),
asp_weighted=VALUES(asp_weighted), mix_share_sales=VALUES(mix_share_sales),
rank_in_period=VALUES(rank_in_period), generated_at=CURRENT_TIMESTAMP
"""
rows = []
for r in cust:
rows.append((
period_from, period_to,
r.get("customer_name"),
safe_num(r.get("qty")),
safe_num(r.get("sales")),
safe_num(r.get("docs")),
safe_num(r.get("asp_weighted"), 6),
safe_num(r.get("mix_share_sales"), 6),
int(r.get("rank_in_period") or 0),
))
cur.executemany(sql, rows)
return len(rows)
def upsert_product_daily(cur, prod_daily):
if not prod_daily: return 0
sql = """
INSERT INTO reporting_product_daily
(period_date, product_code, product_name, qty, sales, asp)
VALUES (%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
qty=VALUES(qty), sales=VALUES(sales), asp=VALUES(asp),
generated_at=CURRENT_TIMESTAMP
"""
rows = []
for r in prod_daily:
period_date = safe_date(r.get("register_date") or r.get("period_date") or r.get("date"))
qty = safe_num(r.get("qty"))
sales = safe_num(r.get("sales"))
asp = safe_num((sales / qty) if (qty and sales is not None and qty != 0) else r.get("asp"), 6)
rows.append((
period_date,
r.get("product_code"),
r.get("product_name"),
qty, sales, asp
))
cur.executemany(sql, rows)
return len(rows)
# ========== ARGPARSE & LOGIKA WYBORU ==========
def parse_cli_args():
p = argparse.ArgumentParser()
p.add_argument('--date-from', dest='date_from', required=False, help='YYYY-MM-DD')
p.add_argument('--date-to', dest='date_to', required=False, help='YYYY-MM-DD (inclusive, we add +1 day internally)')
# akceptuj obie formy: wielokrotne --metric oraz (opcjonalnie) --metrics CSV
p.add_argument('--metric', dest='metric', action='append', default=[], help='Nazwa preagregatu; można podać wiele razy')
p.add_argument('--metrics', dest='metrics', action='append', default=[], help='CSV: a,b,c (można podać wiele razy)')
p.add_argument('--ai', dest='ai', choices=['true','false'], default='false')
return p.parse_args()
def collect_metric_names(args):
names = []
# z --metric (powtarzalne)
if args.metric:
names.extend([s.strip() for s in args.metric if s and s.strip()])
# z --metrics (może być kilka wystąpień; każde może być CSV)
for entry in (args.metrics or []):
if not entry:
continue
for part in str(entry).replace(';', ',').replace(' ', ',').split(','):
part = part.strip()
if part:
names.append(part)
# aliasy dla kpis
alias_map = {'basic': 'kpis', 'basic_totals': 'kpis'}
names = [alias_map.get(n, n) for n in names]
# deduplikacja z zachowaniem kolejności
seen = set()
uniq = []
for n in names:
if n not in seen:
seen.add(n)
uniq.append(n)
return uniq
def compute_selected_preaggs(rows, names):
"""
Liczy TYLKO wskazane preagregaty. ZAWSZE zwraca DataFrame'y (nigdy listy).
Obsługuje pseudo-agregat 'kpis' (podstawowe KPI).
"""
results = {}
if not names:
return results
df = pre.to_df(rows)
# kpis — pseudoagregat
def compute_kpis_df(dfx):
if dfx is None or dfx.empty:
return pd.DataFrame([{
"total_sales": 0.0,
"total_qty": 0.0,
"total_docs": 0,
"asp": None,
}])
total_sales = float(dfx["total_netto"].sum())
total_qty = float(dfx["quantity"].sum())
total_docs = int(dfx["document_no"].nunique())
asp = (total_sales / total_qty) if total_qty else None
return pd.DataFrame([{
"total_sales": total_sales,
"total_qty": total_qty,
"total_docs": total_docs,
"asp": asp,
}])
for name in names:
if name == 'kpis':
results[name] = compute_kpis_df(df)
continue
fn = pre.AGGREGATORS.get(name)
if not fn:
results[name] = pd.DataFrame() # nieznany agregat -> pusty
continue
try:
out = fn(df)
if out is None:
results[name] = pd.DataFrame()
elif hasattr(out, "copy"):
results[name] = out.copy()
else:
results[name] = pd.DataFrame(out)
except Exception:
# np. top10_* na pustych danych -> zwróć pusty wynik
results[name] = pd.DataFrame()
return results
def sanitize_serialized(serialized_dict):
"""
Jeśli jakikolwiek agregat zwrócił błąd (np. _error), zamieniamy na pustą listę.
"""
clean = {}
for k, records in (serialized_dict or {}).items():
if not records:
clean[k] = []
continue
if isinstance(records, list) and isinstance(records[0], dict) and ('_error' in records[0]):
clean[k] = []
else:
clean[k] = records
return clean
def has_any_rows(serialized_dict):
for records in (serialized_dict or {}).values():
if records: # lista niepusta
return True
return False
# ========== MAIN ==========
def main():
# Konfiguracja DB
# --- CLI ---
args = parse_cli_args()
with_ai = (args.ai == 'true')
metric_names = collect_metric_names(args)
# --- Daty: preferuj CLI; 'date_to' inkluzywne (dodajemy +1 dzień dla SQL '<') ---
if args.date_from and args.date_to:
period_from, period_to = args.date_from, add_one_day(args.date_to)
shown_label = "{} .. {}".format(args.date_from, args.date_to)
else:
env_from, env_to = getenv("PERIOD_FROM"), getenv("PERIOD_TO")
if env_from and env_to:
period_from, period_to = env_from, env_to
# label dla czytelności: to-1d
try:
to_label = (datetime.strptime(period_to, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
except Exception:
to_label = period_to
shown_label = "{} .. {}".format(period_from, to_label)
else:
period_from, period_to = last_full_month_bounds()
# label: poprzedni pełny miesiąc
try:
to_label = (datetime.strptime(period_to, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
except Exception:
to_label = period_to
shown_label = "{} .. {}".format(period_from, to_label)
# --- DB ---
cfg = {
"host": getenv("MYSQL_HOST", "twinpol-mysql56"),
"user": getenv("MYSQL_USER", "root"),
@@ -196,36 +511,13 @@ def main():
"database": getenv("MYSQL_DATABASE", "preDb_0dcc87940d3655fa574b253df04ca1c3"),
"port": int(getenv("MYSQL_PORT", "3306")),
}
# Zakres dat
period_from = getenv("PERIOD_FROM")
period_to = getenv("PERIOD_TO")
if not period_from or not period_to:
period_from, period_to = last_full_month_bounds()
period_label = "{} .. {}".format(period_from, period_to)
invoice_type = getenv("INVOICE_TYPE", "normal")
# Konfiguracja AI (model do API + alias do UI)
api_key = API_KEY_HARDCODE or getenv("OPENAI_API_KEY", "")
model = getenv("OPENAI_MODEL", "gpt-4.1")
MODEL_ALIAS = {
"gpt-4.1": "GPT-4.1",
"gpt-4.1-mini": "GPT-4.1-mini",
"gpt-4o": "GPT-4o",
"gpt-4o-mini": "GPT-4o-mini",
}
model_alias = MODEL_ALIAS.get(model, model)
system_prompt = (
"Jesteś analitykiem sprzedaży. Zwróć TYLKO jedną sekcję HTML (bez <html>/<head>/<body>), "
"może być pojedynczy <div> z nagłówkami i listami. Podsumuj kluczowe trendy (dzień, mix), wskaż top produkty/klientów, "
"anomalia/odchylenia oraz daj 36 praktycznych rekomendacji dla sprzedaży/zaopatrzenia/marketingu. Krótko i konkretnie, po polsku."
)
# SQL -> rows
# --- SQL -> rows (UWZGLĘDNIJ DATY; typ wg ENV) ---
try:
cnx = mysql.connector.connect(**cfg)
cur = cnx.cursor()
if invoice_type:
cur.execute(
"""
SELECT i.document_no,
@@ -243,39 +535,88 @@ def main():
""",
(period_from, period_to, invoice_type),
)
else:
cur.execute(
"""
SELECT i.document_no,
i.parent_name,
DATE(i.register_date) AS register_date,
ii.code,
ii.name,
ii.quantity,
ii.total_netto
FROM ecminvoiceoutitems AS ii
JOIN ecminvoiceouts AS i ON i.id = ii.ecminvoiceout_id
WHERE i.register_date >= %s
AND i.register_date < %s
""",
(period_from, period_to),
)
rows = cur.fetchall()
cur.close()
cnx.close()
except Exception as e:
sys.stdout.write(
'<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;'
'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;'
'border-radius:12px;background:#fff5f5;color:#991b1b;">'
'<h3 style="margin:0 0 8px;font-size:18px;">Błąd połączenia/zapytania MySQL</h3>'
f'<p style="margin:0;">{str(e)}</p></div>'
)
sys.exit(1)
html_fatal(str(e), title="Błąd połączenia/zapytania MySQL")
# Preagregaty
try:
results = compute_preaggregates(rows)
# --- LICZ TYLKO WYBRANE PREAGREGATY (w tym pseudo 'kpis') ---
results = {}
serialized = {}
if metric_names:
results = compute_selected_preaggs(rows, metric_names)
serialized = serialize_for_ai(results)
except Exception as e:
sys.stdout.write(
'<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;'
'max-width:900px;margin:24px auto;padding:16px 20px;border:1px solid #fecaca;'
'border-radius:12px;background:#fff5f5;color:#991b1b;">'
'<h3 style="margin:0 0 8px;font-size:18px;">Błąd preagregacji</h3>'
f'<p style="margin:0;">{str(e)}</p></div>'
)
sys.exit(1)
serialized = sanitize_serialized(serialized) # usuń ewentualne _error -> traktuj jako puste
else:
serialized = {}
# KPI (na podstawie daily_sales)
# --- ZAPIS do reporting (tylko to, co faktycznie policzyłeś) ---
try:
if serialized:
rep_cfg = {
"host": "host.docker.internal",
"port": 3307,
"user": "remote",
"password": os.environ.get("REPORTING_PASSWORD", "areiufh*&^yhdua"),
"database": "ai",
}
if os.environ.get("REPORTING_SSL_CA"):
rep_cfg["ssl_ca"] = os.environ["REPORTING_SSL_CA"]
if os.environ.get("REPORTING_SSL_CERT"):
rep_cfg["ssl_cert"] = os.environ["REPORTING_SSL_CERT"]
if os.environ.get("REPORTING_SSL_KEY"):
rep_cfg["ssl_key"] = os.environ["REPORTING_SSL_KEY"]
cnx2 = connect_html_or_die(rep_cfg, label="ReportingDB")
cur2 = cnx2.cursor()
if "daily_sales" in serialized:
upsert_daily_sales(cur2, serialized.get("daily_sales") or [])
if "product_summary" in serialized:
upsert_product_summary(cur2, serialized.get("product_summary") or [], period_from, period_to)
if "customer_summary" in serialized:
upsert_customer_summary(cur2, serialized.get("customer_summary") or [], period_from, period_to)
if "product_daily" in serialized:
upsert_product_daily(cur2, serialized.get("product_daily") or [])
cnx2.commit()
cur2.close(); cnx2.close()
except Exception as e:
sys.stderr.write(f"[reporting] ERROR: {e}\n")
# --- KPI: jeśli wybrano 'kpis' -> bierz z wyników; w przeciwnym razie spróbuj z daily_sales; inaczej zera ---
kpis = []
if "kpis" in results and isinstance(results["kpis"], pd.DataFrame) and not results["kpis"].empty:
r = results["kpis"].iloc[0]
total_sales = r.get("total_sales") or 0
total_qty = r.get("total_qty") or 0
total_docs = r.get("total_docs") or 0
asp = r.get("asp")
else:
daily = serialized.get("daily_sales") or []
total_sales = sum((r.get("sales") or 0) for r in daily)
total_qty = sum((r.get("qty") or 0) for r in daily)
total_docs = sum((r.get("docs") or 0) for r in daily)
total_sales = sum((x.get("sales") or 0) for x in daily) if daily else 0
total_qty = sum((x.get("qty") or 0) for x in daily) if daily else 0
total_docs = sum((x.get("docs") or 0) for x in daily) if daily else 0
asp = (total_sales / total_qty) if total_qty else None
kpis = [
("Sprzedaż (PLN)", fmt_money(total_sales)),
("Ilość (szt.)", "{:,.0f}".format(total_qty).replace(",", " ")),
@@ -283,28 +624,45 @@ def main():
("ASP (PLN/szt.)", fmt_money(asp) if asp is not None else ""),
]
# Sekcje HTML — WYŚWIETLAMY WSZYSTKIE KLUCZOWE PREAGREGATY
top_prod = serialized.get("top10_products_by_sales") or []
top_cli = serialized.get("top10_customers_by_sales") or []
daily_tbl = html_table(serialized.get("daily_sales") or [], title="Sprzedaż dzienna (skrót)", max_rows=30)
prod_sum_tbl = html_table(serialized.get("product_summary") or [], title="Podsumowanie produktów (skrót)", max_rows=30)
cust_sum_tbl = html_table(serialized.get("customer_summary") or [], title="Podsumowanie klientów (skrót)", max_rows=30)
prod_daily_tbl= html_table(serialized.get("product_daily") or [], title="Produkt × Dzień (próbka)", max_rows=30)
prod_tbl = html_table(top_prod, title="Top 10 produktów (po sprzedaży)", max_rows=10)
cust_tbl = html_table(top_cli, title="Top 10 klientów (po sprzedaży)", max_rows=10)
# --- Sekcje HTML: renderuj tylko te, które policzyłeś ---
parts = []
if "top10_products_by_sales" in serialized:
parts.append(html_table(serialized.get("top10_products_by_sales") or [], title="Top 10 produktów (po sprzedaży)", max_rows=10))
if "top10_customers_by_sales" in serialized:
parts.append(html_table(serialized.get("top10_customers_by_sales") or [], title="Top 10 klientów (po sprzedaży)", max_rows=10))
if "daily_sales" in serialized:
parts.append(html_table(serialized.get("daily_sales") or [], title="Sprzedaż dzienna (skrót)", max_rows=30))
if "product_summary" in serialized:
parts.append(html_table(serialized.get("product_summary") or [], title="Podsumowanie produktów (skrót)", max_rows=30))
if "customer_summary" in serialized:
parts.append(html_table(serialized.get("customer_summary") or [], title="Podsumowanie klientów (skrót)", max_rows=30))
if "product_daily" in serialized:
parts.append(html_table(serialized.get("product_daily") or [], title="Produkt × Dzień (próbka)", max_rows=30))
# Dane do AI
ai_data = build_ai_payload(serialized, period_label)
# --- AI tylko gdy: --ai true ORAZ jest co najmniej jeden rekord w którymś z wybranych agregatów ---
api_key = API_KEY_HARDCODE or getenv("OPENAI_API_KEY", "")
model = getenv("OPENAI_MODEL", "gpt-4.1")
MODEL_ALIAS = {
"gpt-4.1": "GPT-4.1",
"gpt-4.1-mini": "GPT-4.1-mini",
"gpt-4o": "GPT-4o",
"gpt-4o-mini": "GPT-4o-mini",
}
model_alias = MODEL_ALIAS.get(model, model)
ai_section = ""
if with_ai and has_any_rows(serialized):
try:
ai_data = {"kpis_hint": {"period_label": shown_label}}
for name, records in serialized.items():
ai_data[name] = compact_table(records, 100)
ai_json = json.dumps(ai_data, ensure_ascii=False, separators=(",", ":"), default=str)
# Wołanie AI (z fallbackiem na mini model przy 429: insufficient_quota)
ai_section = ""
if api_key:
try:
ai_section = call_openai_chat(
api_key=api_key,
api_key=(api_key or ""),
model=model,
system_prompt=system_prompt,
system_prompt=("Jesteś analitykiem sprzedaży. Zwróć TYLKO jedną sekcję HTML (bez <html>/<head>/<body>). "
"Streszcz kluczowe trendy i daj 36 zaleceń. Po polsku."),
user_payload_json=ai_json,
temperature=0.3,
connect_timeout=10,
@@ -314,39 +672,40 @@ def main():
except Exception as e:
err = str(e)
if "insufficient_quota" in err or "You exceeded your current quota" in err:
# spróbuj tańszego modelu
try:
ai_section = call_openai_chat(
api_key=api_key,
api_key=(api_key or ""),
model="gpt-4.1-mini",
system_prompt=system_prompt,
system_prompt=("Jesteś analitykiem sprzedaży. Zwróć TYLKO jedną sekcję HTML (bez <html>/<head>/<body>). "
"Streszcz kluczowe trendy i daj 36 zaleceń. Po polsku."),
user_payload_json=ai_json,
temperature=0.3,
connect_timeout=10,
read_timeout=90,
max_retries=2,
)
model_alias = "GPT-5 Mini"
model_alias = "GPT-4.1-mini"
except Exception as ee:
ai_section = (
'<div style="color:#991b1b;background:#fff5f5;border:1px solid #fecaca;'
'padding:10px;border-radius:8px;">'
f'Brak dostępnego limitu API. {str(ee)}</div>'
'padding:10px;border-radius:8px;">Brak dostępnego limitu API. {}</div>'.format(str(ee))
)
else:
ai_section = (
'<div style="color:#991b1b;background:#fff5f5;border:1px solid #fecaca;'
'padding:10px;border-radius:8px;">'
f'Błąd wywołania AI: {err}</div>'
'padding:10px;border-radius:8px;">Błąd wywołania AI: {}</div>'.format(err)
)
else:
ai_section = '<div style="color:#6b7280">Analiza AI wyłączona lub brak wybranych danych.</div>'
model_alias = ""
# Finalny HTML (jeden <div>)
# --- Finalny HTML ---
report_html = render_report_html(
period_label=period_label,
period_label=shown_label,
kpis=kpis,
parts=[prod_tbl, cust_tbl, daily_tbl, prod_sum_tbl, cust_sum_tbl, prod_daily_tbl],
parts=parts,
ai_section=ai_section,
model_alias=model_alias if api_key else ""
model_alias=(model_alias if (with_ai and has_any_rows(serialized)) else "")
)
sys.stdout.write(report_html)

View File

@@ -0,0 +1,177 @@
<?php
/**
* report_form.php — formularz + uruchomienie analysisAI.py z parametrami
* ZGODNE z PHP 5.6 i Sugar 6 (wyciszone E_STRICT/E_DEPRECATED/NOTICE).
*/
// --- wycisz „hałas” Sugar CRM ---
error_reporting(E_ALL & ~E_STRICT & ~E_DEPRECATED & ~E_NOTICE);
ini_set('display_errors', '0');
// (opcjonalnie) loguj do pliku
// ini_set('log_errors', '1');
// ini_set('error_log', '/var/log/php_form_errors.log');
// --- ŚCIEŻKI (dostosuj do swojej instalacji) ---
$python = '/usr/local/bin/python3';
$script = '/var/www/html/modules/EcmInvoiceOuts/ai/analysisAI.py';
$baseDir = dirname($script);
// --- domyślne wartości pól ---
$defaultDateTo = date('Y-m-d');
$defaultDateFrom = date('Y-m-d', strtotime('-7 days'));
// --- zbieranie POST (PHP 5.6 friendly) ---
$submitted = (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST');
$post_date_from = isset($_POST['date_from']) ? $_POST['date_from'] : $defaultDateFrom;
$post_date_to = isset($_POST['date_to']) ? $_POST['date_to'] : $defaultDateTo;
$post_preaggs = (isset($_POST['preaggs']) && is_array($_POST['preaggs'])) ? $_POST['preaggs'] : array();
$post_with_ai = !empty($_POST['with_ai']);
function h($v) { return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); }
function is_valid_date_yyyy_mm_dd($d) {
return (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
}
// --- wykonanie skryptu Pythona, jeśli formularz został wysłany ---
$ran = false;
$ok = false;
$rc = 0;
$out = '';
$err = '';
if ($submitted) {
// prosta walidacja dat
if (!is_valid_date_yyyy_mm_dd($post_date_from) || !is_valid_date_yyyy_mm_dd($post_date_to)) {
$err = "Nieprawidłowy format daty. Użyj YYYY-MM-DD.";
$ran = true;
} else {
// zbuduj argumenty
$args = array(
'--date-from', $post_date_from,
'--date-to', $post_date_to,
'--ai', ($post_with_ai ? 'true' : 'false')
);
if (!empty($post_preaggs)) {
// CSV z zaznaczonych preagregatów
$args[] = '--metrics';
$args[] = implode(',', $post_preaggs);
}
// komenda: przejdź do katalogu skryptu, uruchom pythona; zbierz stdout+stderr
$cmd = 'cd ' . escapeshellarg($baseDir) . ' && ' .
escapeshellcmd($python) . ' ' . escapeshellarg($script);
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
$output = array();
$returnVar = 0;
exec($cmd . ' 2>&1', $output, $returnVar);
$ran = true;
$rc = $returnVar;
$out = implode("\n", $output);
$ok = ($returnVar === 0);
if (!$ok && $err === '') {
$err = "Błąd uruchamiania skryptu Python (kod: " . $rc . "):\n" . $out;
}
}
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>Generator raportu sprzedaży</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font: 14px/1.4 system-ui, Arial, sans-serif; padding: 20px; }
fieldset { margin-bottom: 16px; padding: 12px; border-radius: 6px; border:1px solid #e5e5e5; }
.row { display: flex; gap: 16px; flex-wrap: wrap; }
.col { min-width: 220px; flex: 1; }
label { display:block; margin: 6px 0; }
input[type="date"], button { padding: 6px 10px; font-size:14px; }
button { margin-top: 10px; cursor: pointer; border:1px solid #0a66c2; background:#0a66c2; color:#fff; border-radius:8px; }
.pill { display:inline-block; padding:2px 8px; border-radius:999px; background:#eee; margin:4px 6px 0 0; }
.out { white-space: normal; background: #fff; border:1px solid #ddd; padding:12px; border-radius:6px; }
.error { white-space: pre-wrap; background: #fff3f3; border:1px solid #f3c2c2; padding:12px; border-radius:6px; color:#b00020; }
.muted { color:#666; }
</style>
</head>
<body>
<h1>Raport sprzedaży — parametry</h1>
<form method="post">
<!-- zakres dat -->
<fieldset>
<legend>Zakres dat</legend>
<div class="row">
<div class="col">
<label>Data od:
<input type="date" name="date_from" value="<?php echo h($post_date_from); ?>" required>
</label>
</div>
<div class="col">
<label>Data do:
<input type="date" name="date_to" value="<?php echo h($post_date_to); ?>" required>
</label>
</div>
</div>
</fieldset>
<!-- preagregaty -->
<fieldset>
<legend>Preagregaty do analizy</legend>
<label><input type="checkbox" name="preaggs[]" value="daily_sales" <?php echo in_array('daily_sales', $post_preaggs, true) ? 'checked' : ''; ?>> Dzienne sprzedaże</label>
<label><input type="checkbox" name="preaggs[]" value="product_summary" <?php echo in_array('product_summary', $post_preaggs, true) ? 'checked' : ''; ?>> Podsumowanie produktów</label>
<label><input type="checkbox" name="preaggs[]" value="customer_summary" <?php echo in_array('customer_summary', $post_preaggs, true) ? 'checked' : ''; ?>> Podsumowanie klientów</label>
<label><input type="checkbox" name="preaggs[]" value="product_daily" <?php echo in_array('product_daily', $post_preaggs, true) ? 'checked' : ''; ?>> Sprzedaż produktu dziennie</label>
<label><input type="checkbox" name="preaggs[]" value="top10_products_by_sales" <?php echo in_array('top10_products_by_sales', $post_preaggs, true) ? 'checked' : ''; ?>> Top10 produktów</label>
<label><input type="checkbox" name="preaggs[]" value="top10_customers_by_sales"<?php echo in_array('top10_customers_by_sales', $post_preaggs, true) ? 'checked' : ''; ?>> Top10 klientów</label>
</fieldset>
<!-- AI -->
<fieldset>
<legend>Analiza AI</legend>
<label>
<input type="checkbox" name="with_ai" <?php echo $post_with_ai ? 'checked' : ''; ?>> Dołącz analizę AI
</label>
</fieldset>
<button type="submit">Generuj</button>
</form>
<?php if ($submitted): ?>
<hr>
<h2>Użyte parametry</h2>
<p>
<span class="pill">Od: <?php echo h($post_date_from); ?></span>
<span class="pill">Do: <?php echo h($post_date_to); ?></span>
<span class="pill">AI: <?php echo $post_with_ai ? 'tak' : 'nie'; ?></span>
</p>
<p>Preagregaty:
<?php
if (!empty($post_preaggs)) {
foreach ($post_preaggs as $p) {
echo '<span class="pill">'.h($p).'</span>';
}
} else {
echo '<span class="muted">brak</span>';
}
?>
</p>
<h2>Wynik analizy</h2>
<?php if (!$ok): ?>
<div class="error"><?php echo h($err); ?></div>
<?php else: ?>
<!-- Zakładamy, że Python zwraca gotowy HTML -->
<div class="out"><?php echo $out; ?></div>
<?php endif; ?>
<?php endif; ?>
</body>
</html>