Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -4,8 +4,7 @@
|
|
| 4 |
# - Deterministic time windows (Harare); explicit start/end on API calls
|
| 5 |
# - KPI engine never uses LLM for numbers (LLM is narration-only fallback)
|
| 6 |
# - JSON-safe snapshot; deep DEBUG logs (optional mirror to Firebase)
|
| 7 |
-
# - Drop-in Firebase + AI wiring identical in spirit to
|
| 8 |
-
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import os, io, re, json, time, uuid, base64, logging
|
|
@@ -49,7 +48,6 @@ try:
|
|
| 49 |
credentials_json_string = os.environ.get("FIREBASE")
|
| 50 |
if not credentials_json_string:
|
| 51 |
raise ValueError("FIREBASE env var is not set")
|
| 52 |
-
|
| 53 |
credentials_json = json.loads(credentials_json_string)
|
| 54 |
firebase_db_url = os.environ.get("Firebase_DB")
|
| 55 |
if not firebase_db_url:
|
|
@@ -113,16 +111,11 @@ logger.info(f"Chart export path set to: {user_defined_path}")
|
|
| 113 |
# -----------------------------------------------------------------------------
|
| 114 |
# Admin API client (client-supplied credentials; holistic admin scope)
|
| 115 |
# -----------------------------------------------------------------------------
|
| 116 |
-
# --- Auth + Request client (robust: bearer OR cookie session) ---
|
| 117 |
-
|
| 118 |
-
import requests
|
| 119 |
-
from typing import Dict, Optional
|
| 120 |
-
|
| 121 |
SC_BASE_URL = os.getenv("SC_BASE_URL", "https://delta-api.pricelyst.co.zw").rstrip("/")
|
| 122 |
|
| 123 |
class SCAuth:
|
| 124 |
"""Caches a requests.Session per admin email; supports bearer or cookie sessions."""
|
| 125 |
-
_cache: Dict[str, Dict[str,
|
| 126 |
|
| 127 |
@classmethod
|
| 128 |
def invalidate(cls, email: str) -> None:
|
|
@@ -137,7 +130,6 @@ class SCAuth:
|
|
| 137 |
def _extract_token(cls, js: dict) -> Optional[str]:
|
| 138 |
if not isinstance(js, dict):
|
| 139 |
return None
|
| 140 |
-
# Try common token shapes
|
| 141 |
candidates = [
|
| 142 |
js.get("token"),
|
| 143 |
js.get("access_token"),
|
|
@@ -152,42 +144,38 @@ class SCAuth:
|
|
| 152 |
return None
|
| 153 |
|
| 154 |
@classmethod
|
| 155 |
-
def login(cls, email: str, password: str) -> Dict[str,
|
| 156 |
s = requests.Session()
|
| 157 |
s.headers.update({"Accept": "application/json"})
|
| 158 |
url = f"{SC_BASE_URL}/api/auth/admin/login"
|
| 159 |
-
# IMPORTANT: many APIs expect JSON, not form data
|
| 160 |
resp = s.post(url, json={"email": email, "password": password}, timeout=30)
|
| 161 |
|
| 162 |
-
|
| 163 |
-
body_text = ""
|
| 164 |
-
body_json = {}
|
| 165 |
try:
|
| 166 |
body_json = resp.json() or {}
|
| 167 |
except Exception:
|
| 168 |
-
body_text = (resp.text or "")[:800]
|
| 169 |
-
|
| 170 |
token = cls._extract_token(body_json)
|
| 171 |
|
| 172 |
if token:
|
| 173 |
s.headers.update({"Authorization": f"Bearer {token}"})
|
| 174 |
entry = {"session": s, "auth": "bearer", "token": token}
|
| 175 |
cls._cache[email] = entry
|
|
|
|
| 176 |
return entry
|
| 177 |
|
| 178 |
-
# If no token but we got Set-Cookie, assume cookie session auth
|
| 179 |
if resp.cookies and (resp.status_code // 100) == 2:
|
| 180 |
entry = {"session": s, "auth": "cookie"}
|
| 181 |
cls._cache[email] = entry
|
|
|
|
| 182 |
return entry
|
| 183 |
|
| 184 |
-
# Neither token nor cookie: raise, but include short body for diagnosis
|
| 185 |
snippet = body_text or (str(body_json)[:800])
|
| 186 |
raise RuntimeError(f"Login did not return a token or cookie session. HTTP {resp.status_code}. Body≈ {snippet}")
|
| 187 |
|
| 188 |
def sc_request(method: str, path: str, email: str, password: str, *,
|
| 189 |
params: dict = None, json_body: dict = None, timeout: int = 30):
|
| 190 |
-
"""Authenticated request with 401 auto-refresh (once)."""
|
| 191 |
if not path.startswith("/"):
|
| 192 |
path = "/" + path
|
| 193 |
url = f"{SC_BASE_URL}{path}"
|
|
@@ -195,7 +183,6 @@ def sc_request(method: str, path: str, email: str, password: str, *,
|
|
| 195 |
def _do(s: requests.Session):
|
| 196 |
return s.request(method.upper(), url, params=params, json=json_body, timeout=timeout)
|
| 197 |
|
| 198 |
-
# get or create session
|
| 199 |
entry = SCAuth._cache.get(email)
|
| 200 |
if not entry:
|
| 201 |
entry = SCAuth.login(email, password)
|
|
@@ -203,77 +190,83 @@ def sc_request(method: str, path: str, email: str, password: str, *,
|
|
| 203 |
|
| 204 |
resp = _do(s)
|
| 205 |
if resp.status_code == 401:
|
| 206 |
-
# refresh & retry once
|
| 207 |
SCAuth.invalidate(email)
|
| 208 |
entry = SCAuth.login(email, password)
|
| 209 |
s = entry["session"]
|
| 210 |
resp = _do(s)
|
| 211 |
|
| 212 |
-
# Raise for other errors
|
| 213 |
try:
|
| 214 |
resp.raise_for_status()
|
| 215 |
except Exception as e:
|
| 216 |
-
# include small snippet to aid debugging
|
| 217 |
snippet = (getattr(resp, "text", "") or "")[:800]
|
| 218 |
raise RuntimeError(f"SC request error {method.upper()} {path}: HTTP {resp.status_code} – {snippet}") from e
|
| 219 |
|
| 220 |
-
|
| 221 |
try:
|
| 222 |
-
|
| 223 |
except Exception:
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
#
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
# -----------------------------------------------------------------------------
|
| 229 |
-
# Timezone
|
| 230 |
# -----------------------------------------------------------------------------
|
| 231 |
-
import os
|
| 232 |
-
import pandas as pd
|
| 233 |
-
|
| 234 |
-
# Canonical timezone for Brave Retail Insights
|
| 235 |
TZ = os.getenv("APP_TZ", "Africa/Harare")
|
| 236 |
-
|
| 237 |
-
# Backward-compatible alias for older references
|
| 238 |
-
_TZ = TZ
|
| 239 |
|
| 240 |
def now_harare() -> pd.Timestamp:
|
| 241 |
-
"""Return the current timestamp in Harare timezone."""
|
| 242 |
return pd.Timestamp.now(tz=TZ)
|
| 243 |
|
| 244 |
-
def week_bounds_from(ts: pd.Timestamp) ->
|
| 245 |
-
"""Return Monday–Sunday bounds for the given timestamp."""
|
| 246 |
monday = ts.tz_convert(TZ).normalize() - pd.Timedelta(days=ts.weekday())
|
| 247 |
sunday = monday + pd.Timedelta(days=6, hours=23, minutes=59, seconds=59)
|
| 248 |
return monday, sunday
|
| 249 |
|
| 250 |
-
def this_month_bounds(ts: pd.Timestamp) ->
|
| 251 |
-
"""Return start and end of the current month."""
|
| 252 |
first_this = ts.normalize().replace(day=1)
|
| 253 |
-
|
| 254 |
-
year=first_this.year + 1, month=1
|
| 255 |
-
|
| 256 |
-
|
|
|
|
| 257 |
return first_this, last_this
|
| 258 |
-
|
| 259 |
|
| 260 |
def period_to_bounds(period: str) -> Tuple[pd.Timestamp, pd.Timestamp, str]:
|
| 261 |
p = (period or "week").strip().lower()
|
| 262 |
now = now_harare()
|
| 263 |
if p == "today":
|
| 264 |
start = now.normalize()
|
| 265 |
-
end = start + pd.Timedelta(hours=23, minutes=59, seconds=59)
|
| 266 |
-
lbl = "Today"
|
| 267 |
elif p in ("week", "this_week"):
|
| 268 |
-
start, end = week_bounds_from(now)
|
| 269 |
-
lbl = "This Week"
|
| 270 |
elif p in ("month", "this_month"):
|
| 271 |
-
start, end = this_month_bounds(now)
|
| 272 |
-
lbl = "This Month"
|
| 273 |
elif p in ("year", "this_year"):
|
| 274 |
start = now.normalize().replace(month=1, day=1, hour=0, minute=0, second=0)
|
| 275 |
-
end = now.normalize().replace(month=12, day=31, hour=23, minute=59, second=59)
|
| 276 |
-
lbl = "This Year"
|
| 277 |
else:
|
| 278 |
start, end = week_bounds_from(now); lbl = "This Week"
|
| 279 |
return start, end, lbl
|
|
@@ -338,32 +331,20 @@ def sanitize_answer(ans) -> str:
|
|
| 338 |
if tb in s: s = s.split(tb, 1)[0]
|
| 339 |
return (s or "").strip()
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
#
|
| 344 |
-
from typing import Any, Iterable, List, Dict, Optional
|
| 345 |
-
import json
|
| 346 |
-
import pandas as pd
|
| 347 |
-
import numpy as np
|
| 348 |
-
|
| 349 |
def _to_list(x: Any) -> List[Any]:
|
| 350 |
-
|
| 351 |
-
if x
|
| 352 |
-
|
| 353 |
-
if isinstance(x, list):
|
| 354 |
-
return x
|
| 355 |
-
if isinstance(x, dict):
|
| 356 |
-
return [x]
|
| 357 |
if isinstance(x, str):
|
| 358 |
-
# try to parse JSON strings like '[{...}]' or '{"a":1}'
|
| 359 |
try:
|
| 360 |
j = json.loads(x)
|
| 361 |
-
if isinstance(j, list):
|
| 362 |
-
|
| 363 |
-
if isinstance(j, dict):
|
| 364 |
-
return [j]
|
| 365 |
except Exception:
|
| 366 |
-
return [x]
|
| 367 |
return [x]
|
| 368 |
|
| 369 |
def _to_float(x: Any) -> Optional[float]:
|
|
@@ -382,13 +363,10 @@ def _to_int(x: Any) -> Optional[int]:
|
|
| 382 |
return None
|
| 383 |
|
| 384 |
def _coerce_date(s: Any) -> Optional[pd.Timestamp]:
|
| 385 |
-
if s is None:
|
| 386 |
-
return None
|
| 387 |
try:
|
| 388 |
dt = pd.to_datetime(s, errors="coerce")
|
| 389 |
-
if pd.isna(dt):
|
| 390 |
-
return None
|
| 391 |
-
# align to Harare if naive; respects your global TZ
|
| 392 |
try:
|
| 393 |
return dt.tz_localize(TZ, nonexistent="shift_forward", ambiguous="NaT")
|
| 394 |
except Exception:
|
|
@@ -396,10 +374,186 @@ def _coerce_date(s: Any) -> Optional[pd.Timestamp]:
|
|
| 396 |
except Exception:
|
| 397 |
return None
|
| 398 |
|
| 399 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
# -----------------------------------------------------------------------------
|
| 402 |
-
# Admin KPI Engine (holistic view)
|
| 403 |
# -----------------------------------------------------------------------------
|
| 404 |
class AdminAnalyticsEngine:
|
| 405 |
"""Single-tenant holistic admin analytics. No shop/brand filters; admin sees entire dataset."""
|
|
@@ -410,44 +564,27 @@ class AdminAnalyticsEngine:
|
|
| 410 |
self.period = (period or "week").lower().strip()
|
| 411 |
self.t_start, self.t_end, self.period_label = period_to_bounds(self.period)
|
| 412 |
|
| 413 |
-
# -------------------- helpers --------------------
|
| 414 |
@staticmethod
|
| 415 |
def _unwrap_data(payload: dict) -> dict:
|
| 416 |
if isinstance(payload, dict):
|
| 417 |
-
# common backend pattern: {"status":"success","data":{...}}
|
| 418 |
return payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
| 419 |
return {}
|
| 420 |
|
| 421 |
-
# -------------------- API pulls (no shop/brand params at all) --------------------
|
| 422 |
def _dashboard(self) -> dict:
|
| 423 |
-
raw = sc_request(
|
| 424 |
-
"GET",
|
| 425 |
-
"/api/analytics/dashboard",
|
| 426 |
-
self.email,
|
| 427 |
-
self.password,
|
| 428 |
-
params={"period": self.period},
|
| 429 |
-
)
|
| 430 |
data = self._unwrap_data(raw)
|
| 431 |
emit_kpi_debug(self.tenant_key, "dashboard", data or raw or {})
|
|
|
|
|
|
|
| 432 |
return data or {}
|
| 433 |
|
| 434 |
def _sales_series(self) -> pd.DataFrame:
|
| 435 |
-
"""
|
| 436 |
-
Fetches /api/analytics/sales and returns a tidy daily series:
|
| 437 |
-
columns: ['_date','total_sales','total_orders','aov']
|
| 438 |
-
- Robust to strings/dicts/mixed payloads
|
| 439 |
-
- Converts numeric strings to floats/ints
|
| 440 |
-
- Skips malformed rows instead of crashing
|
| 441 |
-
"""
|
| 442 |
params = {
|
| 443 |
"start_date": self.t_start.strftime("%Y-%m-%d"),
|
| 444 |
"end_date": self.t_end.strftime("%Y-%m-%d"),
|
| 445 |
"group_by": "day",
|
| 446 |
}
|
| 447 |
raw = sc_request("GET", "/api/analytics/sales", self.email, self.password, params=params)
|
| 448 |
-
|
| 449 |
-
# Expect raw like:
|
| 450 |
-
# {"status":"success","data":{"sales_over_time":[{"date":"YYYY-MM-DD","total_sales":"141.11","total_orders":3}], ...}}
|
| 451 |
data = {}
|
| 452 |
if isinstance(raw, dict):
|
| 453 |
data = (raw.get("data") or raw) if isinstance(raw.get("data"), (dict, list)) else raw
|
|
@@ -458,78 +595,105 @@ class AdminAnalyticsEngine:
|
|
| 458 |
except Exception:
|
| 459 |
data = {}
|
| 460 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
series = []
|
| 462 |
-
|
| 463 |
-
for r in _to_list(sales_ot):
|
| 464 |
if not isinstance(r, dict):
|
| 465 |
continue
|
| 466 |
date_str = r.get("date") or r.get("day") or r.get("period")
|
| 467 |
dt = _coerce_date(date_str)
|
| 468 |
if dt is None:
|
| 469 |
continue
|
| 470 |
-
|
| 471 |
total_sales = _to_float(r.get("total_sales") or r.get("total") or r.get("revenue"))
|
| 472 |
total_orders = _to_int(r.get("total_orders") or r.get("orders") or r.get("count"))
|
| 473 |
aov = _to_float(r.get("average_order_value") or r.get("aov"))
|
| 474 |
-
|
| 475 |
if aov is None and total_sales is not None and (total_orders or 0) > 0:
|
| 476 |
aov = float(total_sales) / int(total_orders)
|
| 477 |
-
|
| 478 |
series.append({
|
| 479 |
"_date": dt,
|
| 480 |
"total_sales": float(total_sales) if total_sales is not None else 0.0,
|
| 481 |
"total_orders": int(total_orders) if total_orders is not None else 0,
|
| 482 |
"aov": float(aov) if aov is not None else None,
|
| 483 |
})
|
| 484 |
-
|
| 485 |
df = pd.DataFrame(series)
|
| 486 |
if df.empty:
|
| 487 |
return pd.DataFrame(columns=["_date", "total_sales", "total_orders", "aov"])
|
| 488 |
-
|
| 489 |
df = df.sort_values("_date").reset_index(drop=True)
|
| 490 |
emit_kpi_debug(self.tenant_key, "sales_series_raw", (raw if isinstance(raw, dict) else {"raw": raw}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
return df
|
| 492 |
|
| 493 |
def _products(self) -> dict:
|
| 494 |
raw = sc_request(
|
| 495 |
-
"GET",
|
| 496 |
-
"
|
| 497 |
-
self.email,
|
| 498 |
-
self.password,
|
| 499 |
-
params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")},
|
| 500 |
)
|
| 501 |
data = self._unwrap_data(raw)
|
| 502 |
emit_kpi_debug(self.tenant_key, "products", data or raw or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
return data or {}
|
| 504 |
|
| 505 |
def _customers(self) -> dict:
|
| 506 |
raw = sc_request(
|
| 507 |
-
"GET",
|
| 508 |
-
"
|
| 509 |
-
self.email,
|
| 510 |
-
self.password,
|
| 511 |
-
params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")},
|
| 512 |
)
|
| 513 |
data = self._unwrap_data(raw)
|
| 514 |
emit_kpi_debug(self.tenant_key, "customers", data or raw or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
return data or {}
|
| 516 |
|
| 517 |
def _inventory(self) -> dict:
|
| 518 |
raw = sc_request("GET", "/api/analytics/inventory", self.email, self.password)
|
| 519 |
data = self._unwrap_data(raw)
|
| 520 |
emit_kpi_debug(self.tenant_key, "inventory", data or raw or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
return data or {}
|
| 522 |
|
| 523 |
def _comparisons(self) -> dict:
|
| 524 |
raw = sc_request(
|
| 525 |
-
"GET",
|
| 526 |
-
"
|
| 527 |
-
self.email,
|
| 528 |
-
self.password,
|
| 529 |
-
params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")},
|
| 530 |
)
|
| 531 |
data = self._unwrap_data(raw)
|
| 532 |
emit_kpi_debug(self.tenant_key, "comparisons", data or raw or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
return data or {}
|
| 534 |
|
| 535 |
# -------------------- deterministic snapshot --------------------
|
|
@@ -541,8 +705,6 @@ class AdminAnalyticsEngine:
|
|
| 541 |
inv = self._inventory()
|
| 542 |
comps = self._comparisons()
|
| 543 |
|
| 544 |
-
# Dashboard numbers can be at the top level or under 'metrics' etc.
|
| 545 |
-
# We’ll try a few keys before falling back to sums from sales_df.
|
| 546 |
def _get_num(d: dict, *keys, default=0.0):
|
| 547 |
for k in keys:
|
| 548 |
v = d.get(k)
|
|
@@ -558,10 +720,8 @@ class AdminAnalyticsEngine:
|
|
| 558 |
transactions = int(_get_num(dash, "transactions", "orders", default=0.0))
|
| 559 |
|
| 560 |
if (total_revenue == 0.0 or transactions == 0) and isinstance(sales_df, pd.DataFrame) and not sales_df.empty:
|
| 561 |
-
# Fallback: use the sales series we just fetched
|
| 562 |
total_revenue = float(sales_df["total_sales"].sum())
|
| 563 |
transactions = int(sales_df["total_orders"].sum())
|
| 564 |
-
# We don't have GP in this endpoint; leave as 0.0 if not provided by dashboard
|
| 565 |
|
| 566 |
product_lb = {
|
| 567 |
"top_by_revenue": prods.get("top_by_revenue") or prods.get("topRevenue") or [],
|
|
@@ -622,32 +782,21 @@ class AdminAnalyticsEngine:
|
|
| 622 |
return json_safe(snapshot)
|
| 623 |
|
| 624 |
def _temporal_patterns_from_sales(self, df: pd.DataFrame) -> Dict[str, Any]:
|
| 625 |
-
"""
|
| 626 |
-
Build simple temporal readouts from the sales series returned by _sales_series():
|
| 627 |
-
- series: [{date, total_sales, total_orders, aov}]
|
| 628 |
-
- best_day_by_sales: highest total_sales by weekday
|
| 629 |
-
"""
|
| 630 |
if df is None or df.empty:
|
| 631 |
return {"series": [], "best_day_by_sales": None}
|
| 632 |
-
|
| 633 |
d = df.copy()
|
| 634 |
-
# Derive DOW and safe date strings
|
| 635 |
d["dow"] = d["_date"].dt.day_name()
|
| 636 |
d["date"] = d["_date"].dt.strftime("%Y-%m-%d")
|
| 637 |
-
|
| 638 |
-
# Aggregate by DOW using total_sales as revenue proxy
|
| 639 |
g = d.groupby("dow", dropna=False).agg(
|
| 640 |
total_sales=("total_sales", "sum"),
|
| 641 |
total_orders=("total_orders", "sum"),
|
| 642 |
).reset_index()
|
| 643 |
-
|
| 644 |
best_row = None if g.empty else g.loc[g["total_sales"].idxmax()]
|
| 645 |
best_day = None if g.empty else {
|
| 646 |
"day": str(best_row["dow"]),
|
| 647 |
"total_sales": float(best_row["total_sales"]),
|
| 648 |
"total_orders": int(best_row["total_orders"]),
|
| 649 |
}
|
| 650 |
-
|
| 651 |
series = d[["date", "total_sales", "total_orders", "aov"]].to_dict(orient="records")
|
| 652 |
return {"series": series, "best_day_by_sales": best_day}
|
| 653 |
|
|
@@ -664,6 +813,7 @@ class AdminAnalyticsEngine:
|
|
| 664 |
return sanitize_answer(text)
|
| 665 |
except Exception:
|
| 666 |
return "### Business Snapshot\n\n```\n" + json.dumps(json_safe(snapshot), indent=2) + "\n```"
|
|
|
|
| 667 |
# -----------------------------------------------------------------------------
|
| 668 |
# /chat — PandasAI first on sales series, else deterministic snapshot + narration
|
| 669 |
# -----------------------------------------------------------------------------
|
|
@@ -686,15 +836,22 @@ def chat():
|
|
| 686 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 687 |
|
| 688 |
engine = AdminAnalyticsEngine(tenant_key, email, password, period)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
sales_df = engine._sales_series()
|
| 690 |
-
if sales_df.empty:
|
| 691 |
snapshot = engine.build_snapshot()
|
| 692 |
answer = engine.narrate(snapshot, user_question)
|
| 693 |
return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "analyst_fallback"}})
|
| 694 |
|
| 695 |
try:
|
| 696 |
logger.info(f"[{rid}] PandasAI attempt …")
|
| 697 |
-
|
|
|
|
|
|
|
| 698 |
"llm": llm,
|
| 699 |
"response_parser": FlaskResponse,
|
| 700 |
"security": "none",
|
|
@@ -726,7 +883,6 @@ def chat():
|
|
| 726 |
return jsonify({"answer": data_uri, "meta": {"source": "pandasai"}})
|
| 727 |
|
| 728 |
return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "pandasai"}})
|
| 729 |
-
|
| 730 |
except Exception:
|
| 731 |
snapshot = engine.build_snapshot()
|
| 732 |
answer = engine.narrate(snapshot, user_question)
|
|
@@ -747,8 +903,7 @@ def report():
|
|
| 747 |
payload = request.get_json() or {}
|
| 748 |
tenant_key = str(payload.get("tenant_key") or "admin")
|
| 749 |
period = (payload.get("period") or "week").strip().lower()
|
| 750 |
-
email = payload.get("email")
|
| 751 |
-
password = payload.get("password")
|
| 752 |
if not email or not password:
|
| 753 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 754 |
|
|
@@ -773,8 +928,7 @@ def marketing():
|
|
| 773 |
payload = request.get_json() or {}
|
| 774 |
tenant_key = str(payload.get("tenant_key") or "admin")
|
| 775 |
period = (payload.get("period") or "week").strip().lower()
|
| 776 |
-
email = payload.get("email")
|
| 777 |
-
password = payload.get("password")
|
| 778 |
if not email or not password:
|
| 779 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 780 |
|
|
@@ -799,8 +953,7 @@ def notify():
|
|
| 799 |
payload = request.get_json() or {}
|
| 800 |
tenant_key = str(payload.get("tenant_key") or "admin")
|
| 801 |
period = (payload.get("period") or "week").strip().lower()
|
| 802 |
-
email = payload.get("email")
|
| 803 |
-
password = payload.get("password")
|
| 804 |
if not email or not password:
|
| 805 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 806 |
|
|
@@ -871,7 +1024,6 @@ def get_call_briefing():
|
|
| 871 |
if not email or not password:
|
| 872 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 873 |
try:
|
| 874 |
-
# 1) Summarize call history
|
| 875 |
call_history = []
|
| 876 |
try:
|
| 877 |
transcripts = db_ref.child(f"transcripts/{profile_id}").get()
|
|
@@ -880,7 +1032,6 @@ def get_call_briefing():
|
|
| 880 |
logger.warning(f"Transcript fetch failed for '{profile_id}': {e}")
|
| 881 |
memory_summary = _synthesize_history_summary(call_history)
|
| 882 |
|
| 883 |
-
# 2) Admin holistic KPI snapshot
|
| 884 |
engine = AdminAnalyticsEngine(profile_id or "admin", email, password, period)
|
| 885 |
kpi_snapshot = engine.build_snapshot()
|
| 886 |
|
|
|
|
| 4 |
# - Deterministic time windows (Harare); explicit start/end on API calls
|
| 5 |
# - KPI engine never uses LLM for numbers (LLM is narration-only fallback)
|
| 6 |
# - JSON-safe snapshot; deep DEBUG logs (optional mirror to Firebase)
|
| 7 |
+
# - Drop-in Firebase + AI wiring identical in spirit to prior server
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import os, io, re, json, time, uuid, base64, logging
|
|
|
|
| 48 |
credentials_json_string = os.environ.get("FIREBASE")
|
| 49 |
if not credentials_json_string:
|
| 50 |
raise ValueError("FIREBASE env var is not set")
|
|
|
|
| 51 |
credentials_json = json.loads(credentials_json_string)
|
| 52 |
firebase_db_url = os.environ.get("Firebase_DB")
|
| 53 |
if not firebase_db_url:
|
|
|
|
| 111 |
# -----------------------------------------------------------------------------
|
| 112 |
# Admin API client (client-supplied credentials; holistic admin scope)
|
| 113 |
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
SC_BASE_URL = os.getenv("SC_BASE_URL", "https://delta-api.pricelyst.co.zw").rstrip("/")
|
| 115 |
|
| 116 |
class SCAuth:
|
| 117 |
"""Caches a requests.Session per admin email; supports bearer or cookie sessions."""
|
| 118 |
+
_cache: Dict[str, Dict[str, Any]] = {}
|
| 119 |
|
| 120 |
@classmethod
|
| 121 |
def invalidate(cls, email: str) -> None:
|
|
|
|
| 130 |
def _extract_token(cls, js: dict) -> Optional[str]:
|
| 131 |
if not isinstance(js, dict):
|
| 132 |
return None
|
|
|
|
| 133 |
candidates = [
|
| 134 |
js.get("token"),
|
| 135 |
js.get("access_token"),
|
|
|
|
| 144 |
return None
|
| 145 |
|
| 146 |
@classmethod
|
| 147 |
+
def login(cls, email: str, password: str) -> Dict[str, Any]:
|
| 148 |
s = requests.Session()
|
| 149 |
s.headers.update({"Accept": "application/json"})
|
| 150 |
url = f"{SC_BASE_URL}/api/auth/admin/login"
|
|
|
|
| 151 |
resp = s.post(url, json={"email": email, "password": password}, timeout=30)
|
| 152 |
|
| 153 |
+
body_text, body_json = "", {}
|
|
|
|
|
|
|
| 154 |
try:
|
| 155 |
body_json = resp.json() or {}
|
| 156 |
except Exception:
|
| 157 |
+
body_text = (resp.text or "")[:800]
|
|
|
|
| 158 |
token = cls._extract_token(body_json)
|
| 159 |
|
| 160 |
if token:
|
| 161 |
s.headers.update({"Authorization": f"Bearer {token}"})
|
| 162 |
entry = {"session": s, "auth": "bearer", "token": token}
|
| 163 |
cls._cache[email] = entry
|
| 164 |
+
logger.debug("Admin login (bearer) OK")
|
| 165 |
return entry
|
| 166 |
|
|
|
|
| 167 |
if resp.cookies and (resp.status_code // 100) == 2:
|
| 168 |
entry = {"session": s, "auth": "cookie"}
|
| 169 |
cls._cache[email] = entry
|
| 170 |
+
logger.debug("Admin login (cookie) OK")
|
| 171 |
return entry
|
| 172 |
|
|
|
|
| 173 |
snippet = body_text or (str(body_json)[:800])
|
| 174 |
raise RuntimeError(f"Login did not return a token or cookie session. HTTP {resp.status_code}. Body≈ {snippet}")
|
| 175 |
|
| 176 |
def sc_request(method: str, path: str, email: str, password: str, *,
|
| 177 |
params: dict = None, json_body: dict = None, timeout: int = 30):
|
| 178 |
+
"""Authenticated request with 401 auto-refresh (once). Logs a compact sample on success."""
|
| 179 |
if not path.startswith("/"):
|
| 180 |
path = "/" + path
|
| 181 |
url = f"{SC_BASE_URL}{path}"
|
|
|
|
| 183 |
def _do(s: requests.Session):
|
| 184 |
return s.request(method.upper(), url, params=params, json=json_body, timeout=timeout)
|
| 185 |
|
|
|
|
| 186 |
entry = SCAuth._cache.get(email)
|
| 187 |
if not entry:
|
| 188 |
entry = SCAuth.login(email, password)
|
|
|
|
| 190 |
|
| 191 |
resp = _do(s)
|
| 192 |
if resp.status_code == 401:
|
|
|
|
| 193 |
SCAuth.invalidate(email)
|
| 194 |
entry = SCAuth.login(email, password)
|
| 195 |
s = entry["session"]
|
| 196 |
resp = _do(s)
|
| 197 |
|
|
|
|
| 198 |
try:
|
| 199 |
resp.raise_for_status()
|
| 200 |
except Exception as e:
|
|
|
|
| 201 |
snippet = (getattr(resp, "text", "") or "")[:800]
|
| 202 |
raise RuntimeError(f"SC request error {method.upper()} {path}: HTTP {resp.status_code} – {snippet}") from e
|
| 203 |
|
| 204 |
+
payload: Any
|
| 205 |
try:
|
| 206 |
+
payload = resp.json()
|
| 207 |
except Exception:
|
| 208 |
+
payload = resp.text
|
| 209 |
+
|
| 210 |
+
# ---- Compact sample logging for every endpoint ----
|
| 211 |
+
sample = None
|
| 212 |
+
if isinstance(payload, dict):
|
| 213 |
+
d = payload.get("data", payload)
|
| 214 |
+
if isinstance(d, dict):
|
| 215 |
+
# try common array keys
|
| 216 |
+
for key in ("sales_over_time", "orders", "transactions", "items", "list", "rows", "data"):
|
| 217 |
+
v = d.get(key)
|
| 218 |
+
if isinstance(v, list) and v:
|
| 219 |
+
sample = {key: v[:2]} # first 2 rows
|
| 220 |
+
break
|
| 221 |
+
if sample is None:
|
| 222 |
+
# fallback: first 10 keys
|
| 223 |
+
sample = {k: ("[list]" if isinstance(v, list) else v) for k, v in list(d.items())[:10]}
|
| 224 |
+
elif isinstance(d, list):
|
| 225 |
+
sample = d[:2]
|
| 226 |
+
elif isinstance(payload, list):
|
| 227 |
+
sample = payload[:2]
|
| 228 |
+
else:
|
| 229 |
+
sample = str(payload)[:300]
|
| 230 |
+
|
| 231 |
+
logger.debug("SAMPLE %s %s -> %s", method.upper(), path, json.dumps(sample, default=str))
|
| 232 |
+
return payload
|
| 233 |
+
|
| 234 |
# -----------------------------------------------------------------------------
|
| 235 |
+
# Timezone & temporal helpers
|
| 236 |
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
TZ = os.getenv("APP_TZ", "Africa/Harare")
|
| 238 |
+
_TZ = TZ # backward-compatible alias
|
|
|
|
|
|
|
| 239 |
|
| 240 |
def now_harare() -> pd.Timestamp:
|
|
|
|
| 241 |
return pd.Timestamp.now(tz=TZ)
|
| 242 |
|
| 243 |
+
def week_bounds_from(ts: pd.Timestamp) -> Tuple[pd.Timestamp, pd.Timestamp]:
|
|
|
|
| 244 |
monday = ts.tz_convert(TZ).normalize() - pd.Timedelta(days=ts.weekday())
|
| 245 |
sunday = monday + pd.Timedelta(days=6, hours=23, minutes=59, seconds=59)
|
| 246 |
return monday, sunday
|
| 247 |
|
| 248 |
+
def this_month_bounds(ts: pd.Timestamp) -> Tuple[pd.Timestamp, pd.Timestamp]:
|
|
|
|
| 249 |
first_this = ts.normalize().replace(day=1)
|
| 250 |
+
if first_this.month == 12:
|
| 251 |
+
first_next = first_this.replace(year=first_this.year + 1, month=1)
|
| 252 |
+
else:
|
| 253 |
+
first_next = first_this.replace(month=first_this.month + 1)
|
| 254 |
+
last_this = first_next - pd.Timedelta(seconds=1)
|
| 255 |
return first_this, last_this
|
|
|
|
| 256 |
|
| 257 |
def period_to_bounds(period: str) -> Tuple[pd.Timestamp, pd.Timestamp, str]:
|
| 258 |
p = (period or "week").strip().lower()
|
| 259 |
now = now_harare()
|
| 260 |
if p == "today":
|
| 261 |
start = now.normalize()
|
| 262 |
+
end = start + pd.Timedelta(hours=23, minutes=59, seconds=59); lbl = "Today"
|
|
|
|
| 263 |
elif p in ("week", "this_week"):
|
| 264 |
+
start, end = week_bounds_from(now); lbl = "This Week"
|
|
|
|
| 265 |
elif p in ("month", "this_month"):
|
| 266 |
+
start, end = this_month_bounds(now); lbl = "This Month"
|
|
|
|
| 267 |
elif p in ("year", "this_year"):
|
| 268 |
start = now.normalize().replace(month=1, day=1, hour=0, minute=0, second=0)
|
| 269 |
+
end = now.normalize().replace(month=12, day=31, hour=23, minute=59, second=59); lbl = "This Year"
|
|
|
|
| 270 |
else:
|
| 271 |
start, end = week_bounds_from(now); lbl = "This Week"
|
| 272 |
return start, end, lbl
|
|
|
|
| 331 |
if tb in s: s = s.split(tb, 1)[0]
|
| 332 |
return (s or "").strip()
|
| 333 |
|
| 334 |
+
# -----------------------------------------------------------------------------
|
| 335 |
+
# Robust normalizers
|
| 336 |
+
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
def _to_list(x: Any) -> List[Any]:
|
| 338 |
+
if x is None: return []
|
| 339 |
+
if isinstance(x, list): return x
|
| 340 |
+
if isinstance(x, dict): return [x]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
if isinstance(x, str):
|
|
|
|
| 342 |
try:
|
| 343 |
j = json.loads(x)
|
| 344 |
+
if isinstance(j, list): return j
|
| 345 |
+
if isinstance(j, dict): return [j]
|
|
|
|
|
|
|
| 346 |
except Exception:
|
| 347 |
+
return [x]
|
| 348 |
return [x]
|
| 349 |
|
| 350 |
def _to_float(x: Any) -> Optional[float]:
|
|
|
|
| 363 |
return None
|
| 364 |
|
| 365 |
def _coerce_date(s: Any) -> Optional[pd.Timestamp]:
|
| 366 |
+
if s is None: return None
|
|
|
|
| 367 |
try:
|
| 368 |
dt = pd.to_datetime(s, errors="coerce")
|
| 369 |
+
if pd.isna(dt): return None
|
|
|
|
|
|
|
| 370 |
try:
|
| 371 |
return dt.tz_localize(TZ, nonexistent="shift_forward", ambiguous="NaT")
|
| 372 |
except Exception:
|
|
|
|
| 374 |
except Exception:
|
| 375 |
return None
|
| 376 |
|
| 377 |
+
# -----------------------------------------------------------------------------
|
| 378 |
+
# Admin raw transactions extractor (row-level for PandasAI) + sample logging
|
| 379 |
+
# -----------------------------------------------------------------------------
|
| 380 |
+
def _paginate(sc_get, email, password, path, params=None, page_param="page", per_page=200, max_pages=50):
|
| 381 |
+
"""Generic paginator for endpoints with page/per_page/meta"""
|
| 382 |
+
params = dict(params or {})
|
| 383 |
+
params.setdefault(page_param, 1)
|
| 384 |
+
params.setdefault("per_page", per_page)
|
| 385 |
+
page = 1
|
| 386 |
+
for _ in range(max_pages):
|
| 387 |
+
params[page_param] = page
|
| 388 |
+
raw = sc_get("GET", path, email, password, params=params)
|
| 389 |
+
yield raw
|
| 390 |
+
try:
|
| 391 |
+
meta = (raw or {}).get("meta") or {}
|
| 392 |
+
last_page = int(meta.get("last_page") or 0)
|
| 393 |
+
cur = int(meta.get("current_page") or page)
|
| 394 |
+
if last_page and cur >= last_page:
|
| 395 |
+
break
|
| 396 |
+
if not last_page and not raw:
|
| 397 |
+
break
|
| 398 |
+
except Exception:
|
| 399 |
+
break
|
| 400 |
+
page += 1
|
| 401 |
+
|
| 402 |
+
def _normalize_line(order, item, tz=TZ) -> dict:
|
| 403 |
+
g = lambda o, *ks, default=None: next((o[k] for k in ks if isinstance(o, dict) and k in o), default)
|
| 404 |
+
to_f = lambda x: _to_float(x) or 0.0
|
| 405 |
+
to_i = lambda x: _to_int(x) or 0
|
| 406 |
+
|
| 407 |
+
order_id = g(order, "id", "order_id", "uuid", "reference")
|
| 408 |
+
created_at = g(order, "created_at", "date", "ordered_at", "timestamp")
|
| 409 |
+
customer = g(order, "customer_name", "customer", "buyer_name", "customer_reference")
|
| 410 |
+
payment = g(order, "payment_method", "payment", "money_type")
|
| 411 |
+
branch = g(order, "shop_name", "shop", "branch", "store")
|
| 412 |
+
status = g(order, "status")
|
| 413 |
+
currency = g(order, "currency")
|
| 414 |
+
|
| 415 |
+
prod_id = g(item, "product_id", "item_id", "sku_id", "id")
|
| 416 |
+
prod_name = g(item, "product_name", "name", "title", "sku")
|
| 417 |
+
qty = to_i(g(item, "quantity", "qty", "units"))
|
| 418 |
+
unit_price = to_f(g(item, "unit_price", "price", "unitPrice"))
|
| 419 |
+
line_total = to_f(g(item, "line_total", "total", "amount", "revenue"))
|
| 420 |
+
cost_price = _to_float(g(item, "unit_cost", "cost_price", "cost")) # optional
|
| 421 |
+
|
| 422 |
+
dt = _coerce_date(created_at)
|
| 423 |
+
revenue = line_total if line_total else (qty * unit_price)
|
| 424 |
+
gp = None
|
| 425 |
+
if cost_price is not None:
|
| 426 |
+
gp = float(revenue - qty * (cost_price or 0.0))
|
| 427 |
+
|
| 428 |
+
return {
|
| 429 |
+
"order_id": order_id,
|
| 430 |
+
"datetime": dt,
|
| 431 |
+
"date": dt.tz_convert(tz).date().isoformat() if dt is not None else None,
|
| 432 |
+
"customer": customer,
|
| 433 |
+
"payment_method": payment,
|
| 434 |
+
"branch": branch,
|
| 435 |
+
"status": status,
|
| 436 |
+
"currency": currency,
|
| 437 |
+
"product_id": prod_id,
|
| 438 |
+
"product": prod_name,
|
| 439 |
+
"quantity": qty,
|
| 440 |
+
"unit_price": unit_price,
|
| 441 |
+
"line_total": revenue,
|
| 442 |
+
"unit_cost": float(cost_price) if cost_price is not None else None,
|
| 443 |
+
"gross_profit": float(gp) if gp is not None else None,
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
def fetch_transactions_df(email: str, password: str, t_start: pd.Timestamp, t_end: pd.Timestamp) -> pd.DataFrame:
|
| 447 |
+
"""
|
| 448 |
+
Pull row-level order lines. Tries multiple likely endpoints, logs a sample for each,
|
| 449 |
+
flattens nested items, returns a clean DataFrame suitable for PandasAI.
|
| 450 |
+
"""
|
| 451 |
+
CANDIDATES: Tuple[Tuple[str, str, str], ...] = (
|
| 452 |
+
("/api/analytics/orders", "orders", "items"),
|
| 453 |
+
("/api/orders", "data", "items"), # many APIs wrap orders under "data"
|
| 454 |
+
("/api/analytics/transactions", "transactions", "items"),
|
| 455 |
+
("/api/sales/transactions", "transactions", "lines"),
|
| 456 |
+
)
|
| 457 |
+
params = {
|
| 458 |
+
"start_date": t_start.strftime("%Y-%m-%d"),
|
| 459 |
+
"end_date": t_end.strftime("%Y-%m-%d"),
|
| 460 |
+
"include": "items",
|
| 461 |
+
"per_page": 200,
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
rows: List[dict] = []
|
| 465 |
+
for path, orders_key, items_key in CANDIDATES:
|
| 466 |
+
try:
|
| 467 |
+
# Non-paginated attempt
|
| 468 |
+
raw = sc_request("GET", path, email, password, params=params)
|
| 469 |
+
# Log a sharper sample for this endpoint (top-level)
|
| 470 |
+
logger.debug("TXN_PROBE_RAW %s -> keys=%s", path, list(raw.keys())[:10] if isinstance(raw, dict) else type(raw))
|
| 471 |
+
|
| 472 |
+
payload = raw.get("data") if isinstance(raw, dict) and isinstance(raw.get("data"), (dict, list)) else raw
|
| 473 |
+
orders = payload.get(orders_key) if isinstance(payload, dict) else payload
|
| 474 |
+
if orders:
|
| 475 |
+
orders_list = _to_list(orders)
|
| 476 |
+
if orders_list:
|
| 477 |
+
# sample one order + items
|
| 478 |
+
o0 = orders_list[0] if isinstance(orders_list[0], dict) else {}
|
| 479 |
+
i0 = _to_list((o0 or {}).get(items_key))
|
| 480 |
+
logger.debug("TXN_SAMPLE %s -> order_keys=%s; first_item_keys=%s",
|
| 481 |
+
path,
|
| 482 |
+
list(o0.keys())[:15] if isinstance(o0, dict) else type(o0),
|
| 483 |
+
(list(i0[0].keys())[:15] if i0 and isinstance(i0[0], dict) else "N/A"))
|
| 484 |
+
for o in orders_list:
|
| 485 |
+
for it in _to_list((o or {}).get(items_key)):
|
| 486 |
+
if isinstance(o, dict) and isinstance(it, dict):
|
| 487 |
+
rows.append(_normalize_line(o, it))
|
| 488 |
+
if rows:
|
| 489 |
+
break
|
| 490 |
+
|
| 491 |
+
# Try paginated shape
|
| 492 |
+
collected = 0
|
| 493 |
+
for page_raw in _paginate(sc_request, email, password, path, params=params):
|
| 494 |
+
logger.debug("TXN_PAGE %s meta=%s", path, (page_raw or {}).get("meta") if isinstance(page_raw, dict) else "N/A")
|
| 495 |
+
page_data = page_raw.get("data") if isinstance(page_raw, dict) and isinstance(page_raw.get("data"), (dict, list)) else page_raw
|
| 496 |
+
page_orders = page_data.get(orders_key) if isinstance(page_data, dict) else page_data
|
| 497 |
+
for o in _to_list(page_orders):
|
| 498 |
+
for it in _to_list((o or {}).get(items_key)):
|
| 499 |
+
if isinstance(o, dict) and isinstance(it, dict):
|
| 500 |
+
rows.append(_normalize_line(o, it))
|
| 501 |
+
collected += 1
|
| 502 |
+
if collected and collected >= 5000: # safety cap
|
| 503 |
+
break
|
| 504 |
+
if rows:
|
| 505 |
+
# Log a compact sample of flattened rows
|
| 506 |
+
logger.debug("TXN_FLAT_SAMPLE %s -> %s", path, json.dumps(rows[:2], default=str))
|
| 507 |
+
break
|
| 508 |
+
except Exception as e:
|
| 509 |
+
logger.debug(f"fetch_transactions_df: {path} probe failed: {e}")
|
| 510 |
+
|
| 511 |
+
if not rows:
|
| 512 |
+
logger.warning("No row-level endpoint found; returning an empty transactions frame (schema only).")
|
| 513 |
+
schema = {
|
| 514 |
+
"datetime": pd.Series(dtype="datetime64[ns]"),
|
| 515 |
+
"date": pd.Series(dtype="object"),
|
| 516 |
+
"order_id": pd.Series(dtype="object"),
|
| 517 |
+
"status": pd.Series(dtype="object"),
|
| 518 |
+
"customer": pd.Series(dtype="object"),
|
| 519 |
+
"branch": pd.Series(dtype="object"),
|
| 520 |
+
"payment_method": pd.Series(dtype="object"),
|
| 521 |
+
"currency": pd.Series(dtype="object"),
|
| 522 |
+
"product_id": pd.Series(dtype="object"),
|
| 523 |
+
"product": pd.Series(dtype="object"),
|
| 524 |
+
"quantity": pd.Series(dtype="float"),
|
| 525 |
+
"unit_price": pd.Series(dtype="float"),
|
| 526 |
+
"line_total": pd.Series(dtype="float"),
|
| 527 |
+
"unit_cost": pd.Series(dtype="float"),
|
| 528 |
+
"gross_profit": pd.Series(dtype="float"),
|
| 529 |
+
}
|
| 530 |
+
return pd.DataFrame(schema)
|
| 531 |
+
|
| 532 |
+
df = pd.DataFrame(rows)
|
| 533 |
+
df["datetime"] = pd.to_datetime(df["datetime"], errors="coerce")
|
| 534 |
+
try:
|
| 535 |
+
# Keep tz-naive for some plotting libs but deterministic in Harare
|
| 536 |
+
df["datetime"] = df["datetime"].dt.tz_convert(TZ).dt.tz_localize(None)
|
| 537 |
+
except Exception:
|
| 538 |
+
pass
|
| 539 |
+
|
| 540 |
+
for c in ("quantity", "unit_price", "line_total", "unit_cost", "gross_profit"):
|
| 541 |
+
if c in df.columns:
|
| 542 |
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
| 543 |
+
|
| 544 |
+
cols = [
|
| 545 |
+
"datetime", "date", "order_id", "status", "customer", "branch",
|
| 546 |
+
"payment_method", "currency", "product_id", "product",
|
| 547 |
+
"quantity", "unit_price", "line_total", "unit_cost", "gross_profit",
|
| 548 |
+
]
|
| 549 |
+
df = df[[c for c in cols if c in df.columns]]
|
| 550 |
+
|
| 551 |
+
logger.debug("TXN_DF_COLUMNS %s", df.columns.tolist())
|
| 552 |
+
logger.debug("TXN_DF_HEAD %s", json.dumps(df.head(3).to_dict(orient="records"), default=str))
|
| 553 |
+
return df
|
| 554 |
|
| 555 |
# -----------------------------------------------------------------------------
|
| 556 |
+
# Admin KPI Engine (holistic view) — logs sample after each endpoint
|
| 557 |
# -----------------------------------------------------------------------------
|
| 558 |
class AdminAnalyticsEngine:
|
| 559 |
"""Single-tenant holistic admin analytics. No shop/brand filters; admin sees entire dataset."""
|
|
|
|
| 564 |
self.period = (period or "week").lower().strip()
|
| 565 |
self.t_start, self.t_end, self.period_label = period_to_bounds(self.period)
|
| 566 |
|
|
|
|
| 567 |
@staticmethod
|
| 568 |
def _unwrap_data(payload: dict) -> dict:
|
| 569 |
if isinstance(payload, dict):
|
|
|
|
| 570 |
return payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
| 571 |
return {}
|
| 572 |
|
|
|
|
| 573 |
def _dashboard(self) -> dict:
|
| 574 |
+
raw = sc_request("GET", "/api/analytics/dashboard", self.email, self.password, params={"period": self.period})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
data = self._unwrap_data(raw)
|
| 576 |
emit_kpi_debug(self.tenant_key, "dashboard", data or raw or {})
|
| 577 |
+
# Log a friendly sample view:
|
| 578 |
+
logger.debug("SAMPLE /api/analytics/dashboard -> %s", json.dumps({k: data.get(k) for k in list(data.keys())[:10]}, default=str))
|
| 579 |
return data or {}
|
| 580 |
|
| 581 |
def _sales_series(self) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
params = {
|
| 583 |
"start_date": self.t_start.strftime("%Y-%m-%d"),
|
| 584 |
"end_date": self.t_end.strftime("%Y-%m-%d"),
|
| 585 |
"group_by": "day",
|
| 586 |
}
|
| 587 |
raw = sc_request("GET", "/api/analytics/sales", self.email, self.password, params=params)
|
|
|
|
|
|
|
|
|
|
| 588 |
data = {}
|
| 589 |
if isinstance(raw, dict):
|
| 590 |
data = (raw.get("data") or raw) if isinstance(raw.get("data"), (dict, list)) else raw
|
|
|
|
| 595 |
except Exception:
|
| 596 |
data = {}
|
| 597 |
|
| 598 |
+
# log samples from top-level keys we expect
|
| 599 |
+
try:
|
| 600 |
+
so = data.get("sales_over_time")
|
| 601 |
+
pm = data.get("sales_by_payment_method")
|
| 602 |
+
cat = data.get("sales_by_category")
|
| 603 |
+
logger.debug("SAMPLE /api/analytics/sales -> sales_over_time[:2]=%s; sales_by_payment_method[:2]=%s; sales_by_category[:2]=%s",
|
| 604 |
+
json.dumps((so or [])[:2]), json.dumps((pm or [])[:2]), json.dumps((cat or [])[:2]))
|
| 605 |
+
except Exception:
|
| 606 |
+
pass
|
| 607 |
+
|
| 608 |
series = []
|
| 609 |
+
for r in _to_list(data.get("sales_over_time")):
|
|
|
|
| 610 |
if not isinstance(r, dict):
|
| 611 |
continue
|
| 612 |
date_str = r.get("date") or r.get("day") or r.get("period")
|
| 613 |
dt = _coerce_date(date_str)
|
| 614 |
if dt is None:
|
| 615 |
continue
|
|
|
|
| 616 |
total_sales = _to_float(r.get("total_sales") or r.get("total") or r.get("revenue"))
|
| 617 |
total_orders = _to_int(r.get("total_orders") or r.get("orders") or r.get("count"))
|
| 618 |
aov = _to_float(r.get("average_order_value") or r.get("aov"))
|
|
|
|
| 619 |
if aov is None and total_sales is not None and (total_orders or 0) > 0:
|
| 620 |
aov = float(total_sales) / int(total_orders)
|
|
|
|
| 621 |
series.append({
|
| 622 |
"_date": dt,
|
| 623 |
"total_sales": float(total_sales) if total_sales is not None else 0.0,
|
| 624 |
"total_orders": int(total_orders) if total_orders is not None else 0,
|
| 625 |
"aov": float(aov) if aov is not None else None,
|
| 626 |
})
|
|
|
|
| 627 |
df = pd.DataFrame(series)
|
| 628 |
if df.empty:
|
| 629 |
return pd.DataFrame(columns=["_date", "total_sales", "total_orders", "aov"])
|
|
|
|
| 630 |
df = df.sort_values("_date").reset_index(drop=True)
|
| 631 |
emit_kpi_debug(self.tenant_key, "sales_series_raw", (raw if isinstance(raw, dict) else {"raw": raw}))
|
| 632 |
+
logger.debug("SAMPLE sales_series_df.head -> %s", json.dumps(df.head(3).to_dict(orient="records"), default=str))
|
| 633 |
+
return df
|
| 634 |
+
|
| 635 |
+
def transactions_df(self) -> pd.DataFrame:
|
| 636 |
+
df = fetch_transactions_df(self.email, self.password, self.t_start, self.t_end)
|
| 637 |
+
emit_kpi_debug(self.tenant_key, "transactions_df_meta", {
|
| 638 |
+
"rows": int(len(df)),
|
| 639 |
+
"cols": list(df.columns),
|
| 640 |
+
"period": {"start": self.t_start.isoformat(), "end": self.t_end.isoformat()}
|
| 641 |
+
})
|
| 642 |
+
# already logged columns + head in fetch_transactions_df()
|
| 643 |
return df
|
| 644 |
|
| 645 |
def _products(self) -> dict:
|
| 646 |
raw = sc_request(
|
| 647 |
+
"GET", "/api/analytics/products", self.email, self.password,
|
| 648 |
+
params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")}
|
|
|
|
|
|
|
|
|
|
| 649 |
)
|
| 650 |
data = self._unwrap_data(raw)
|
| 651 |
emit_kpi_debug(self.tenant_key, "products", data or raw or {})
|
| 652 |
+
# log sample leaderboards if present
|
| 653 |
+
keys = ["top_by_revenue","top_by_units","top_by_margin_value","top_by_margin_pct","bottom_by_revenue","loss_makers"]
|
| 654 |
+
sample = {k: (data.get(k) or [])[:2] for k in keys if isinstance(data.get(k), list)}
|
| 655 |
+
logger.debug("SAMPLE /api/analytics/products -> %s", json.dumps(sample))
|
| 656 |
return data or {}
|
| 657 |
|
| 658 |
def _customers(self) -> dict:
|
| 659 |
raw = sc_request(
|
| 660 |
+
"GET", "/api/analytics/customers", self.email, self.password,
|
| 661 |
+
params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")}
|
|
|
|
|
|
|
|
|
|
| 662 |
)
|
| 663 |
data = self._unwrap_data(raw)
|
| 664 |
emit_kpi_debug(self.tenant_key, "customers", data or raw or {})
|
| 665 |
+
# sample common shapes
|
| 666 |
+
sample = {
|
| 667 |
+
"top_customers_by_gp": (data.get("top_customers_by_gp") or [])[:2],
|
| 668 |
+
"at_risk": (data.get("at_risk") or [])[:2],
|
| 669 |
+
"new_customers": (data.get("new_customers") or [])[:2],
|
| 670 |
+
"summary": data.get("summary"),
|
| 671 |
+
}
|
| 672 |
+
logger.debug("SAMPLE /api/analytics/customers -> %s", json.dumps(sample))
|
| 673 |
return data or {}
|
| 674 |
|
| 675 |
def _inventory(self) -> dict:
|
| 676 |
raw = sc_request("GET", "/api/analytics/inventory", self.email, self.password)
|
| 677 |
data = self._unwrap_data(raw)
|
| 678 |
emit_kpi_debug(self.tenant_key, "inventory", data or raw or {})
|
| 679 |
+
try:
|
| 680 |
+
items = data.get("products") or data.get("items") or data.get("snapshot") or []
|
| 681 |
+
logger.debug("SAMPLE /api/analytics/inventory -> %s", json.dumps((items or [])[:2], default=str))
|
| 682 |
+
except Exception:
|
| 683 |
+
pass
|
| 684 |
return data or {}
|
| 685 |
|
| 686 |
def _comparisons(self) -> dict:
|
| 687 |
raw = sc_request(
|
| 688 |
+
"GET", "/api/analytics/comparisons", self.email, self.password,
|
| 689 |
+
params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")}
|
|
|
|
|
|
|
|
|
|
| 690 |
)
|
| 691 |
data = self._unwrap_data(raw)
|
| 692 |
emit_kpi_debug(self.tenant_key, "comparisons", data or raw or {})
|
| 693 |
+
try:
|
| 694 |
+
logger.debug("SAMPLE /api/analytics/comparisons -> keys=%s", list(data.keys())[:15])
|
| 695 |
+
except Exception:
|
| 696 |
+
pass
|
| 697 |
return data or {}
|
| 698 |
|
| 699 |
# -------------------- deterministic snapshot --------------------
|
|
|
|
| 705 |
inv = self._inventory()
|
| 706 |
comps = self._comparisons()
|
| 707 |
|
|
|
|
|
|
|
| 708 |
def _get_num(d: dict, *keys, default=0.0):
|
| 709 |
for k in keys:
|
| 710 |
v = d.get(k)
|
|
|
|
| 720 |
transactions = int(_get_num(dash, "transactions", "orders", default=0.0))
|
| 721 |
|
| 722 |
if (total_revenue == 0.0 or transactions == 0) and isinstance(sales_df, pd.DataFrame) and not sales_df.empty:
|
|
|
|
| 723 |
total_revenue = float(sales_df["total_sales"].sum())
|
| 724 |
transactions = int(sales_df["total_orders"].sum())
|
|
|
|
| 725 |
|
| 726 |
product_lb = {
|
| 727 |
"top_by_revenue": prods.get("top_by_revenue") or prods.get("topRevenue") or [],
|
|
|
|
| 782 |
return json_safe(snapshot)
|
| 783 |
|
| 784 |
def _temporal_patterns_from_sales(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
if df is None or df.empty:
|
| 786 |
return {"series": [], "best_day_by_sales": None}
|
|
|
|
| 787 |
d = df.copy()
|
|
|
|
| 788 |
d["dow"] = d["_date"].dt.day_name()
|
| 789 |
d["date"] = d["_date"].dt.strftime("%Y-%m-%d")
|
|
|
|
|
|
|
| 790 |
g = d.groupby("dow", dropna=False).agg(
|
| 791 |
total_sales=("total_sales", "sum"),
|
| 792 |
total_orders=("total_orders", "sum"),
|
| 793 |
).reset_index()
|
|
|
|
| 794 |
best_row = None if g.empty else g.loc[g["total_sales"].idxmax()]
|
| 795 |
best_day = None if g.empty else {
|
| 796 |
"day": str(best_row["dow"]),
|
| 797 |
"total_sales": float(best_row["total_sales"]),
|
| 798 |
"total_orders": int(best_row["total_orders"]),
|
| 799 |
}
|
|
|
|
| 800 |
series = d[["date", "total_sales", "total_orders", "aov"]].to_dict(orient="records")
|
| 801 |
return {"series": series, "best_day_by_sales": best_day}
|
| 802 |
|
|
|
|
| 813 |
return sanitize_answer(text)
|
| 814 |
except Exception:
|
| 815 |
return "### Business Snapshot\n\n```\n" + json.dumps(json_safe(snapshot), indent=2) + "\n```"
|
| 816 |
+
|
| 817 |
# -----------------------------------------------------------------------------
|
| 818 |
# /chat — PandasAI first on sales series, else deterministic snapshot + narration
|
| 819 |
# -----------------------------------------------------------------------------
|
|
|
|
| 836 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 837 |
|
| 838 |
engine = AdminAnalyticsEngine(tenant_key, email, password, period)
|
| 839 |
+
|
| 840 |
+
# Build transactions_df now and place it in meta logs (useful for PandasAI later)
|
| 841 |
+
tdf = engine.transactions_df()
|
| 842 |
+
|
| 843 |
+
# For simple Q&A we still start with sales_df (fast + stable)
|
| 844 |
sales_df = engine._sales_series()
|
| 845 |
+
if sales_df.empty and tdf.empty:
|
| 846 |
snapshot = engine.build_snapshot()
|
| 847 |
answer = engine.narrate(snapshot, user_question)
|
| 848 |
return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "analyst_fallback"}})
|
| 849 |
|
| 850 |
try:
|
| 851 |
logger.info(f"[{rid}] PandasAI attempt …")
|
| 852 |
+
# If the question references products/items explicitly, switch to transactions_df
|
| 853 |
+
use_df = tdf if re.search(r"\b(product|sku|item|category|top\s*5|top\s*ten|by\s*revenue|by\s*units)\b", user_question, re.I) and not tdf.empty else sales_df
|
| 854 |
+
pandas_agent = SmartDataframe(use_df, config={
|
| 855 |
"llm": llm,
|
| 856 |
"response_parser": FlaskResponse,
|
| 857 |
"security": "none",
|
|
|
|
| 883 |
return jsonify({"answer": data_uri, "meta": {"source": "pandasai"}})
|
| 884 |
|
| 885 |
return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "pandasai"}})
|
|
|
|
| 886 |
except Exception:
|
| 887 |
snapshot = engine.build_snapshot()
|
| 888 |
answer = engine.narrate(snapshot, user_question)
|
|
|
|
| 903 |
payload = request.get_json() or {}
|
| 904 |
tenant_key = str(payload.get("tenant_key") or "admin")
|
| 905 |
period = (payload.get("period") or "week").strip().lower()
|
| 906 |
+
email = payload.get("email"); password = payload.get("password")
|
|
|
|
| 907 |
if not email or not password:
|
| 908 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 909 |
|
|
|
|
| 928 |
payload = request.get_json() or {}
|
| 929 |
tenant_key = str(payload.get("tenant_key") or "admin")
|
| 930 |
period = (payload.get("period") or "week").strip().lower()
|
| 931 |
+
email = payload.get("email"); password = payload.get("password")
|
|
|
|
| 932 |
if not email or not password:
|
| 933 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 934 |
|
|
|
|
| 953 |
payload = request.get_json() or {}
|
| 954 |
tenant_key = str(payload.get("tenant_key") or "admin")
|
| 955 |
period = (payload.get("period") or "week").strip().lower()
|
| 956 |
+
email = payload.get("email"); password = payload.get("password")
|
|
|
|
| 957 |
if not email or not password:
|
| 958 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 959 |
|
|
|
|
| 1024 |
if not email or not password:
|
| 1025 |
return jsonify({"error": "Missing 'email' or 'password'."}), 400
|
| 1026 |
try:
|
|
|
|
| 1027 |
call_history = []
|
| 1028 |
try:
|
| 1029 |
transcripts = db_ref.child(f"transcripts/{profile_id}").get()
|
|
|
|
| 1032 |
logger.warning(f"Transcript fetch failed for '{profile_id}': {e}")
|
| 1033 |
memory_summary = _synthesize_history_summary(call_history)
|
| 1034 |
|
|
|
|
| 1035 |
engine = AdminAnalyticsEngine(profile_id or "admin", email, password, period)
|
| 1036 |
kpi_snapshot = engine.build_snapshot()
|
| 1037 |
|