generowanie raportów z wyborem preagregatów
This commit is contained in:
@@ -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,16 +511,135 @@ 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)
|
||||
# --- 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,
|
||||
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
|
||||
AND i.type = %s
|
||||
""",
|
||||
(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:
|
||||
html_fatal(str(e), title="Błąd połączenia/zapytania MySQL")
|
||||
|
||||
# --- 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)
|
||||
serialized = sanitize_serialized(serialized) # usuń ewentualne _error -> traktuj jako puste
|
||||
else:
|
||||
serialized = {}
|
||||
|
||||
# --- 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((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(",", " ")),
|
||||
("Dokumenty", "{:,.0f}".format(total_docs).replace(",", " ")),
|
||||
("ASP (PLN/szt.)", fmt_money(asp) if asp is not None else "—"),
|
||||
]
|
||||
|
||||
# --- 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))
|
||||
|
||||
# --- 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 = {
|
||||
@@ -216,95 +650,19 @@ def main():
|
||||
}
|
||||
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 3–6 praktycznych rekomendacji dla sprzedaży/zaopatrzenia/marketingu. Krótko i konkretnie, po polsku."
|
||||
)
|
||||
|
||||
# SQL -> rows
|
||||
try:
|
||||
cnx = mysql.connector.connect(**cfg)
|
||||
cur = cnx.cursor()
|
||||
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
|
||||
AND i.type = %s
|
||||
""",
|
||||
(period_from, period_to, invoice_type),
|
||||
)
|
||||
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)
|
||||
|
||||
# Preagregaty
|
||||
try:
|
||||
results = compute_preaggregates(rows)
|
||||
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)
|
||||
|
||||
# KPI (na podstawie daily_sales)
|
||||
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)
|
||||
asp = (total_sales / total_qty) if total_qty else None
|
||||
kpis = [
|
||||
("Sprzedaż (PLN)", fmt_money(total_sales)),
|
||||
("Ilość (szt.)", "{:,.0f}".format(total_qty).replace(",", " ")),
|
||||
("Dokumenty", "{:,.0f}".format(total_docs).replace(",", " ")),
|
||||
("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)
|
||||
|
||||
# Dane do AI
|
||||
ai_data = build_ai_payload(serialized, period_label)
|
||||
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:
|
||||
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)
|
||||
|
||||
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 3–6 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 3–6 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)
|
||||
|
||||
|
||||
177
modules/EcmInvoiceOuts/report_form.php
Normal file
177
modules/EcmInvoiceOuts/report_form.php
Normal 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>
|
||||
Reference in New Issue
Block a user