rairo commited on
Commit
9899cf5
·
verified ·
1 Parent(s): 343ff22

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +219 -57
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
- data = sc_request("GET", "/api/analytics/dashboard", self.email, self.password, params={"period": self.period})
356
- emit_kpi_debug(self.tenant_key, "dashboard", data)
 
 
 
 
 
 
 
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
- data = sc_request("GET", "/api/analytics/sales", self.email, self.password, params=params)
366
- emit_kpi_debug(self.tenant_key, "sales_series_raw", data)
367
- rows = []
368
- seq = (data.get("data") if isinstance(data, dict) else data) or []
369
- for r in seq:
370
- rows.append({
371
- "date": str(r.get("date") or r.get("day") or r.get("period") or ""),
372
- "revenue": float(r.get("revenue") or r.get("total") or 0.0),
373
- "orders": int(r.get("orders") or r.get("transactions") or 0),
374
- "gross_profit": float(r.get("gross_profit") or r.get("gp") or 0.0),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  })
376
- df = pd.DataFrame(rows)
377
- if not df.empty:
378
- df["date"] = pd.to_datetime(df["date"], errors="coerce").dt.tz_localize(_TZ, nonexistent="shift_forward")
 
 
 
 
379
  return df
380
 
381
  def _products(self) -> dict:
382
- data = sc_request("GET", "/api/analytics/products", self.email, self.password,
383
- params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")})
384
- emit_kpi_debug(self.tenant_key, "products", data)
 
 
 
 
 
 
385
  return data or {}
386
 
387
  def _customers(self) -> dict:
388
- data = sc_request("GET", "/api/analytics/customers", self.email, self.password,
389
- params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")})
390
- emit_kpi_debug(self.tenant_key, "customers", data)
 
 
 
 
 
 
391
  return data or {}
392
 
393
  def _inventory(self) -> dict:
394
- data = sc_request("GET", "/api/analytics/inventory", self.email, self.password)
395
- emit_kpi_debug(self.tenant_key, "inventory", data)
 
396
  return data or {}
397
 
398
  def _comparisons(self) -> dict:
399
- data = sc_request("GET", "/api/analytics/comparisons", self.email, self.password,
400
- params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")})
401
- emit_kpi_debug(self.tenant_key, "comparisons", data)
 
 
 
 
 
 
402
  return data or {}
403
 
404
  # -------------------- deterministic snapshot --------------------
@@ -410,14 +541,27 @@ class AdminAnalyticsEngine:
410
  inv = self._inventory()
411
  comps = self._comparisons()
412
 
413
- try:
414
- total_revenue = float(dash.get("total_revenue", 0.0))
415
- gross_profit = float(dash.get("gross_profit", 0.0) or dash.get("gp", 0.0))
416
- transactions = int(dash.get("transactions", dash.get("orders", 0)))
417
- except Exception:
418
- total_revenue = float(sales_df["revenue"].sum()) if not sales_df.empty else 0.0
419
- gross_profit = float(sales_df["gross_profit"].sum()) if not sales_df.empty else 0.0
420
- transactions = int(sales_df["orders"].sum()) if not sales_df.empty else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "row_counts": {"sales_points": int(len(sales_df)) if isinstance(sales_df, pd.DataFrame) else 0}
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": [], "best_day_by_profit": None}
 
481
  d = df.copy()
482
- d["dow"] = d["date"].dt.day_name()
483
- by_dow = d.groupby("dow", dropna=False).agg(revenue=("revenue","sum"), gross_profit=("gross_profit","sum")).reset_index()
484
- best_day = None
485
- if not by_dow.empty:
486
- best = by_dow.iloc[by_dow["gross_profit"].idxmax()]
487
- best_day = {"day": str(best["dow"]), "gross_profit": float(best["gross_profit"])}
488
- series = d.sort_values("date").assign(date=lambda x: x["date"].dt.strftime("%Y-%m-%d")).to_dict(orient="records")
489
- return {"series": series, "best_day_by_profit": best_day}
 
 
 
 
 
 
 
 
 
 
 
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
  # -----------------------------------------------------------------------------