Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -338,67 +338,198 @@ def sanitize_answer(ans) -> str:
|
|
| 338 |
if tb in s: s = s.split(tb, 1)[0]
|
| 339 |
return (s or "").strip()
|
| 340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
# -----------------------------------------------------------------------------
|
| 342 |
# Admin KPI Engine (holistic view)
|
| 343 |
# -----------------------------------------------------------------------------
|
| 344 |
class AdminAnalyticsEngine:
|
| 345 |
"""Single-tenant holistic admin analytics. No shop/brand filters; admin sees entire dataset."""
|
| 346 |
def __init__(self, tenant_key: str, email: str, password: str, period: str = "week"):
|
| 347 |
-
self.tenant_key = tenant_key or "admin"
|
| 348 |
-
self.email = email
|
| 349 |
-
self.password = password
|
| 350 |
self.period = (period or "week").lower().strip()
|
| 351 |
self.t_start, self.t_end, self.period_label = period_to_bounds(self.period)
|
| 352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
# -------------------- API pulls (no shop/brand params at all) --------------------
|
| 354 |
def _dashboard(self) -> dict:
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
return data or {}
|
| 358 |
|
| 359 |
def _sales_series(self) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
params = {
|
| 361 |
"start_date": self.t_start.strftime("%Y-%m-%d"),
|
| 362 |
"end_date": self.t_end.strftime("%Y-%m-%d"),
|
| 363 |
-
"group_by": "day"
|
| 364 |
}
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
})
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
return df
|
| 380 |
|
| 381 |
def _products(self) -> dict:
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
return data or {}
|
| 386 |
|
| 387 |
def _customers(self) -> dict:
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
return data or {}
|
| 392 |
|
| 393 |
def _inventory(self) -> dict:
|
| 394 |
-
|
| 395 |
-
|
|
|
|
| 396 |
return data or {}
|
| 397 |
|
| 398 |
def _comparisons(self) -> dict:
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
return data or {}
|
| 403 |
|
| 404 |
# -------------------- deterministic snapshot --------------------
|
|
@@ -410,14 +541,27 @@ class AdminAnalyticsEngine:
|
|
| 410 |
inv = self._inventory()
|
| 411 |
comps = self._comparisons()
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
|
| 422 |
product_lb = {
|
| 423 |
"top_by_revenue": prods.get("top_by_revenue") or prods.get("topRevenue") or [],
|
|
@@ -435,7 +579,7 @@ class AdminAnalyticsEngine:
|
|
| 435 |
"new_customers": custs.get("new_customers", []),
|
| 436 |
},
|
| 437 |
"rfm_summary": custs.get("summary", {}),
|
| 438 |
-
"params": {"window": self.period_label}
|
| 439 |
}
|
| 440 |
|
| 441 |
temporal = self._temporal_patterns_from_sales(sales_df)
|
|
@@ -443,20 +587,20 @@ class AdminAnalyticsEngine:
|
|
| 443 |
inventory_block = {
|
| 444 |
"status": "ok" if inv else "no_stock_data",
|
| 445 |
"alerts": inv.get("alerts") if isinstance(inv, dict) else {},
|
| 446 |
-
"snapshot": inv
|
| 447 |
}
|
| 448 |
|
| 449 |
snapshot = {
|
| 450 |
"Summary Period": f"{self.period_label} ({self.t_start.date()} to {self.t_end.date()})",
|
| 451 |
"Performance Snapshot": {
|
| 452 |
-
"Total Revenue": total_revenue,
|
| 453 |
-
"Gross Profit": gross_profit,
|
| 454 |
"Transactions": transactions,
|
| 455 |
"Change": {
|
| 456 |
"revenue": dash.get("revenue_change") or dash.get("total_revenue_change"),
|
| 457 |
"gross_profit": dash.get("gross_profit_change") or dash.get("gp_change"),
|
| 458 |
-
"transactions": dash.get("transactions_change") or dash.get("orders_change")
|
| 459 |
-
}
|
| 460 |
},
|
| 461 |
"Temporal Patterns": temporal,
|
| 462 |
"Product KPIs": {"leaderboards": product_lb},
|
|
@@ -467,26 +611,45 @@ class AdminAnalyticsEngine:
|
|
| 467 |
"timeframes": {
|
| 468 |
"current_start": self.t_start.isoformat(),
|
| 469 |
"current_end": self.t_end.isoformat(),
|
| 470 |
-
"period_label": self.period_label
|
|
|
|
|
|
|
|
|
|
| 471 |
},
|
| 472 |
-
|
| 473 |
-
}
|
| 474 |
}
|
| 475 |
emit_kpi_debug(self.tenant_key, "snapshot_done", snapshot["meta"])
|
| 476 |
return json_safe(snapshot)
|
| 477 |
|
| 478 |
def _temporal_patterns_from_sales(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
if df is None or df.empty:
|
| 480 |
-
return {"series": [], "
|
|
|
|
| 481 |
d = df.copy()
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
|
| 491 |
def narrate(self, snapshot: dict, user_question: str) -> str:
|
| 492 |
try:
|
|
@@ -501,7 +664,6 @@ class AdminAnalyticsEngine:
|
|
| 501 |
return sanitize_answer(text)
|
| 502 |
except Exception:
|
| 503 |
return "### Business Snapshot\n\n```\n" + json.dumps(json_safe(snapshot), indent=2) + "\n```"
|
| 504 |
-
|
| 505 |
# -----------------------------------------------------------------------------
|
| 506 |
# /chat — PandasAI first on sales series, else deterministic snapshot + narration
|
| 507 |
# -----------------------------------------------------------------------------
|
|
|
|
| 338 |
if tb in s: s = s.split(tb, 1)[0]
|
| 339 |
return (s or "").strip()
|
| 340 |
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# -------------------- Robust normalizers (paste once, near your engine class) --------------------
|
| 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 |
+
"""Return x as a list. Accept list/dict/JSON-string/None gracefully."""
|
| 351 |
+
if x is None:
|
| 352 |
+
return []
|
| 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 |
+
return j
|
| 363 |
+
if isinstance(j, dict):
|
| 364 |
+
return [j]
|
| 365 |
+
except Exception:
|
| 366 |
+
return [x] # treat as scalar string row (will be skipped later)
|
| 367 |
+
return [x]
|
| 368 |
+
|
| 369 |
+
def _to_float(x: Any) -> Optional[float]:
|
| 370 |
+
try:
|
| 371 |
+
if x is None or (isinstance(x, str) and not x.strip()):
|
| 372 |
+
return None
|
| 373 |
+
return float(str(x).replace(",", "").strip())
|
| 374 |
+
except Exception:
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
def _to_int(x: Any) -> Optional[int]:
|
| 378 |
+
try:
|
| 379 |
+
f = _to_float(x)
|
| 380 |
+
return int(f) if f is not None else None
|
| 381 |
+
except Exception:
|
| 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:
|
| 395 |
+
return dt.tz_convert(TZ)
|
| 396 |
+
except Exception:
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
# -------------------- Hardened sales series (drop-in replacement) --------------------
|
| 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."""
|
| 406 |
def __init__(self, tenant_key: str, email: str, password: str, period: str = "week"):
|
| 407 |
+
self.tenant_key = (tenant_key or "admin").strip()
|
| 408 |
+
self.email = (email or "").strip()
|
| 409 |
+
self.password = (password or "").strip()
|
| 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
|
| 454 |
+
else:
|
| 455 |
+
try:
|
| 456 |
+
j = json.loads(raw)
|
| 457 |
+
data = j.get("data", j) if isinstance(j, dict) else {}
|
| 458 |
+
except Exception:
|
| 459 |
+
data = {}
|
| 460 |
+
|
| 461 |
+
series = []
|
| 462 |
+
sales_ot = data.get("sales_over_time")
|
| 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 |
+
"/api/analytics/products",
|
| 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 |
+
"/api/analytics/customers",
|
| 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 |
+
"/api/analytics/comparisons",
|
| 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 |
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)
|
| 549 |
+
if isinstance(v, (int, float, str)):
|
| 550 |
+
try:
|
| 551 |
+
return float(v)
|
| 552 |
+
except Exception:
|
| 553 |
+
continue
|
| 554 |
+
return default
|
| 555 |
+
|
| 556 |
+
total_revenue = _get_num(dash, "total_revenue", "revenue", default=0.0)
|
| 557 |
+
gross_profit = _get_num(dash, "gross_profit", "gp", default=0.0)
|
| 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 [],
|
|
|
|
| 579 |
"new_customers": custs.get("new_customers", []),
|
| 580 |
},
|
| 581 |
"rfm_summary": custs.get("summary", {}),
|
| 582 |
+
"params": {"window": self.period_label},
|
| 583 |
}
|
| 584 |
|
| 585 |
temporal = self._temporal_patterns_from_sales(sales_df)
|
|
|
|
| 587 |
inventory_block = {
|
| 588 |
"status": "ok" if inv else "no_stock_data",
|
| 589 |
"alerts": inv.get("alerts") if isinstance(inv, dict) else {},
|
| 590 |
+
"snapshot": inv,
|
| 591 |
}
|
| 592 |
|
| 593 |
snapshot = {
|
| 594 |
"Summary Period": f"{self.period_label} ({self.t_start.date()} to {self.t_end.date()})",
|
| 595 |
"Performance Snapshot": {
|
| 596 |
+
"Total Revenue": round(total_revenue, 2),
|
| 597 |
+
"Gross Profit": round(gross_profit, 2),
|
| 598 |
"Transactions": transactions,
|
| 599 |
"Change": {
|
| 600 |
"revenue": dash.get("revenue_change") or dash.get("total_revenue_change"),
|
| 601 |
"gross_profit": dash.get("gross_profit_change") or dash.get("gp_change"),
|
| 602 |
+
"transactions": dash.get("transactions_change") or dash.get("orders_change"),
|
| 603 |
+
},
|
| 604 |
},
|
| 605 |
"Temporal Patterns": temporal,
|
| 606 |
"Product KPIs": {"leaderboards": product_lb},
|
|
|
|
| 611 |
"timeframes": {
|
| 612 |
"current_start": self.t_start.isoformat(),
|
| 613 |
"current_end": self.t_end.isoformat(),
|
| 614 |
+
"period_label": self.period_label,
|
| 615 |
+
},
|
| 616 |
+
"row_counts": {
|
| 617 |
+
"sales_points": int(len(sales_df)) if isinstance(sales_df, pd.DataFrame) else 0
|
| 618 |
},
|
| 619 |
+
},
|
|
|
|
| 620 |
}
|
| 621 |
emit_kpi_debug(self.tenant_key, "snapshot_done", snapshot["meta"])
|
| 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 |
|
| 654 |
def narrate(self, snapshot: dict, user_question: str) -> str:
|
| 655 |
try:
|
|
|
|
| 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 |
# -----------------------------------------------------------------------------
|