"""管理后台仪表盘统计 (T6.2). 提供一组纯函数,接受 db_path 返回仪表盘 payload。SQL 聚合在 :mod:`data.orders.dao.OrdersDAO` 之外的轻量连接里直接执行 (避免 DAO 解密 PII 字段带来的开销) — 统计只涉及 ``status`` / ``source`` / ``service_version`` / ``amount_cents`` / ``created_at`` 等 非敏感列。 公开 API -------- - :func:`build_dashboard_payload` : 一次返回 dashboard 全部数据 - :func:`compute_summary` : 汇总卡片 (订单/用户/收入 + 今日/7d/30d) - :func:`compute_by_status` : 6 态分布 - :func:`compute_by_source` : 来源分布 - :func:`compute_by_service_version` : 服务版本分布 - :func:`compute_trends` : 今日 / 7d / 30d 趋势 (日粒度) - :func:`generate_day_series` : 趋势补零辅助 (0 填充生成完整序列) 口径约定 -------- - **收入 (revenue)** = 所有 **非 pending 且非 refunded** 订单的 ``amount_cents`` 累计值。pending 表示未付款,refunded 表示已退款, 两者均不计入有效收入。 - **趋势桶粒度** = 日 (UTC 日期, ``YYYY-MM-DD``)。ISO8601 字符串的 前 10 位切片 (``substr(created_at, 1, 10)``) 与 UTC 日期等价。 - **"今日"** = 服务器当前 UTC 日;**7d/30d** = 含今日回溯 7/30 个 完整日。窗口外的点会被丢弃,缺失日以 0 填充。 - **不读 PII** : 统计路径只触碰 ``amount_cents``/``status``/``source`` /``service_version``/``created_at``,避免进入加密层。 """ from __future__ import annotations import sqlite3 from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Sequence from admin.db import get_connection # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- # 收入口径:进入这些状态的订单算有效收入 (paid 之后才付款, refunded 排除) _REVENUE_STATUSES: tuple[str, ...] = ( "paid", "serving", "delivered", "completed", ) # 完整 6 态 (分布统计时强制输出 0 计数) _ALL_STATUSES: tuple[str, ...] = ( "pending", "paid", "serving", "delivered", "completed", "refunded", ) # 完整来源 (与 meta.py 对齐) _ALL_SOURCES: tuple[str, ...] = ("xianyu", "wechat", "web", "school") # 完整服务版本 _ALL_SERVICE_VERSIONS: tuple[str, ...] = ("audit", "basic", "standard", "premium") # --------------------------------------------------------------------------- # 数据结构 # --------------------------------------------------------------------------- @dataclass(frozen=True) class TrendPoint: """单日趋势点。""" date: str # YYYY-MM-DD orders: int revenue_cents: int def to_dict(self) -> dict: return { "date": self.date, "orders": int(self.orders), "revenue_cents": int(self.revenue_cents), } # --------------------------------------------------------------------------- # 时间窗口辅助 # --------------------------------------------------------------------------- def _utc_today() -> datetime: """返回当前 UTC 的午夜 (00:00:00)。""" now = datetime.now(timezone.utc) return now.replace(hour=0, minute=0, second=0, microsecond=0) def _date_range(end: datetime, days: int) -> List[str]: """返回 end 当天及之前 ``days`` 个 UTC 日期字符串 (含端点)。""" return [ (end - timedelta(days=i)).date().isoformat() for i in range(days - 1, -1, -1) ] def _format_utc_now_iso() -> str: """当前 UTC 时间的 ISO8601 字符串 (秒精度)。""" return datetime.now(timezone.utc).replace(microsecond=0).isoformat() # --------------------------------------------------------------------------- # 单查询辅助 # --------------------------------------------------------------------------- def _open_conn(db_path: str) -> sqlite3.Connection: """打开 SQLite 连接 (统一走 admin.db.get_connection 避免 PRAGMA 分裂)。""" return get_connection(db_path) def _fetchall_dict( conn: sqlite3.Connection, sql: str, params: Sequence[Any] = () ) -> List[dict]: """执行查询并以 dict 形式返回全部行。""" cur = conn.execute(sql, params) rows = cur.fetchall() out: List[dict] = [] for row in rows: # sqlite3.Row 走 keys() + 索引; tuple 走索引 if hasattr(row, "keys"): out.append({k: row[k] for k in row.keys()}) else: out.append(dict(row)) return out # --------------------------------------------------------------------------- # 聚合函数 # --------------------------------------------------------------------------- def compute_summary( orders_db_path: str, admin_db_path: Optional[str] = None, *, today: Optional[datetime] = None, ) -> dict: """汇总卡片:订单/用户/收入/待处理多口径,以及今日/7d/30d 三个窗口的切片。 待处理订单 (pending_orders) 提供三个互不互斥的视角,用于运营快速分流: - pending_orders : 所有 status='pending' 的订单数 - pending_overdue_24h : 24h 内仍未付款的待处理订单(超时未付,需主动催付) - pending_missing_intake : 未提交完整报名资料的待处理订单(资料待补,需主动跟进) Args: orders_db_path: orders / order_status_history / order_intakes 所在 DB (data.orders.*) admin_db_path: admin_users 所在 DB;传 None 时跳过用户统计,返回 0 today: 测试可注入的"当前 UTC 日" (默认 = 真实 now) Returns: dict 形如:: { "total_orders": int, "total_revenue_cents": int, "total_users": int, "pending_orders": int, "pending_overdue_24h": int, "pending_missing_intake": int, "orders_today": int, "orders_7d": int, "orders_30d": int, "revenue_today_cents": int, "revenue_7d_cents": int, "revenue_30d_cents": int, } """ if today is None: today = _utc_today() today_iso = today.date().isoformat() seven_ago = (today - timedelta(days=6)).date().isoformat() # 7 天窗口含今天 thirty_ago = (today - timedelta(days=29)).date().isoformat() # 30 天窗口含今天 # 24h 超时切割点: 当前 UTC 时刻往前 24h, ISO8601 字符串 overdue_cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).replace(microsecond=0).isoformat() revenue_status_placeholder = ",".join("?" for _ in _REVENUE_STATUSES) with _open_conn(orders_db_path) as conn: # 总订单数 / 总收入 / 待处理多口径 (一次性走单条聚合 SQL) # pending_missing_intake 走 LEFT JOIN order_intakes 判定: # - 没记录 OR 记录 status='draft' → 资料未提交完整 # - 记录 status='submitted' → 资料已完整, 不算缺失 row = conn.execute( f""" SELECT COUNT(*) AS total_orders, COALESCE(SUM( CASE WHEN orders.status IN ({revenue_status_placeholder}) THEN amount_cents ELSE 0 END ), 0) AS total_revenue_cents, COALESCE(SUM( CASE WHEN orders.status = 'pending' THEN 1 ELSE 0 END ), 0) AS pending_orders, COALESCE(SUM( CASE WHEN orders.status = 'pending' AND orders.created_at < ? THEN 1 ELSE 0 END ), 0) AS pending_overdue_24h, COALESCE(SUM( CASE WHEN orders.status = 'pending' AND ( i.status IS NULL OR i.status = 'draft' ) THEN 1 ELSE 0 END ), 0) AS pending_missing_intake FROM orders LEFT JOIN order_intakes AS i ON i.order_id = orders.id """, (*_REVENUE_STATUSES, overdue_cutoff), ).fetchone() # 今日 / 7d / 30d 切片 (用 substr 切日期) slice_row = conn.execute( f""" SELECT COALESCE(SUM( CASE WHEN substr(created_at, 1, 10) = ? THEN 1 ELSE 0 END ), 0) AS orders_today, COALESCE(SUM( CASE WHEN substr(created_at, 1, 10) >= ? THEN 1 ELSE 0 END ), 0) AS orders_7d, COALESCE(SUM( CASE WHEN substr(created_at, 1, 10) >= ? THEN 1 ELSE 0 END ), 0) AS orders_30d, COALESCE(SUM( CASE WHEN substr(created_at, 1, 10) = ? AND status IN ({revenue_status_placeholder}) THEN amount_cents ELSE 0 END ), 0) AS revenue_today_cents, COALESCE(SUM( CASE WHEN substr(created_at, 1, 10) >= ? AND status IN ({revenue_status_placeholder}) THEN amount_cents ELSE 0 END ), 0) AS revenue_7d_cents, COALESCE(SUM( CASE WHEN substr(created_at, 1, 10) >= ? AND status IN ({revenue_status_placeholder}) THEN amount_cents ELSE 0 END ), 0) AS revenue_30d_cents FROM orders """, ( today_iso, seven_ago, thirty_ago, today_iso, *_REVENUE_STATUSES, seven_ago, *_REVENUE_STATUSES, thirty_ago, *_REVENUE_STATUSES, ), ).fetchone() # 用户数 (单独连接 admin DB,避免在 orders DB 上去找可能不存在的表) total_users = 0 if admin_db_path is not None: with _open_conn(admin_db_path) as conn: user_row = conn.execute("SELECT COUNT(*) AS n FROM admin_users").fetchone() total_users = int(user_row["n"] or 0) return { "total_orders": int(row["total_orders"] or 0), "total_revenue_cents": int(row["total_revenue_cents"] or 0), "total_users": total_users, "pending_orders": int(row["pending_orders"] or 0), "pending_overdue_24h": int(row["pending_overdue_24h"] or 0), "pending_missing_intake": int(row["pending_missing_intake"] or 0), "orders_today": int(slice_row["orders_today"] or 0), "orders_7d": int(slice_row["orders_7d"] or 0), "orders_30d": int(slice_row["orders_30d"] or 0), "revenue_today_cents": int(slice_row["revenue_today_cents"] or 0), "revenue_7d_cents": int(slice_row["revenue_7d_cents"] or 0), "revenue_30d_cents": int(slice_row["revenue_30d_cents"] or 0), } def compute_by_status(db_path: str) -> Dict[str, int]: """按 status 分组统计订单数,缺失的 6 态以 0 填充。""" with _open_conn(db_path) as conn: rows = _fetchall_dict( conn, "SELECT status, COUNT(*) AS n FROM orders GROUP BY status", ) result: Dict[str, int] = {s: 0 for s in _ALL_STATUSES} for r in rows: s = str(r.get("status", "")) if s in result: result[s] = int(r.get("n", 0) or 0) return result def compute_by_source(db_path: str) -> Dict[str, int]: """按 source 分组统计订单数,缺失的来源以 0 填充。""" with _open_conn(db_path) as conn: rows = _fetchall_dict( conn, "SELECT source, COUNT(*) AS n FROM orders GROUP BY source", ) result: Dict[str, int] = {s: 0 for s in _ALL_SOURCES} for r in rows: s = str(r.get("source", "")) if s in result: result[s] = int(r.get("n", 0) or 0) return result def compute_by_service_version(db_path: str) -> Dict[str, int]: """按 service_version 分组统计订单数,缺失版本以 0 填充。""" with _open_conn(db_path) as conn: rows = _fetchall_dict( conn, "SELECT service_version, COUNT(*) AS n FROM orders GROUP BY service_version", ) result: Dict[str, int] = {s: 0 for s in _ALL_SERVICE_VERSIONS} for r in rows: s = str(r.get("service_version", "")) if s in result: result[s] = int(r.get("n", 0) or 0) return result def generate_day_series( db_path: str, *, days: int, today: Optional[datetime] = None ) -> List[TrendPoint]: """按日粒度聚合订单数 + 收入(收入走 :data:`_REVENUE_STATUSES` 口径),并 0 填充空日。 Args: db_path: SQLite 路径 days: 窗口大小 (7 / 30);含今天,共生成 ``days`` 个点 today: 测试可注入的"当前 UTC 日" Returns: 按日期升序排列的 :class:`TrendPoint` 列表,长度 = ``days``。 """ if days <= 0: raise ValueError(f"days 必须 >= 1, 得到 {days}") if today is None: today = _utc_today() end = today start = end - timedelta(days=days - 1) start_iso = start.date().isoformat() end_iso = end.date().isoformat() date_keys = _date_range(end, days) revenue_status_placeholder = ",".join("?" for _ in _REVENUE_STATUSES) sql = f""" SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS orders, COALESCE(SUM( CASE WHEN status IN ({revenue_status_placeholder}) THEN amount_cents ELSE 0 END ), 0) AS revenue_cents FROM orders WHERE substr(created_at, 1, 10) >= ? AND substr(created_at, 1, 10) <= ? GROUP BY substr(created_at, 1, 10) """ params: List[Any] = [*_REVENUE_STATUSES, start_iso, end_iso] with _open_conn(db_path) as conn: rows = _fetchall_dict(conn, sql, params) by_day: Dict[str, TrendPoint] = {} for r in rows: day = str(r.get("day") or "") if not day: continue by_day[day] = TrendPoint( date=day, orders=int(r.get("orders", 0) or 0), revenue_cents=int(r.get("revenue_cents", 0) or 0), ) return [ by_day.get(d, TrendPoint(date=d, orders=0, revenue_cents=0)) for d in date_keys ] def compute_trends( db_path: str, *, today: Optional[datetime] = None ) -> Dict[str, List[dict]]: """返回今日 / 7d / 30d 三个窗口的趋势点 (每个点 to_dict)。 "今日" 返回 1 个点 (当前 UTC 日); 7d / 30d 分别返回 7 / 30 个点。 """ return { "today": [ p.to_dict() for p in generate_day_series(db_path, days=1, today=today) ], "7d": [p.to_dict() for p in generate_day_series(db_path, days=7, today=today)], "30d": [ p.to_dict() for p in generate_day_series(db_path, days=30, today=today) ], } # --------------------------------------------------------------------------- # 顶层 payload # --------------------------------------------------------------------------- def build_dashboard_payload( orders_db_path: str, admin_db_path: Optional[str] = None, *, today: Optional[datetime] = None, ) -> dict: """组装仪表盘一站式 payload,供 ``GET /api/stats/dashboard`` 渲染。 Args: orders_db_path: orders / order_status_history 所在 DB admin_db_path: admin_users 所在 DB (传 None 时 ``total_users`` 为 0) today: 测试可注入的"当前 UTC 日" Returns: dict,字段详见模块 docstring。空库也返回完整骨架 (全 0 计数 + 完整 趋势序列),前端可直接渲染。 """ return { "summary": compute_summary( orders_db_path, admin_db_path=admin_db_path, today=today ), "by_status": compute_by_status(orders_db_path), "by_source": compute_by_source(orders_db_path), "by_service_version": compute_by_service_version(orders_db_path), "trends": compute_trends(orders_db_path, today=today), "generated_at": _format_utc_now_iso(), } # --------------------------------------------------------------------------- # 兼容层 — /api/stats/orders 老端点 # --------------------------------------------------------------------------- # # T6.1 时挂在 ``/api/stats/orders`` 上的占位端点 (返回 ``_stub=True``) # 在 T6.2 被赋以"订单维度统计"的实际意义,前端若还在用旧契约 (只读 # by_status / by_source / by_service_version / total_orders / # total_revenue_cents) 不会破。前端新接 dashboard 端点可获完整聚合。 # --------------------------------------------------------------------------- def build_order_stats_payload( orders_db_path: str, admin_db_path: Optional[str] = None, *, today: Optional[datetime] = None, ) -> dict: """``/api/stats/orders`` 真实数据。 字段集 = :func:`build_dashboard_payload` 的"订单子集",保持 T6.1 stub 阶段的字段名不变,避免前端破契约。 Args: orders_db_path: orders / order_status_history 所在 DB admin_db_path: 当前未使用 (T6.2 端点不含 user 字段),保留便于未来扩展 today: 测试可注入的"当前 UTC 日" """ del admin_db_path # 当前未消费,显式忽略 summary = compute_summary(orders_db_path, today=today) return { "total_orders": summary["total_orders"], "total_revenue_cents": summary["total_revenue_cents"], "by_status": compute_by_status(orders_db_path), "by_source": compute_by_source(orders_db_path), "by_service_version": compute_by_service_version(orders_db_path), } __all__ = [ "TrendPoint", "build_dashboard_payload", "build_order_stats_payload", "compute_summary", "compute_by_status", "compute_by_source", "compute_by_service_version", "compute_trends", "generate_day_series", ]