Realign dashboard API to the v2 bot schema (transactions)
Browse filesThe endpoints read the legacy sales/expenses subcollections that the upgraded
bot no longer writes, so dashboards returned zeros. Read the v2 `transactions`
schema instead and mirror the bot's accounting.
- analytics.py: pure aggregation (per-currency sales/COGS/gross/expenses/net,
cash-on-hand, receivables/payables, stock value, top items/customers/expenses,
daily series) + model_health over distillation_examples.
- /api/user/dashboard rewritten (v2 payload + legacy aliases); add
/api/user/transactions.
- /api/admin/dashboard/stats rewritten (per-user v2 aggregation, per-currency
leaderboards, global standing); add /api/admin/model-health.
- Auth, user CRUD, org and phone-migration endpoints unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- analytics.py +306 -0
- main.py +190 -204
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analytics.py — v2 ledger aggregation for the Smart Qx dashboards.
|
| 3 |
+
|
| 4 |
+
The WhatsApp bot (smart-w) records ONE `transactions` collection per user
|
| 5 |
+
(`users/{mobile}/transactions`), each doc carrying a `transaction_type`
|
| 6 |
+
(sale | stock_in | expense | asset | loan | repayment | other) and a `details`
|
| 7 |
+
map with an `items[]` array. These helpers reproduce the bot's own accounting
|
| 8 |
+
(see smart-w/utility.py: _compute_cogs, _compute_cash_position, build_period_report)
|
| 9 |
+
so the dashboards and the WhatsApp reports agree to the cent.
|
| 10 |
+
|
| 11 |
+
Pure functions — they take plain dicts (Firestore `doc.to_dict()`), no Firebase here.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from collections import defaultdict
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
from typing import Any, Dict, List, Optional
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ── Parsing helpers ──────────────────────────────────────────────────────────
|
| 20 |
+
_CURRENCY_MAP = {
|
| 21 |
+
"$": "USD", "dollar": "USD", "dollars": "USD", "usd": "USD",
|
| 22 |
+
"r": "ZAR", "rand": "ZAR", "rands": "ZAR", "zar": "ZAR",
|
| 23 |
+
"zwg": "ZWG", "zwl": "ZWG", "kes": "KES", "ngn": "NGN", "ghs": "GHS",
|
| 24 |
+
"eur": "EUR", "€": "EUR", "gbp": "GBP", "£": "GBP",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def normalize_currency_code(raw_code: Any, default_code: str = "USD") -> str:
|
| 29 |
+
"""Messy currency string ('$', 'rand', 'R') → ISO code ('USD', 'ZAR')."""
|
| 30 |
+
if not raw_code or not isinstance(raw_code, str):
|
| 31 |
+
return default_code
|
| 32 |
+
return _CURRENCY_MAP.get(raw_code.lower().strip(), (raw_code.upper().strip()[:3] or default_code))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def money(value: Any) -> float:
|
| 36 |
+
"""Best-effort money → float. Accepts numbers and strings like 'USD4', '$3.50', '50c'."""
|
| 37 |
+
if value is None:
|
| 38 |
+
return 0.0
|
| 39 |
+
if isinstance(value, bool):
|
| 40 |
+
return 0.0
|
| 41 |
+
if isinstance(value, (int, float)):
|
| 42 |
+
return float(value)
|
| 43 |
+
s = str(value).strip().replace(",", "")
|
| 44 |
+
if not s or s.lower() in ("none", "null", "nan", "?"):
|
| 45 |
+
return 0.0
|
| 46 |
+
import re
|
| 47 |
+
m = re.search(r"-?\d+(?:\.\d+)?", s)
|
| 48 |
+
if not m:
|
| 49 |
+
return 0.0
|
| 50 |
+
try:
|
| 51 |
+
return float(m.group(0))
|
| 52 |
+
except ValueError:
|
| 53 |
+
return 0.0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def money_opt(value: Any) -> Optional[float]:
|
| 57 |
+
"""Like money() but returns None when there is no numeric value (distinguishes 0 from absent)."""
|
| 58 |
+
if value is None:
|
| 59 |
+
return None
|
| 60 |
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
| 61 |
+
return float(value)
|
| 62 |
+
import re
|
| 63 |
+
m = re.search(r"-?\d+(?:\.\d+)?", str(value))
|
| 64 |
+
return float(m.group(0)) if m else None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _to_dt(ts: Any) -> Optional[datetime]:
|
| 68 |
+
"""Firestore Timestamp / datetime / ISO string → tz-aware datetime (UTC)."""
|
| 69 |
+
if ts is None:
|
| 70 |
+
return None
|
| 71 |
+
if isinstance(ts, datetime):
|
| 72 |
+
return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
|
| 73 |
+
if hasattr(ts, "timestamp"): # Firestore Timestamp / DatetimeWithNanoseconds
|
| 74 |
+
try:
|
| 75 |
+
return datetime.fromtimestamp(ts.timestamp(), tz=timezone.utc)
|
| 76 |
+
except Exception:
|
| 77 |
+
return None
|
| 78 |
+
if isinstance(ts, str):
|
| 79 |
+
try:
|
| 80 |
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
| 81 |
+
except Exception:
|
| 82 |
+
return None
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _in_range(ts: Any, start: Optional[datetime], end: Optional[datetime]) -> bool:
|
| 87 |
+
if start is None and end is None:
|
| 88 |
+
return True
|
| 89 |
+
dt = _to_dt(ts)
|
| 90 |
+
if dt is None:
|
| 91 |
+
return False
|
| 92 |
+
if start and dt < start:
|
| 93 |
+
return False
|
| 94 |
+
if end and dt > end:
|
| 95 |
+
return False
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _txn_type(d: Dict) -> str:
|
| 100 |
+
return (d.get("transaction_type") or d.get("type") or "other").lower()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _items(details: Dict) -> List[Dict]:
|
| 104 |
+
it = details.get("items")
|
| 105 |
+
return it if isinstance(it, list) else []
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _truthy(v: Any) -> bool:
|
| 109 |
+
return str(v).strip().lower() in ("true", "1", "yes", "y", "service")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ── Cost basis (COGS) ────────────────────────────────────────────────────────
|
| 113 |
+
def build_cost_map(stock_batches: List[Dict]) -> Dict[str, float]:
|
| 114 |
+
"""item name → mean known unit cost (cost_each) from stock batches."""
|
| 115 |
+
sums: Dict[str, float] = defaultdict(float)
|
| 116 |
+
counts: Dict[str, int] = defaultdict(int)
|
| 117 |
+
for b in stock_batches or []:
|
| 118 |
+
name = str(b.get("name") or "").strip().lower()
|
| 119 |
+
c = money_opt(b.get("cost_each"))
|
| 120 |
+
if name and c is not None and c > 0:
|
| 121 |
+
sums[name] += c
|
| 122 |
+
counts[name] += 1
|
| 123 |
+
return {k: sums[k] / counts[k] for k in sums if counts[k]}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ── Main ledger aggregation ──────────────────────────────────────────────────
|
| 127 |
+
def aggregate_ledger(txns: List[Dict], stock_batches: List[Dict], customers: List[Dict],
|
| 128 |
+
start: Optional[datetime] = None, end: Optional[datetime] = None,
|
| 129 |
+
default_currency: str = "USD") -> Dict[str, Any]:
|
| 130 |
+
"""Return per-currency financials + insight lists. Mirrors the bot's report math.
|
| 131 |
+
|
| 132 |
+
Standing figures (cash on hand, receivables, payables, stock value) are CURRENT — the
|
| 133 |
+
date range only filters period flows (sales/expenses/cogs/net and the series/lists).
|
| 134 |
+
"""
|
| 135 |
+
cur_metrics: Dict[str, Dict[str, float]] = defaultdict(
|
| 136 |
+
lambda: {"sales": 0.0, "cogs": 0.0, "expenses": 0.0, "stock_purchases": 0.0, "sales_count": 0})
|
| 137 |
+
item_rev: Dict[str, Dict[str, float]] = defaultdict(lambda: defaultdict(float)) # item → cur → revenue
|
| 138 |
+
item_qty: Dict[str, float] = defaultdict(float)
|
| 139 |
+
expense_cat: Dict[str, Dict[str, float]] = defaultdict(lambda: defaultdict(float)) # cat → cur → amount
|
| 140 |
+
daily: Dict[str, Dict[str, float]] = defaultdict(lambda: {"revenue": 0.0, "cogs": 0.0, "expenses": 0.0})
|
| 141 |
+
|
| 142 |
+
cost_map = build_cost_map(stock_batches)
|
| 143 |
+
last_cur = normalize_currency_code(default_currency, "USD")
|
| 144 |
+
cash = 0.0 # cash on hand is CURRENT (whole trail), not date-filtered
|
| 145 |
+
|
| 146 |
+
for d in txns or []:
|
| 147 |
+
ttype = _txn_type(d)
|
| 148 |
+
det = d.get("details", {}) or {}
|
| 149 |
+
cur = normalize_currency_code(det.get("currency"), last_cur)
|
| 150 |
+
last_cur = cur
|
| 151 |
+
amount = money(det.get("amount") or det.get("total"))
|
| 152 |
+
paid = money_opt(det.get("amount_paid"))
|
| 153 |
+
in_period = _in_range(d.get("created_at"), start, end)
|
| 154 |
+
day_key = (_to_dt(d.get("created_at")) or datetime.now(timezone.utc)).strftime("%Y-%m-%d")
|
| 155 |
+
|
| 156 |
+
# --- cash on hand (current, all-time) ---
|
| 157 |
+
if ttype == "sale":
|
| 158 |
+
credit = money(det.get("customer_credit"))
|
| 159 |
+
cash += max(amount - credit, 0.0)
|
| 160 |
+
elif ttype in ("stock_in", "expense", "asset"):
|
| 161 |
+
cash -= (paid if paid is not None else amount)
|
| 162 |
+
elif ttype == "loan":
|
| 163 |
+
direction = (det.get("loan_direction") or "").lower()
|
| 164 |
+
if direction == "lent":
|
| 165 |
+
cash -= amount
|
| 166 |
+
elif direction == "borrowed":
|
| 167 |
+
cash += amount
|
| 168 |
+
elif ttype == "repayment":
|
| 169 |
+
cash += money(det.get("cash_in"))
|
| 170 |
+
cash -= money(det.get("cash_out"))
|
| 171 |
+
|
| 172 |
+
if not in_period:
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
# --- period flows ---
|
| 176 |
+
if ttype == "sale":
|
| 177 |
+
cur_metrics[cur]["sales"] += amount
|
| 178 |
+
cur_metrics[cur]["sales_count"] += 1
|
| 179 |
+
daily[day_key]["revenue"] += amount
|
| 180 |
+
line_cogs = 0.0
|
| 181 |
+
for it in _items(det):
|
| 182 |
+
if _truthy(it.get("is_service")):
|
| 183 |
+
continue
|
| 184 |
+
nm = str(it.get("item") or it.get("name") or "").strip().lower()
|
| 185 |
+
qty = money(it.get("quantity"))
|
| 186 |
+
if nm and qty > 0:
|
| 187 |
+
unit_cost = cost_map.get(nm, 0.0)
|
| 188 |
+
line_cogs += qty * unit_cost
|
| 189 |
+
item_qty[nm] += qty
|
| 190 |
+
# per-item revenue share for leaderboards
|
| 191 |
+
ippu = money_opt(it.get("price_per_unit") or it.get("price_each"))
|
| 192 |
+
iamt = money_opt(it.get("amount"))
|
| 193 |
+
rev = iamt if iamt is not None else (ippu * qty if (ippu is not None and qty) else None)
|
| 194 |
+
if nm and rev:
|
| 195 |
+
item_rev[nm][cur] += rev
|
| 196 |
+
cur_metrics[cur]["cogs"] += line_cogs
|
| 197 |
+
daily[day_key]["cogs"] += line_cogs
|
| 198 |
+
elif ttype == "expense":
|
| 199 |
+
cur_metrics[cur]["expenses"] += amount
|
| 200 |
+
cat = (det.get("category") or det.get("description") or "other") or "other"
|
| 201 |
+
expense_cat[str(cat)][cur] += amount
|
| 202 |
+
daily[day_key]["expenses"] += amount
|
| 203 |
+
elif ttype == "stock_in":
|
| 204 |
+
cur_metrics[cur]["stock_purchases"] += amount
|
| 205 |
+
|
| 206 |
+
# finalize per-currency
|
| 207 |
+
by_cur: Dict[str, Dict[str, float]] = {}
|
| 208 |
+
for cur, m in cur_metrics.items():
|
| 209 |
+
gross = round(m["sales"] - m["cogs"], 2)
|
| 210 |
+
net = round(gross - m["expenses"], 2)
|
| 211 |
+
by_cur[cur] = {
|
| 212 |
+
"sales": round(m["sales"], 2),
|
| 213 |
+
"cogs": round(m["cogs"], 2),
|
| 214 |
+
"grossProfit": gross,
|
| 215 |
+
"grossMarginPct": round((gross / m["sales"] * 100.0), 1) if m["sales"] else 0.0,
|
| 216 |
+
"expenses": round(m["expenses"], 2),
|
| 217 |
+
"netProfit": net,
|
| 218 |
+
"stockPurchases": round(m["stock_purchases"], 2),
|
| 219 |
+
"salesCount": int(m["sales_count"]),
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
# standing (current)
|
| 223 |
+
receivables = round(sum(money(c.get("receivable", c.get("outstanding_credit"))) for c in (customers or [])), 2)
|
| 224 |
+
payables = round(sum(money(c.get("payable", c.get("outstanding_change"))) for c in (customers or [])), 2)
|
| 225 |
+
stock_value = round(sum(money(b.get("quantity_remaining")) * money(b.get("cost_each")) for b in (stock_batches or [])), 2)
|
| 226 |
+
|
| 227 |
+
# leaderboards / lists
|
| 228 |
+
top_items = sorted(
|
| 229 |
+
({"item": nm, "quantity": round(item_qty.get(nm, 0.0), 2),
|
| 230 |
+
"revenue": round(sum(cm.values()), 2), "byCurrency": {k: round(v, 2) for k, v in cm.items()}}
|
| 231 |
+
for nm, cm in item_rev.items()),
|
| 232 |
+
key=lambda x: -x["revenue"])[:10]
|
| 233 |
+
top_customers = sorted(
|
| 234 |
+
({"name": c.get("name", "Customer"),
|
| 235 |
+
"receivable": round(money(c.get("receivable", c.get("outstanding_credit"))), 2),
|
| 236 |
+
"totalPurchases": round(money(c.get("total_purchases")), 2),
|
| 237 |
+
"visits": int(money(c.get("visit_count")))}
|
| 238 |
+
for c in (customers or [])),
|
| 239 |
+
key=lambda x: -x["totalPurchases"])[:10]
|
| 240 |
+
top_expenses = sorted(
|
| 241 |
+
({"category": cat, "amount": round(sum(cm.values()), 2),
|
| 242 |
+
"byCurrency": {k: round(v, 2) for k, v in cm.items()}}
|
| 243 |
+
for cat, cm in expense_cat.items()),
|
| 244 |
+
key=lambda x: -x["amount"])[:10]
|
| 245 |
+
daily_series = [
|
| 246 |
+
{"date": day, "revenue": round(v["revenue"], 2), "cogs": round(v["cogs"], 2),
|
| 247 |
+
"expenses": round(v["expenses"], 2),
|
| 248 |
+
"profit": round(v["revenue"] - v["cogs"] - v["expenses"], 2)}
|
| 249 |
+
for day, v in sorted(daily.items())]
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
"byCurrency": by_cur,
|
| 253 |
+
"cashOnHand": round(cash, 2),
|
| 254 |
+
"receivables": receivables,
|
| 255 |
+
"payables": payables,
|
| 256 |
+
"stockValue": stock_value,
|
| 257 |
+
"topItems": top_items,
|
| 258 |
+
"topCustomers": top_customers,
|
| 259 |
+
"topExpenses": top_expenses,
|
| 260 |
+
"dailySeries": daily_series,
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ── Model-Health (distillation session capture) ──────────────────────────────
|
| 265 |
+
def model_health(examples: List[Dict], start: Optional[datetime] = None,
|
| 266 |
+
end: Optional[datetime] = None, opt_out_count: int = 0) -> Dict[str, Any]:
|
| 267 |
+
"""Aggregate distillation_examples: how we record & close sessions.
|
| 268 |
+
|
| 269 |
+
Confirm/Cancel verdicts are a live extraction-accuracy proxy. `archived` are the
|
| 270 |
+
anonymised reset snapshots (reason == account_reset)."""
|
| 271 |
+
by_task: Dict[str, int] = defaultdict(int)
|
| 272 |
+
by_modality: Dict[str, int] = defaultdict(int)
|
| 273 |
+
by_verdict: Dict[str, int] = defaultdict(int)
|
| 274 |
+
growth: Dict[str, int] = defaultdict(int)
|
| 275 |
+
reset_snapshots = 0
|
| 276 |
+
total = 0
|
| 277 |
+
|
| 278 |
+
for e in examples or []:
|
| 279 |
+
if not _in_range(e.get("created_at"), start, end):
|
| 280 |
+
continue
|
| 281 |
+
total += 1
|
| 282 |
+
by_task[str(e.get("task") or "unknown")] += 1
|
| 283 |
+
by_modality[str(e.get("modality") or "unknown")] += 1
|
| 284 |
+
verdict = str(e.get("verdict") or "pending")
|
| 285 |
+
by_verdict[verdict] += 1
|
| 286 |
+
if str(e.get("reason") or "") == "account_reset" or e.get("task") == "ledger_snapshot":
|
| 287 |
+
reset_snapshots += 1
|
| 288 |
+
day = (_to_dt(e.get("created_at")) or datetime.now(timezone.utc)).strftime("%Y-%m-%d")
|
| 289 |
+
growth[day] += 1
|
| 290 |
+
|
| 291 |
+
confirmed = by_verdict.get("confirmed", 0)
|
| 292 |
+
rejected = by_verdict.get("rejected", 0)
|
| 293 |
+
labelled = confirmed + rejected
|
| 294 |
+
confirm_rate = round(confirmed / labelled * 100.0, 1) if labelled else None
|
| 295 |
+
|
| 296 |
+
return {
|
| 297 |
+
"totalCaptured": total,
|
| 298 |
+
"byTask": dict(by_task),
|
| 299 |
+
"byModality": dict(by_modality),
|
| 300 |
+
"byVerdict": dict(by_verdict),
|
| 301 |
+
"confirmRate": confirm_rate, # % of labelled sessions the user confirmed
|
| 302 |
+
"labelledCount": labelled,
|
| 303 |
+
"growthSeries": [{"date": d, "count": growth[d]} for d in sorted(growth)],
|
| 304 |
+
"optOutCount": int(opt_out_count),
|
| 305 |
+
"resetSnapshots": reset_snapshots,
|
| 306 |
+
}
|
|
@@ -7,6 +7,8 @@ from flask_cors import CORS
|
|
| 7 |
import firebase_admin
|
| 8 |
from firebase_admin import credentials, auth, firestore
|
| 9 |
|
|
|
|
|
|
|
| 10 |
# --- Basic Configuration ---
|
| 11 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 12 |
|
|
@@ -92,6 +94,28 @@ def normalize_currency_code(raw_code, default_code='USD'):
|
|
| 92 |
clean_code = raw_code.lower().strip()
|
| 93 |
return currency_map.get(clean_code, default_code)
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
# -----------------------------------------------------------------------------
|
| 96 |
# 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS
|
| 97 |
# -----------------------------------------------------------------------------
|
|
@@ -244,82 +268,80 @@ def get_user_dashboard():
|
|
| 244 |
if not phone_number: return jsonify({'error': 'No phone number is associated with your account.'}), 404
|
| 245 |
|
| 246 |
try:
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
start_date, end_date = None, None
|
| 251 |
-
if start_date_str:
|
| 252 |
-
start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00'))
|
| 253 |
-
if end_date_str:
|
| 254 |
-
end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00'))
|
| 255 |
-
|
| 256 |
bot_data_id = phone_number.lstrip('+')
|
| 257 |
-
bot_user_ref = db.collection('users').document(bot_data_id)
|
| 258 |
-
|
| 259 |
-
sales_revenue_by_currency, cogs_by_currency, expenses_by_currency = {}, {}, {}
|
| 260 |
-
sales_count = 0
|
| 261 |
-
|
| 262 |
-
default_currency_code = normalize_currency_code(user_data.get('defaultCurrency'), 'USD')
|
| 263 |
-
last_seen_currency_code = default_currency_code
|
| 264 |
-
|
| 265 |
-
# Process Sales
|
| 266 |
-
sales_query = bot_user_ref.collection('sales').stream()
|
| 267 |
-
for doc in sales_query:
|
| 268 |
-
doc_data = doc.to_dict()
|
| 269 |
-
created_at = doc_data.get('createdAt')
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
quantity, price, cost = int(details.get('quantity', 1)), float(details.get('price', 0)), float(details.get('cost', 0))
|
| 280 |
-
sales_revenue_by_currency[currency_code] = sales_revenue_by_currency.get(currency_code, 0) + (price * quantity)
|
| 281 |
-
cogs_by_currency[currency_code] = cogs_by_currency.get(currency_code, 0) + (cost * quantity)
|
| 282 |
-
sales_count += 1
|
| 283 |
-
|
| 284 |
-
# Process Expenses
|
| 285 |
-
expenses_query = bot_user_ref.collection('expenses').stream()
|
| 286 |
-
for doc in expenses_query:
|
| 287 |
-
doc_data = doc.to_dict()
|
| 288 |
-
created_at = doc_data.get('createdAt')
|
| 289 |
|
| 290 |
-
if start_date and (not created_at or created_at < start_date):
|
| 291 |
-
continue
|
| 292 |
-
if end_date and (not created_at or created_at > end_date):
|
| 293 |
-
continue
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
'
|
| 316 |
-
'
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
except Exception as e:
|
| 321 |
-
logging.error(f"Error fetching
|
| 322 |
-
return jsonify({'error': 'An error occurred while fetching your
|
| 323 |
|
| 324 |
# Replace your existing PUT /api/user/profile with this one.
|
| 325 |
@app.route('/api/user/profile', methods=['PUT'])
|
|
@@ -812,151 +834,96 @@ def get_admin_dashboard_stats():
|
|
| 812 |
"""
|
| 813 |
try:
|
| 814 |
verify_admin_and_get_uid(request.headers.get('Authorization'))
|
|
|
|
| 815 |
|
| 816 |
-
start_date_str = request.args.get('start_date')
|
| 817 |
-
end_date_str = request.args.get('end_date')
|
| 818 |
-
|
| 819 |
-
start_date, end_date = None, None
|
| 820 |
-
if start_date_str:
|
| 821 |
-
start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00'))
|
| 822 |
-
if end_date_str:
|
| 823 |
-
end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00'))
|
| 824 |
-
|
| 825 |
-
# --- Initialization ---
|
| 826 |
all_users_docs = list(db.collection('users').stream())
|
| 827 |
all_orgs_docs = list(db.collection('organizations').stream())
|
| 828 |
-
|
| 829 |
-
user_sales_data, global_item_revenue, global_expense_totals, phone_to_user_map = {}, {}, {}, {}
|
| 830 |
-
global_sales_rev_by_curr, global_cogs_by_curr, global_expenses_by_curr = {}, {}, {}
|
| 831 |
-
|
| 832 |
-
# --- First Pass: Process all documents to gather stats and approved phones ---
|
| 833 |
-
pending_approvals, approved_users, admin_count = 0, 0, 0
|
| 834 |
-
approved_phone_numbers = []
|
| 835 |
|
|
|
|
|
|
|
|
|
|
| 836 |
for doc in all_users_docs:
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
}
|
| 861 |
-
|
| 862 |
-
org_stats = {'total': len(all_orgs_docs)}
|
| 863 |
-
|
| 864 |
-
# --- Second Pass: Aggregate financial data from bot documents ---
|
| 865 |
-
sales_count = 0
|
| 866 |
-
for phone in approved_phone_numbers:
|
| 867 |
try:
|
| 868 |
-
|
| 869 |
-
bot_user_ref = db.collection('users').document(bot_data_id)
|
| 870 |
-
|
| 871 |
-
user_sales_data[phone] = {'total_revenue_by_currency': {}, 'item_sales': {}}
|
| 872 |
-
last_seen_currency_code = normalize_currency_code(phone_to_user_map.get(phone, {}).get('defaultCurrency'), 'USD')
|
| 873 |
-
|
| 874 |
-
sales_query = bot_user_ref.collection('sales').stream()
|
| 875 |
-
for sale_doc in sales_query:
|
| 876 |
-
sale_data = sale_doc.to_dict()
|
| 877 |
-
created_at = sale_data.get('createdAt')
|
| 878 |
-
|
| 879 |
-
if start_date and (not created_at or created_at < start_date):
|
| 880 |
-
continue
|
| 881 |
-
if end_date and (not created_at or created_at > end_date):
|
| 882 |
-
continue
|
| 883 |
-
|
| 884 |
-
details = sale_data.get('details', {})
|
| 885 |
-
currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code)
|
| 886 |
-
last_seen_currency_code = currency_code
|
| 887 |
-
quantity, price, cost = int(details.get('quantity', 1)), float(details.get('price', 0)), float(details.get('cost', 0))
|
| 888 |
-
sale_revenue = price * quantity
|
| 889 |
-
item_name = details.get('item', 'Unknown Item')
|
| 890 |
-
|
| 891 |
-
global_sales_rev_by_curr[currency_code] = global_sales_rev_by_curr.get(currency_code, 0) + sale_revenue
|
| 892 |
-
global_cogs_by_curr[currency_code] = global_cogs_by_curr.get(currency_code, 0) + (cost * quantity)
|
| 893 |
-
sales_count += 1
|
| 894 |
-
|
| 895 |
-
user_sales_data[phone]['total_revenue_by_currency'][currency_code] = user_sales_data[phone]['total_revenue_by_currency'].get(currency_code, 0) + sale_revenue
|
| 896 |
-
|
| 897 |
-
if item_name not in global_item_revenue: global_item_revenue[item_name] = {}
|
| 898 |
-
global_item_revenue[item_name][currency_code] = global_item_revenue[item_name].get(currency_code, 0) + sale_revenue
|
| 899 |
-
|
| 900 |
-
expenses_query = bot_user_ref.collection('expenses').stream()
|
| 901 |
-
for expense_doc in expenses_query:
|
| 902 |
-
expense_data = expense_doc.to_dict()
|
| 903 |
-
created_at = expense_data.get('createdAt')
|
| 904 |
-
|
| 905 |
-
if start_date and (not created_at or created_at < start_date):
|
| 906 |
-
continue
|
| 907 |
-
if end_date and (not created_at or created_at > end_date):
|
| 908 |
-
continue
|
| 909 |
-
|
| 910 |
-
details = expense_data.get('details', {})
|
| 911 |
-
currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code)
|
| 912 |
-
last_seen_currency_code = currency_code
|
| 913 |
-
amount = float(details.get('amount', 0))
|
| 914 |
-
category = details.get('description', 'Uncategorized')
|
| 915 |
-
|
| 916 |
-
global_expenses_by_curr[currency_code] = global_expenses_by_curr.get(currency_code, 0) + amount
|
| 917 |
-
if category not in global_expense_totals: global_expense_totals[category] = {}
|
| 918 |
-
global_expense_totals[category][currency_code] = global_expense_totals[category].get(currency_code, 0) + amount
|
| 919 |
except Exception as e:
|
| 920 |
-
logging.error(f"Admin stats:
|
| 921 |
continue
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
expenses = global_expenses_by_curr.get(curr, 0)
|
| 947 |
-
global_net_profit_by_curr[curr] = round(revenue - cogs - expenses, 2)
|
| 948 |
-
|
| 949 |
-
system_stats = {
|
| 950 |
-
'totalSalesRevenueByCurrency': {k: round(v, 2) for k, v in global_sales_rev_by_curr.items()},
|
| 951 |
-
'totalCostOfGoodsSoldByCurrency': {k: round(v, 2) for k, v in global_cogs_by_curr.items()},
|
| 952 |
-
'totalExpensesByCurrency': {k: round(v, 2) for k, v in global_expenses_by_curr.items()},
|
| 953 |
-
'totalNetProfitByCurrency': global_net_profit_by_curr,
|
| 954 |
-
'totalSalesCount': sales_count,
|
| 955 |
-
}
|
| 956 |
|
| 957 |
return jsonify({
|
| 958 |
-
'userStats':
|
| 959 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
}), 200
|
| 961 |
|
| 962 |
except PermissionError as e:
|
|
@@ -964,6 +931,25 @@ def get_admin_dashboard_stats():
|
|
| 964 |
except Exception as e:
|
| 965 |
logging.error(f"Admin failed to fetch dashboard stats: {e}", exc_info=True)
|
| 966 |
return jsonify({'error': 'An internal error occurred while fetching stats'}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
# -----------------------------------------------------------------------------
|
| 968 |
# 8. SERVER EXECUTION
|
| 969 |
# -----------------------------------------------------------------------------
|
|
|
|
| 7 |
import firebase_admin
|
| 8 |
from firebase_admin import credentials, auth, firestore
|
| 9 |
|
| 10 |
+
import analytics # v2 ledger aggregation (reads the transactions schema)
|
| 11 |
+
|
| 12 |
# --- Basic Configuration ---
|
| 13 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 14 |
|
|
|
|
| 94 |
clean_code = raw_code.lower().strip()
|
| 95 |
return currency_map.get(clean_code, default_code)
|
| 96 |
|
| 97 |
+
|
| 98 |
+
# ── v2 ledger helpers ────────────────────────────────────────────────────────
|
| 99 |
+
def parse_date_range(req):
|
| 100 |
+
"""Parse ?start_date&end_date (ISO) into tz-aware datetimes (or None)."""
|
| 101 |
+
def _p(s):
|
| 102 |
+
if not s:
|
| 103 |
+
return None
|
| 104 |
+
try:
|
| 105 |
+
return datetime.fromisoformat(s.replace('Z', '+00:00'))
|
| 106 |
+
except Exception:
|
| 107 |
+
return None
|
| 108 |
+
return _p(req.args.get('start_date')), _p(req.args.get('end_date'))
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def fetch_user_ledger(bot_data_id):
|
| 112 |
+
"""Read a bot user's v2 ledger: (transactions, stock_batches, customers) as dicts."""
|
| 113 |
+
ref = db.collection('users').document(bot_data_id)
|
| 114 |
+
txns = [d.to_dict() for d in ref.collection('transactions').stream()]
|
| 115 |
+
batches = [d.to_dict() for d in ref.collection('stock_batches').stream()]
|
| 116 |
+
customers = [d.to_dict() for d in ref.collection('customers').stream()]
|
| 117 |
+
return txns, batches, customers
|
| 118 |
+
|
| 119 |
# -----------------------------------------------------------------------------
|
| 120 |
# 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS
|
| 121 |
# -----------------------------------------------------------------------------
|
|
|
|
| 268 |
if not phone_number: return jsonify({'error': 'No phone number is associated with your account.'}), 404
|
| 269 |
|
| 270 |
try:
|
| 271 |
+
start_date, end_date = parse_date_range(request)
|
| 272 |
+
default_currency = user_data.get('defaultCurrency', 'USD')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
bot_data_id = phone_number.lstrip('+')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
+
txns, batches, customers = fetch_user_ledger(bot_data_id)
|
| 276 |
+
agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
|
| 277 |
+
default_currency=default_currency)
|
| 278 |
+
|
| 279 |
+
# Legacy-compatible per-currency maps (so existing UI keeps working during the switch)
|
| 280 |
+
by_cur = agg['byCurrency']
|
| 281 |
+
legacy = {
|
| 282 |
+
'totalSalesRevenueByCurrency': {c: v['sales'] for c, v in by_cur.items()},
|
| 283 |
+
'totalCostOfGoodsSoldByCurrency': {c: v['cogs'] for c, v in by_cur.items()},
|
| 284 |
+
'grossProfitByCurrency': {c: v['grossProfit'] for c, v in by_cur.items()},
|
| 285 |
+
'totalExpensesByCurrency': {c: v['expenses'] for c, v in by_cur.items()},
|
| 286 |
+
'netProfitByCurrency': {c: v['netProfit'] for c, v in by_cur.items()},
|
| 287 |
+
'salesCount': sum(v['salesCount'] for v in by_cur.values()),
|
| 288 |
+
}
|
| 289 |
+
# New v2 insight payload
|
| 290 |
+
payload = {
|
| 291 |
+
**legacy,
|
| 292 |
+
'byCurrency': by_cur,
|
| 293 |
+
'cashOnHand': agg['cashOnHand'],
|
| 294 |
+
'receivables': agg['receivables'],
|
| 295 |
+
'payables': agg['payables'],
|
| 296 |
+
'stockValue': agg['stockValue'],
|
| 297 |
+
'topItems': agg['topItems'],
|
| 298 |
+
'topCustomers': agg['topCustomers'],
|
| 299 |
+
'dailySeries': agg['dailySeries'],
|
| 300 |
+
}
|
| 301 |
+
return jsonify(payload), 200
|
| 302 |
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logging.error(f"Error fetching dashboard data for user {uid} (phone: {phone_number}): {e}", exc_info=True)
|
| 305 |
+
return jsonify({'error': 'An error occurred while fetching your dashboard data.'}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
+
@app.route('/api/user/transactions', methods=['GET'])
|
| 309 |
+
def get_user_transactions():
|
| 310 |
+
"""Recent ledger rows for the user's dashboard activity feed."""
|
| 311 |
+
uid = verify_token(request.headers.get('Authorization'))
|
| 312 |
+
if not uid:
|
| 313 |
+
return jsonify({'error': 'Invalid or expired token'}), 401
|
| 314 |
+
user_doc = db.collection('users').document(uid).get()
|
| 315 |
+
if not user_doc.exists:
|
| 316 |
+
return jsonify({'error': 'User not found'}), 404
|
| 317 |
+
user_data = user_doc.to_dict()
|
| 318 |
+
if user_data.get('phoneStatus') != 'approved' or not user_data.get('phone'):
|
| 319 |
+
return jsonify({'error': 'Your phone number is not yet approved.'}), 403
|
| 320 |
+
try:
|
| 321 |
+
limit = min(int(request.args.get('limit', 25)), 100)
|
| 322 |
+
bot_data_id = user_data['phone'].lstrip('+')
|
| 323 |
+
q = (db.collection('users').document(bot_data_id).collection('transactions')
|
| 324 |
+
.order_by('created_at', direction=firestore.Query.DESCENDING).limit(limit))
|
| 325 |
+
rows = []
|
| 326 |
+
for d in q.stream():
|
| 327 |
+
v = d.to_dict()
|
| 328 |
+
det = v.get('details', {}) or {}
|
| 329 |
+
ts = v.get('created_at')
|
| 330 |
+
rows.append({
|
| 331 |
+
'id': d.id,
|
| 332 |
+
'type': (v.get('transaction_type') or v.get('type') or 'other'),
|
| 333 |
+
'description': det.get('description') or ', '.join(
|
| 334 |
+
f"{it.get('quantity','')} {it.get('item') or it.get('name','')}".strip()
|
| 335 |
+
for it in (det.get('items') or []) if isinstance(it, dict)),
|
| 336 |
+
'amount': analytics.money(det.get('amount') or det.get('total')),
|
| 337 |
+
'currency': normalize_currency_code(det.get('currency'), 'USD'),
|
| 338 |
+
'party': det.get('customer') or det.get('party') or '',
|
| 339 |
+
'createdAt': ts.isoformat() if hasattr(ts, 'isoformat') else (str(ts) if ts else None),
|
| 340 |
+
})
|
| 341 |
+
return jsonify({'transactions': rows}), 200
|
| 342 |
except Exception as e:
|
| 343 |
+
logging.error(f"Error fetching transactions for {uid}: {e}", exc_info=True)
|
| 344 |
+
return jsonify({'error': 'An error occurred while fetching your transactions.'}), 500
|
| 345 |
|
| 346 |
# Replace your existing PUT /api/user/profile with this one.
|
| 347 |
@app.route('/api/user/profile', methods=['PUT'])
|
|
|
|
| 834 |
"""
|
| 835 |
try:
|
| 836 |
verify_admin_and_get_uid(request.headers.get('Authorization'))
|
| 837 |
+
start_date, end_date = parse_date_range(request)
|
| 838 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
all_users_docs = list(db.collection('users').stream())
|
| 840 |
all_orgs_docs = list(db.collection('organizations').stream())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 841 |
|
| 842 |
+
# User/profile stats + approved phone map (profile docs have an 'email')
|
| 843 |
+
pending_approvals = approved_users = admin_count = 0
|
| 844 |
+
phone_to_user = {}
|
| 845 |
for doc in all_users_docs:
|
| 846 |
+
u = doc.to_dict()
|
| 847 |
+
if not u.get('email'):
|
| 848 |
+
continue
|
| 849 |
+
if u.get('isAdmin'):
|
| 850 |
+
admin_count += 1
|
| 851 |
+
status, phone = u.get('phoneStatus'), u.get('phone')
|
| 852 |
+
if status == 'pending':
|
| 853 |
+
pending_approvals += 1
|
| 854 |
+
elif status == 'approved' and phone:
|
| 855 |
+
approved_users += 1
|
| 856 |
+
phone_to_user[phone] = {'displayName': u.get('displayName', 'N/A'), 'uid': u.get('uid'),
|
| 857 |
+
'defaultCurrency': u.get('defaultCurrency', 'USD')}
|
| 858 |
+
user_profile_count = sum(1 for d in all_users_docs if d.to_dict().get('email'))
|
| 859 |
+
|
| 860 |
+
# Aggregate every approved user's v2 ledger and merge globally
|
| 861 |
+
from collections import defaultdict as _dd
|
| 862 |
+
gcur = _dd(lambda: {'sales': 0.0, 'cogs': 0.0, 'expenses': 0.0, 'salesCount': 0})
|
| 863 |
+
item_rev = _dd(lambda: _dd(float)) # item -> cur -> revenue
|
| 864 |
+
exp_cat = _dd(lambda: _dd(float)) # category -> cur -> amount
|
| 865 |
+
user_rev = _dd(lambda: _dd(float)) # phone -> cur -> revenue
|
| 866 |
+
g_cash = g_recv = g_pay = g_stock = 0.0
|
| 867 |
+
|
| 868 |
+
for phone in phone_to_user:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
try:
|
| 870 |
+
txns, batches, customers = fetch_user_ledger(phone.lstrip('+'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
except Exception as e:
|
| 872 |
+
logging.error(f"Admin stats: ledger read failed for {phone}: {e}")
|
| 873 |
continue
|
| 874 |
+
agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
|
| 875 |
+
default_currency=phone_to_user[phone]['defaultCurrency'])
|
| 876 |
+
for cur, m in agg['byCurrency'].items():
|
| 877 |
+
gcur[cur]['sales'] += m['sales']; gcur[cur]['cogs'] += m['cogs']
|
| 878 |
+
gcur[cur]['expenses'] += m['expenses']; gcur[cur]['salesCount'] += m['salesCount']
|
| 879 |
+
user_rev[phone][cur] += m['sales']
|
| 880 |
+
for it in agg['topItems']:
|
| 881 |
+
for cur, v in it['byCurrency'].items():
|
| 882 |
+
item_rev[it['item']][cur] += v
|
| 883 |
+
for ex in agg['topExpenses']:
|
| 884 |
+
for cur, v in ex['byCurrency'].items():
|
| 885 |
+
exp_cat[ex['category']][cur] += v
|
| 886 |
+
g_cash += agg['cashOnHand']; g_recv += agg['receivables']
|
| 887 |
+
g_pay += agg['payables']; g_stock += agg['stockValue']
|
| 888 |
+
|
| 889 |
+
all_currencies = set(gcur)
|
| 890 |
+
system_by_cur, total_count = {}, 0
|
| 891 |
+
for cur, m in gcur.items():
|
| 892 |
+
gross = round(m['sales'] - m['cogs'], 2)
|
| 893 |
+
system_by_cur[cur] = {
|
| 894 |
+
'sales': round(m['sales'], 2), 'cogs': round(m['cogs'], 2), 'grossProfit': gross,
|
| 895 |
+
'expenses': round(m['expenses'], 2), 'netProfit': round(gross - m['expenses'], 2),
|
| 896 |
+
'salesCount': int(m['salesCount'])}
|
| 897 |
+
total_count += int(m['salesCount'])
|
| 898 |
|
| 899 |
+
leaderboards = {'topUsersByRevenue': {}, 'topSellingItems': {}, 'topExpenses': {}}
|
| 900 |
+
for cur in all_currencies:
|
| 901 |
+
users_c = sorted(((p, r.get(cur, 0.0)) for p, r in user_rev.items() if r.get(cur, 0) > 0),
|
| 902 |
+
key=lambda x: -x[1])[:5]
|
| 903 |
+
leaderboards['topUsersByRevenue'][cur] = [
|
| 904 |
+
{'displayName': phone_to_user.get(p, {}).get('displayName'),
|
| 905 |
+
'uid': phone_to_user.get(p, {}).get('uid'), 'totalRevenue': round(rev, 2)} for p, rev in users_c]
|
| 906 |
+
items_c = sorted(((n, t.get(cur, 0.0)) for n, t in item_rev.items() if t.get(cur, 0) > 0),
|
| 907 |
+
key=lambda x: -x[1])[:5]
|
| 908 |
+
leaderboards['topSellingItems'][cur] = [{'item': n, 'totalRevenue': round(v, 2)} for n, v in items_c]
|
| 909 |
+
exp_c = sorted(((n, t.get(cur, 0.0)) for n, t in exp_cat.items() if t.get(cur, 0) > 0),
|
| 910 |
+
key=lambda x: -x[1])[:5]
|
| 911 |
+
leaderboards['topExpenses'][cur] = [{'category': n, 'totalAmount': round(v, 2)} for n, v in exp_c]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
|
| 913 |
return jsonify({
|
| 914 |
+
'userStats': {'total': user_profile_count, 'admins': admin_count,
|
| 915 |
+
'approvedForBot': approved_users, 'pendingApproval': pending_approvals},
|
| 916 |
+
'organizationStats': {'total': len(all_orgs_docs)},
|
| 917 |
+
'systemStats': {
|
| 918 |
+
'byCurrency': system_by_cur,
|
| 919 |
+
'totalSalesRevenueByCurrency': {c: v['sales'] for c, v in system_by_cur.items()},
|
| 920 |
+
'totalCostOfGoodsSoldByCurrency': {c: v['cogs'] for c, v in system_by_cur.items()},
|
| 921 |
+
'totalExpensesByCurrency': {c: v['expenses'] for c, v in system_by_cur.items()},
|
| 922 |
+
'totalNetProfitByCurrency': {c: v['netProfit'] for c, v in system_by_cur.items()},
|
| 923 |
+
'totalSalesCount': total_count,
|
| 924 |
+
'cashOnHand': round(g_cash, 2), 'receivables': round(g_recv, 2),
|
| 925 |
+
'payables': round(g_pay, 2), 'stockValue': round(g_stock, 2)},
|
| 926 |
+
'leaderboards': leaderboards,
|
| 927 |
}), 200
|
| 928 |
|
| 929 |
except PermissionError as e:
|
|
|
|
| 931 |
except Exception as e:
|
| 932 |
logging.error(f"Admin failed to fetch dashboard stats: {e}", exc_info=True)
|
| 933 |
return jsonify({'error': 'An internal error occurred while fetching stats'}), 500
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
@app.route('/api/admin/model-health', methods=['GET'])
|
| 937 |
+
def get_model_health():
|
| 938 |
+
"""Scientific view of how we record & close sessions: the distillation dataset."""
|
| 939 |
+
try:
|
| 940 |
+
verify_admin_and_get_uid(request.headers.get('Authorization'))
|
| 941 |
+
start_date, end_date = parse_date_range(request)
|
| 942 |
+
examples = [d.to_dict() for d in db.collection('distillation_examples').limit(20000).stream()]
|
| 943 |
+
opt_out = sum(1 for d in db.collection('users').stream()
|
| 944 |
+
if d.to_dict().get('distill_consent') is False)
|
| 945 |
+
return jsonify(analytics.model_health(examples, start_date, end_date, opt_out)), 200
|
| 946 |
+
except PermissionError as e:
|
| 947 |
+
return jsonify({'error': str(e)}), 403
|
| 948 |
+
except Exception as e:
|
| 949 |
+
logging.error(f"Admin failed to fetch model health: {e}", exc_info=True)
|
| 950 |
+
return jsonify({'error': 'An internal error occurred while fetching model health'}), 500
|
| 951 |
+
|
| 952 |
+
|
| 953 |
# -----------------------------------------------------------------------------
|
| 954 |
# 8. SERVER EXECUTION
|
| 955 |
# -----------------------------------------------------------------------------
|