# AI_CHANGELOG # - 2026-03-20 | Prompt: RNS1.3 E2E 修复 | _build_recharge 环比比较逻辑修正(compare 参数传递), # _empty_revenue 新增确保包含所有必需字段,skills 暂返回空列表 """ 看板业务逻辑服务层。 提供 BOARD-1(助教看板)、BOARD-2(客户看板)、BOARD-3(财务看板)的 日期范围计算、环比计算等通用工具函数,以及各看板的编排函数。 """ from __future__ import annotations import calendar from datetime import date, timedelta from decimal import Decimal, ROUND_HALF_UP # --------------------------------------------------------------------------- # 通用工具函数 # --------------------------------------------------------------------------- def _calc_date_range( time_enum: str, ref_date: date | None = None ) -> tuple[date, date]: """ 根据时间枚举计算当期日期范围。 支持 BOARD-1 的 6 种枚举(snake_case)和 BOARD-3 的 8 种枚举(camelCase), 同义枚举(如 last_month / lastMonth)映射到相同逻辑。 返回 (start_date, end_date),均为 date 类型,end_date 为区间末日(含)。 """ today = ref_date or date.today() # --- 当月 --- if time_enum == "month": start = today.replace(day=1) end = today.replace(day=calendar.monthrange(today.year, today.month)[1]) return start, end # --- 上月 --- if time_enum in ("last_month", "lastMonth"): first_of_this_month = today.replace(day=1) last_month_end = first_of_this_month - timedelta(days=1) last_month_start = last_month_end.replace(day=1) return last_month_start, last_month_end # --- 本周(周一 ~ 周日)--- if time_enum == "week": monday = today - timedelta(days=today.weekday()) sunday = monday + timedelta(days=6) return monday, sunday # --- 上周 --- if time_enum == "lastWeek": this_monday = today - timedelta(days=today.weekday()) last_sunday = this_monday - timedelta(days=1) last_monday = last_sunday - timedelta(days=6) return last_monday, last_sunday # --- 本季度 --- if time_enum == "quarter": q_start_month = (today.month - 1) // 3 * 3 + 1 start = date(today.year, q_start_month, 1) q_end_month = q_start_month + 2 end = date(today.year, q_end_month, calendar.monthrange(today.year, q_end_month)[1]) return start, end # --- 上季度 --- if time_enum in ("last_quarter", "lastQuarter"): q_start_month = (today.month - 1) // 3 * 3 + 1 # 上季度末日 = 本季度首日前一天 this_q_start = date(today.year, q_start_month, 1) prev_q_end = this_q_start - timedelta(days=1) prev_q_start_month = (prev_q_end.month - 1) // 3 * 3 + 1 prev_q_start = date(prev_q_end.year, prev_q_start_month, 1) return prev_q_start, prev_q_end # --- 前 3 个月(不含本月)--- if time_enum in ("last_3m", "quarter3"): # end = 上月末日 first_of_this_month = today.replace(day=1) end = first_of_this_month - timedelta(days=1) # start = 往前推 3 个月的首日 start = _month_offset(first_of_this_month, -3) return start, end # --- 前 6 个月(不含本月)--- if time_enum in ("last_6m", "half6"): first_of_this_month = today.replace(day=1) end = first_of_this_month - timedelta(days=1) start = _month_offset(first_of_this_month, -6) return start, end raise ValueError(f"不支持的时间枚举: {time_enum}") def _month_offset(d: date, months: int) -> date: """将日期 d 偏移 months 个月,保持 day=1。仅用于内部月份偏移计算。""" # d 应为某月 1 日 total_months = d.year * 12 + (d.month - 1) + months y, m = divmod(total_months, 12) m += 1 return date(y, m, 1) def _calc_prev_range(start_date: date, end_date: date) -> tuple[date, date]: """ 根据当期范围计算上期日期范围。 上期长度等于当期长度,prev_end = start_date - 1 天。 """ period_length = (end_date - start_date).days + 1 prev_end = start_date - timedelta(days=1) prev_start = prev_end - timedelta(days=period_length - 1) return prev_start, prev_end def calc_compare(current: Decimal, previous: Decimal) -> dict: """ 统一环比计算。 返回: - compare: str — "12.5%" / "新增" / "持平" - is_down: bool — 是否下降 - is_flat: bool — 是否持平 规则: - previous=0, current≠0 → "新增", is_down=False, is_flat=False - previous=0, current=0 → "持平", is_down=False, is_flat=True - 正常计算: (current - previous) / previous × 100% - 正值 → is_down=False; 负值 → is_down=True; 零 → is_flat=True """ if previous == 0: if current != 0: return {"compare": "新增", "is_down": False, "is_flat": False} return {"compare": "持平", "is_down": False, "is_flat": True} diff = current - previous pct = (diff / previous * 100).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) if pct > 0: return {"compare": f"{pct}%", "is_down": False, "is_flat": False} if pct < 0: return {"compare": f"{abs(pct)}%", "is_down": True, "is_flat": False} # pct == 0(当期 == 上期) return {"compare": "持平", "is_down": False, "is_flat": True} import logging from typing import Any from fastapi import HTTPException from app.services import fdw_queries logger = logging.getLogger(__name__) def _get_connection(): """延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。""" from app.database import get_connection return get_connection() # --------------------------------------------------------------------------- # 排序映射 # --------------------------------------------------------------------------- _SORT_KEY_MAP = { "perf_desc": ("perf_hours", True), "perf_asc": ("perf_hours", False), "salary_desc": ("salary", True), "salary_asc": ("salary", False), "sv_desc": ("sv_amount", True), "task_desc": ("task_total", True), } _SORT_DIM_MAP = { "perf_desc": "perf", "perf_asc": "perf", "salary_desc": "salary", "salary_asc": "salary", "sv_desc": "sv", "task_desc": "task", } # --------------------------------------------------------------------------- # BOARD-1 助教看板 # --------------------------------------------------------------------------- async def get_coach_board( sort: str, skill: str, time: str, site_id: int ) -> dict: """ BOARD-1:助教看板。扁平返回所有维度字段。 参数互斥:time=last_6m + sort=sv_desc → HTTP 400。 """ # 参数互斥校验 if time == "last_6m" and sort == "sv_desc": raise HTTPException( status_code=400, detail="最近6个月不支持客源储值排序", ) start_date, end_date = _calc_date_range(time) start_str = str(start_date) end_str = str(end_date) conn = _get_connection() try: # 1. 助教列表 assistants = fdw_queries.get_all_assistants(conn, site_id, skill) if not assistants: return {"items": [], "dim_type": _SORT_DIM_MAP.get(sort, "perf")} aid_list = [a["assistant_id"] for a in assistants] # 2. 批量查询绩效 salary_map = fdw_queries.get_salary_calc_batch( conn, site_id, aid_list, start_str, end_str ) # 3. Top 客户(降级为空) top_map: dict[int, list[str]] = {} try: top_map = fdw_queries.get_top_customers_for_coaches( conn, site_id, aid_list ) except Exception: logger.warning("BOARD-1 topCustomers 查询失败,降级为空", exc_info=True) # 4. 储值数据 sv_map: dict[int, dict] = {} try: sv_map = fdw_queries.get_coach_sv_data( conn, site_id, aid_list, start_str, end_str ) except Exception: logger.warning("BOARD-1 sv 数据查询失败,降级为空", exc_info=True) # 5. 任务数据 task_map = _query_coach_tasks(conn, site_id, aid_list, start_str, end_str) # 6. 组装扁平响应 items = [] for a in assistants: aid = a["assistant_id"] sal = salary_map.get(aid, {}) sv = sv_map.get(aid, {}) tasks = task_map.get(aid, {}) top_custs = top_map.get(aid, []) name = a["name"] initial = name[0] if name else "" perf_hours = sal.get("effective_hours", 0.0) salary_val = sal.get("gross_salary", 0.0) task_recall = tasks.get("recall", 0) task_callback = tasks.get("callback", 0) items.append({ "id": aid, "name": name, "initial": initial, "avatar_gradient": "", "level": sal.get("level_name", a.get("level", "")), "skills": [], # CHANGE 2026-03-20 | v_dim_assistant 无 skill 列,暂返回空 "top_customers": top_custs, "perf_hours": perf_hours, "perf_hours_before": None, "perf_gap": None, "perf_reached": False, "salary": salary_val, "salary_perf_hours": perf_hours, "salary_perf_before": None, "sv_amount": sv.get("sv_amount", 0.0), "sv_customer_count": sv.get("sv_customer_count", 0), "sv_consume": sv.get("sv_consume", 0.0), "task_recall": task_recall, "task_callback": task_callback, "task_total": task_recall + task_callback, }) # 7. 排序 sort_key, sort_desc = _SORT_KEY_MAP.get(sort, ("perf_hours", True)) items.sort(key=lambda x: x.get(sort_key, 0), reverse=sort_desc) # 移除内部排序字段 for item in items: item.pop("task_total", None) return { "items": items, "dim_type": _SORT_DIM_MAP.get(sort, "perf"), } finally: conn.close() def _query_coach_tasks( conn: Any, site_id: int, assistant_ids: list[int], start_date: str, end_date: str, ) -> dict[int, dict]: """ 查询助教任务完成数(BOARD-1 task 维度)。 来源: biz.coach_tasks,按 task_type 分类统计 recall/callback。 """ if not assistant_ids: return {} result: dict[int, dict] = {} try: with conn.cursor() as cur: cur.execute( """ SELECT assistant_id, COUNT(*) FILTER (WHERE task_type = 'recall') AS recall_count, COUNT(*) FILTER (WHERE task_type = 'callback') AS callback_count FROM biz.coach_tasks WHERE assistant_id = ANY(%s) AND site_id = %s AND completed_at >= %s::date AND completed_at <= %s::date AND status = 'completed' GROUP BY assistant_id """, (assistant_ids, site_id, start_date, end_date), ) for row in cur.fetchall(): result[row[0]] = { "recall": row[1] or 0, "callback": row[2] or 0, } conn.commit() except Exception: logger.warning("BOARD-1 任务查询失败,降级为空", exc_info=True) return result # --------------------------------------------------------------------------- # BOARD-2 客户看板 # --------------------------------------------------------------------------- # 维度 → FDW 查询函数映射 _DIMENSION_QUERY_MAP = { "recall": "get_customer_board_recall", "potential": "get_customer_board_potential", "balance": "get_customer_board_balance", "recharge": "get_customer_board_recharge", "recent": "get_customer_board_recent", "spend60": "get_customer_board_spend60", "freq60": "get_customer_board_freq60", "loyal": "get_customer_board_loyal", } async def get_customer_board( dimension: str, project: str, page: int, page_size: int, site_id: int ) -> dict: """ BOARD-2:客户看板。按维度返回专属字段 + 分页。 """ query_fn_name = _DIMENSION_QUERY_MAP.get(dimension) if not query_fn_name: raise HTTPException(status_code=400, detail=f"不支持的维度: {dimension}") query_fn = getattr(fdw_queries, query_fn_name) conn = _get_connection() try: # 1. 按维度查询分页数据 result = query_fn(conn, site_id, project, page, page_size) items = result["items"] # 2. 批量查询客户关联助教 member_ids = [item["member_id"] for item in items if item.get("member_id")] assistants_map: dict[int, list[dict]] = {} if member_ids: try: assistants_map = fdw_queries.get_customer_assistants( conn, site_id, member_ids ) except Exception: logger.warning("BOARD-2 客户助教查询失败,降级为空", exc_info=True) # 3. 组装响应(添加基础字段 + assistants) for item in items: mid = item.get("member_id", 0) name = item.get("name", "") item["id"] = mid item["initial"] = name[0] if name else "" item["avatar_cls"] = "" item["assistants"] = assistants_map.get(mid, []) return { "items": items, "total": result["total"], "page": result["page"], "page_size": result["page_size"], } finally: conn.close() # --------------------------------------------------------------------------- # BOARD-3 财务看板 # --------------------------------------------------------------------------- async def get_finance_board( time: str, area: str, compare: int, site_id: int ) -> dict: """ BOARD-3:财务看板。6 板块独立查询、独立降级。 area≠all 时 recharge 返回 null。 compare=1 时计算上期范围并调用 calc_compare。 compare=0 时环比字段为 None(序列化时排除)。 """ start_date, end_date = _calc_date_range(time) start_str = str(start_date) end_str = str(end_date) prev_start_str = None prev_end_str = None if compare == 1: prev_start, prev_end = _calc_prev_range(start_date, end_date) prev_start_str = str(prev_start) prev_end_str = str(prev_end) conn = _get_connection() try: # 各板块独立 try/except overview = _build_overview(conn, site_id, start_str, end_str, prev_start_str, prev_end_str, compare) recharge = None if area == "all": recharge = _build_recharge(conn, site_id, start_str, end_str, prev_start_str, prev_end_str, compare) revenue = _build_revenue(conn, site_id, start_str, end_str, area) cashflow = _build_cashflow(conn, site_id, start_str, end_str, prev_start_str, prev_end_str, compare) expense = _build_expense(conn, site_id, start_str, end_str, prev_start_str, prev_end_str, compare) coach_analysis = _build_coach_analysis(conn, site_id, start_str, end_str, prev_start_str, prev_end_str, compare) return { "overview": overview, "recharge": recharge, "revenue": revenue, "cashflow": cashflow, "expense": expense, "coach_analysis": coach_analysis, } finally: conn.close() def _build_overview( conn: Any, site_id: int, start: str, end: str, prev_start: str | None, prev_end: str | None, compare: int, ) -> dict: """经营一览板块。""" try: data = fdw_queries.get_finance_overview(conn, site_id, start, end) except Exception: logger.warning("overview 查询失败,降级为空", exc_info=True) return _empty_overview() result = {**data} if compare == 1 and prev_start and prev_end: try: prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end) _attach_compare(result, data, prev, [ "occurrence", "discount", "discount_rate", "confirmed_revenue", "cash_in", "cash_out", "cash_balance", "balance_rate", ]) except Exception: logger.warning("overview 环比查询失败", exc_info=True) return result def _build_recharge( conn: Any, site_id: int, start: str, end: str, prev_start: str | None, prev_end: str | None, compare: int, ) -> dict | None: """预收资产板块。""" try: data = fdw_queries.get_finance_recharge(conn, site_id, start, end) except Exception: logger.warning("recharge 查询失败,降级为 null", exc_info=True) return None if compare == 1 and prev_start and prev_end: try: prev = fdw_queries.get_finance_recharge(conn, site_id, prev_start, prev_end) _attach_compare(data, data, prev, [ "actual_income", "first_charge", "renew_charge", "consumed", "card_balance", ]) # 赠送卡矩阵环比 for i, row in enumerate(data.get("gift_rows", [])): prev_row = prev.get("gift_rows", [{}] * 3)[i] if i < len(prev.get("gift_rows", [])) else {} for key in ["total", "liquor", "table_fee", "voucher"]: # gift_rows 的 cell 是 GiftCell dict({"value": float}) cur_cell = row.get(key, {}) prev_cell = prev_row.get(key, {}) cur_val = Decimal(str(cur_cell.get("value", 0) if isinstance(cur_cell, dict) else cur_cell)) prev_val = Decimal(str(prev_cell.get("value", 0) if isinstance(prev_cell, dict) else prev_cell)) cmp = calc_compare(cur_val, prev_val) if isinstance(cur_cell, dict): cur_cell["compare"] = cmp["compare"] cur_cell["down"] = cmp["is_down"] cur_cell["flat"] = cmp["is_flat"] else: row[key] = {"value": float(cur_val), "compare": cmp["compare"], "down": cmp["is_down"], "flat": cmp["is_flat"]} except Exception: logger.warning("recharge 环比查询失败", exc_info=True) return data def _build_revenue( conn: Any, site_id: int, start: str, end: str, area: str, ) -> dict: """应计收入板块。""" try: return fdw_queries.get_finance_revenue(conn, site_id, start, end, area) except Exception: logger.warning("revenue 查询失败,降级为空", exc_info=True) return _empty_revenue() def _build_cashflow( conn: Any, site_id: int, start: str, end: str, prev_start: str | None, prev_end: str | None, compare: int, ) -> dict: """现金流入板块。""" try: data = fdw_queries.get_finance_cashflow(conn, site_id, start, end) except Exception: logger.warning("cashflow 查询失败,降级为空", exc_info=True) return {"consume_items": [], "recharge_items": [], "total": 0.0} return data def _build_expense( conn: Any, site_id: int, start: str, end: str, prev_start: str | None, prev_end: str | None, compare: int, ) -> dict: """现金流出板块。""" try: data = fdw_queries.get_finance_expense(conn, site_id, start, end) except Exception: logger.warning("expense 查询失败,降级为空", exc_info=True) return { "operation_items": [], "fixed_items": [], "coach_items": [], "platform_items": [], "total": 0.0, } if compare == 1 and prev_start and prev_end: try: prev = fdw_queries.get_finance_expense(conn, site_id, prev_start, prev_end) total_cmp = calc_compare( Decimal(str(data["total"])), Decimal(str(prev["total"])) ) data["total_compare"] = total_cmp["compare"] data["total_down"] = total_cmp["is_down"] data["total_flat"] = total_cmp["is_flat"] except Exception: logger.warning("expense 环比查询失败", exc_info=True) return data def _build_coach_analysis( conn: Any, site_id: int, start: str, end: str, prev_start: str | None, prev_end: str | None, compare: int, ) -> dict: """助教分析板块。""" try: data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end) except Exception: logger.warning("coachAnalysis 查询失败,降级为空", exc_info=True) empty_table = {"total_pay": 0.0, "total_share": 0.0, "avg_hourly": 0.0, "rows": []} return {"basic": empty_table, "incentive": {**empty_table}} if compare == 1 and prev_start and prev_end: try: prev = fdw_queries.get_finance_coach_analysis( conn, site_id, prev_start, prev_end ) for key in ("basic", "incentive"): cur_t = data[key] prev_t = prev[key] _attach_compare(cur_t, cur_t, prev_t, [ "total_pay", "total_share", "avg_hourly", ]) except Exception: logger.warning("coachAnalysis 环比查询失败", exc_info=True) return data # --------------------------------------------------------------------------- # 环比辅助 # --------------------------------------------------------------------------- def _attach_compare( target: dict, current: dict, previous: dict, fields: list[str] ) -> None: """为 target dict 中的指定字段附加环比三元组。""" for field in fields: cur_val = Decimal(str(current.get(field, 0))) prev_val = Decimal(str(previous.get(field, 0))) cmp = calc_compare(cur_val, prev_val) target[f"{field}_compare"] = cmp["compare"] target[f"{field}_down"] = cmp["is_down"] target[f"{field}_flat"] = cmp["is_flat"] # --------------------------------------------------------------------------- # 空默认值工厂(优雅降级用) # --------------------------------------------------------------------------- def _empty_overview() -> dict: return { "occurrence": 0.0, "discount": 0.0, "discount_rate": 0.0, "confirmed_revenue": 0.0, "cash_in": 0.0, "cash_out": 0.0, "cash_balance": 0.0, "balance_rate": 0.0, } def _empty_revenue() -> dict: """应计收入空默认值(优雅降级用)。 CHANGE 2026-03-20 | 新增,确保包含所有必需字段: price_items, total_occurrence, confirmed_total, channel_items """ return { "structure_rows": [], "price_items": [], "total_occurrence": 0.0, "discount_items": [], "confirmed_total": 0.0, "channel_items": [], }