# 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 from app.trace.decorators import trace_service # --------------------------------------------------------------------------- # 通用工具函数 # --------------------------------------------------------------------------- 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() # --- 当月(cap 到今天)--- if time_enum == "month": start = today.replace(day=1) end = today 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()) end = today return monday, end # --- 上周 --- 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 # --- 本季度(cap 到今天)--- if time_enum == "quarter": q_start_month = (today.month - 1) // 3 * 3 + 1 start = date(today.year, q_start_month, 1) end = today 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( time_enum: str, start_date: date, end_date: date ) -> tuple[date, date]: """ 根据当期范围和周期类型计算上期同期日期范围。 CHANGE 2026-03-28 | 环比改为同期对比: - month: 当期 3/1~3/28 → 上期 2/1~2/28(上月同日) - week: 当期 周一~周四 → 上期 上周一~上周四(上周同天数) - quarter: 当期 1/1~3/28 → 上期 去年10/1~10/28 对应天数 - lastMonth: 2/1~2/28 → 1/1~1/28(再上月同天数) - lastWeek: 上周一~上周日 → 再上周一~再上周日 - lastQuarter: 上季度完整 → 再上季度完整 - quarter3/half6: 往前推等长天数(无明确"同期"概念) """ elapsed_days = (end_date - start_date).days # 当期已过天数(0-indexed) # 月度类:上月1日 + 同样天数 if time_enum in ("month", "last_month", "lastMonth"): prev_start = _month_offset(start_date, -1) # 上月同日,但不超过上月末日 prev_end_day = min(end_date.day, calendar.monthrange(prev_start.year, prev_start.month)[1]) prev_end = prev_start.replace(day=prev_end_day) return prev_start, prev_end # 周度类:往前推 7 天 if time_enum in ("week", "lastWeek"): prev_start = start_date - timedelta(days=7) prev_end = end_date - timedelta(days=7) return prev_start, prev_end # 季度类:上季度首日 + 同样天数 if time_enum in ("quarter", "last_quarter", "lastQuarter"): prev_q_start = _month_offset(start_date, -3) prev_end = prev_q_start + timedelta(days=elapsed_days) # 不超过上季度末日 prev_q_end_month = prev_q_start.month + 2 prev_q_end_max = date(prev_q_start.year, prev_q_end_month, calendar.monthrange(prev_q_start.year, prev_q_end_month)[1]) if prev_end > prev_q_end_max: prev_end = prev_q_end_max return prev_q_start, prev_end # 其他(quarter3/half6):往前推等长天数 period_length = elapsed_days + 1 prev_end = start_date - timedelta(days=1) prev_start = prev_end - timedelta(days=period_length - 1) return prev_start, prev_end @trace_service(description_zh="计算对比数据", description_en="Calc Compare") 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), } # 项目标签 category_code → 前端显示文本 / CSS 类名 _SKILL_DISPLAY = { "BILLIARD": "🎱", "SNOOKER": "斯", "MAHJONG": "🀄", "KTV": "🎤", } _SKILL_CLS = { "BILLIARD": "skill--chinese", "SNOOKER": "skill--snooker", "MAHJONG": "skill--mahjong", "KTV": "skill--karaoke", } _SORT_DIM_MAP = { "perf_desc": "perf", "perf_asc": "perf", "salary_desc": "salary", "salary_asc": "salary", "sv_desc": "sv", "task_desc": "task", } # --------------------------------------------------------------------------- # BOARD-1 助教看板 # --------------------------------------------------------------------------- @trace_service("获取助教看板", "Get coach board") async def get_coach_board( sort: str, skill: str, time: str, page: int, page_size: int, 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. 查询档位配置,计算距升档(仅本月/上月有意义) tier_nodes: list[float] = [] show_perf_gap = time in ("month", "last_month") if show_perf_gap: try: tiers = fdw_queries.get_performance_tiers(conn, site_id) tier_nodes = [float(t["min_hours"]) for t in tiers] if tiers else [] except Exception as e: logger.warning("BOARD-1 档位配置查询失败: %s", e, exc_info=True) # 7. 组装扁平响应 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 = float(sal.get("effective_hours", 0) or 0) salary_val = float(sal.get("gross_salary", 0) or 0) task_recall = tasks.get("recall", 0) task_callback = tasks.get("callback", 0) # 折前课时:当 effective_hours != raw_hours 时显示(惩罚扣减导致的差异) # 惩罚规则:同台 >2 助教重叠,per_hour_contribution < 24 元时按比例扣减 raw_hours = float(sal.get("raw_hours", 0) or 0) perf_hours_before = None if abs(perf_hours - raw_hours) > 0.01: perf_hours_before = raw_hours # 计算距升档差距 perf_gap = None perf_reached = False if tier_nodes and perf_hours is not None: # 找到下一个未达到的档位 for threshold in tier_nodes: if perf_hours < threshold: gap = threshold - perf_hours perf_gap = f"距升档 {gap:.1f}h" break else: perf_reached = True # 已达到最高档 items.append({ "id": aid, "name": name, "initial": initial, "avatar_gradient": "", "level": sal.get("level_name", a.get("level", "")), # CHANGE 2026-03-29 | 从 get_all_assistants 返回的 skill 字段取项目标签 # Schema 要求 list[CoachSkillItem]({text, cls}),不是纯字符串 # text 映射为中文短名 + emoji,cls 映射为 CSS 类名 "skills": [ {"text": _SKILL_DISPLAY.get(s, s), "cls": _SKILL_CLS.get(s, "")} for s in (a.get("skill") or "").split(",") if s ], "top_customers": top_custs, "perf_hours": perf_hours, "perf_hours_before": perf_hours_before, "perf_gap": perf_gap, "perf_reached": perf_reached, "salary": salary_val, "salary_perf_hours": perf_hours, "salary_perf_before": perf_hours_before, "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. 排序(id 作 tiebreaker 保证分页稳定) sort_key, sort_desc = _SORT_KEY_MAP.get(sort, ("perf_hours", True)) items.sort(key=lambda x: (x.get(sort_key, 0), x.get("id", 0)), reverse=sort_desc) # 移除内部排序字段 for item in items: item.pop("task_total", None) # 8. 分页 total = len(items) start = (page - 1) * page_size items = items[start : start + page_size] return { "items": items, "total": total, "page": page, "page_size": page_size, "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 维度)。 CHANGE 2026-04-08 | Fix-13 改造: - recall: 广义召回数(从 biz.recall_events 统计,按天去重,不重复叠加) - callback: 回访完成数(从 biz.coach_tasks 统计,status='completed') """ if not assistant_ids: return {} result: dict[int, dict] = {} try: with conn.cursor() as cur: # 广义召回数:从 recall_events 统计(天然去重) cur.execute( """ SELECT assistant_id, COUNT(*) AS recall_count FROM biz.recall_events WHERE assistant_id = ANY(%s) AND site_id = %s AND pay_time >= %s::date AND pay_time < (%s::date + INTERVAL '1 day') GROUP BY assistant_id """, (assistant_ids, site_id, start_date, end_date), ) for row in cur.fetchall(): result.setdefault(row[0], {"recall": 0, "callback": 0}) result[row[0]]["recall"] = row[1] or 0 # 回访完成数:从 coach_tasks 统计 cur.execute( """ SELECT assistant_id, COUNT(*) 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 + INTERVAL '1 day')::timestamptz AND status = 'completed' AND task_type = 'follow_up_visit' GROUP BY assistant_id """, (assistant_ids, site_id, start_date, end_date), ) for row in cur.fetchall(): result.setdefault(row[0], {"recall": 0, "callback": 0}) result[row[0]]["callback"] = row[1] or 0 conn.commit() except Exception: logger.warning("BOARD-1 任务查询失败,降级为空", exc_info=True) return result # --------------------------------------------------------------------------- # BOARD-2 客户看板 # --------------------------------------------------------------------------- def _batch_ideal_days(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, int]: """批量查询客户理想到店间隔天数(balance/recharge 维度头部用)。""" from app.services.fdw_queries import _fdw_context result: dict[int, int] = {} try: with _fdw_context(conn, site_id) as cur: cur.execute( """ SELECT member_id, COALESCE(ideal_interval_days, 0) FROM app.v_dws_member_winback_index WHERE member_id = ANY(%s) """, (member_ids,), ) for row in cur.fetchall(): result[row[0]] = int(row[1]) if row[1] is not None else 0 except Exception: logger.warning("_batch_ideal_days 查询失败", exc_info=True) return result def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[dict]]: """批量查询客户-助教服务明细(loyal 维度 coachDetails 用)。每个客户前 5 个。""" from app.services.fdw_queries import _fdw_context result: dict[int, list[dict]] = {mid: [] for mid in member_ids} try: with _fdw_context(conn, site_id) as cur: # CHANGE 2026-03-29 | coach_spend 改为从 dwd_assistant_service_log 聚合 60 天消费 cur.execute( """ SELECT ri.member_id, COALESCE(da.nickname, da.real_name, '') AS name, ri.rs_display, ri.session_count, ri.total_duration_minutes, COALESCE(s60.spend_60d, 0) AS spend_60d FROM app.v_dws_member_assistant_relation_index ri LEFT JOIN app.v_dim_assistant da ON ri.assistant_id = da.assistant_id AND da.scd2_is_current = 1 LEFT JOIN ( SELECT tenant_member_id, site_assistant_id, SUM(ledger_amount) AS spend_60d FROM app.v_dwd_assistant_service_log WHERE is_delete = 0 AND create_time >= CURRENT_DATE - INTERVAL '60 days' AND tenant_member_id = ANY(%s) GROUP BY tenant_member_id, site_assistant_id ) s60 ON ri.member_id = s60.tenant_member_id AND ri.assistant_id = s60.site_assistant_id WHERE ri.member_id = ANY(%s) AND (da.leave_status IS NULL OR da.leave_status = 0) ORDER BY ri.member_id, ri.rs_display DESC """, (member_ids, member_ids), ) for row in cur.fetchall(): mid = row[0] if mid in result and len(result[mid]) < 5: svc_count = row[3] or 0 total_mins = float(row[4]) if row[4] else 0.0 avg_dur = round(total_mins / 60 / svc_count, 1) if svc_count > 0 else 0.0 result[mid].append({ "name": row[1] or "", "cls": "", "heart_score": float(row[2]) if row[2] is not None else 0.0, "avg_duration": f"{avg_dur}h", "service_count": str(svc_count), "coach_spend": float(row[5]) if row[5] is not None else 0.0, "relation_idx": float(row[2]) if row[2] is not None else 0.0, }) except Exception: logger.warning("_batch_coach_details 查询失败", exc_info=True) return result def _batch_member_projects(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[str]]: """批量查询客户项目标签(BOARD-2 用)。通过 FDW 视图查询。""" from app.services.fdw_queries import _fdw_context result: dict[int, list[str]] = {mid: [] for mid in member_ids} try: with _fdw_context(conn, site_id) as cur: cur.execute( """ SELECT member_id, array_agg(DISTINCT category_code) FROM app.v_dws_member_project_tag WHERE member_id = ANY(%s) AND is_tagged = true GROUP BY member_id """, (member_ids,), ) for row in cur.fetchall(): mid = row[0] codes = row[1] or [] if mid in result: result[mid] = [c for c in codes if c] except Exception: logger.warning("_batch_member_projects 查询失败", exc_info=True) return result # 维度 → 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", } @trace_service("获取客户看板", "Get customer board") 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) # 2b. 批量查询客户项目标签 member_projects: dict[int, list[str]] = {} if member_ids: try: member_projects = _batch_member_projects(conn, site_id, member_ids) except Exception: logger.warning("BOARD-2 客户项目标签查询失败,降级为空", exc_info=True) # 2c. balance/recharge 维度:补充 ideal_days if dimension in ("balance", "recharge") and member_ids: try: ideal_map = _batch_ideal_days(conn, site_id, member_ids) for item in items: mid = item.get("member_id", 0) if item.get("ideal_days") is None: item["ideal_days"] = ideal_map.get(mid, 0) except Exception: logger.warning("BOARD-2 ideal_days 查询失败", 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, []) item["projects"] = member_projects.get(mid, []) # 3b. loyal 维度:为每个客户补充 coach_details(前 5 个助教的服务明细) if dimension == "loyal" and member_ids: try: coach_details_map = _batch_coach_details(conn, site_id, member_ids) for item in items: mid = item.get("member_id", 0) item["coach_details"] = coach_details_map.get(mid, []) except Exception: logger.warning("BOARD-2 loyal coachDetails 查询失败", exc_info=True) for item in items: item["coach_details"] = [] # CHANGE 2026-03-28 | P5 联调修复:items 是 list[dict],Pydantic CamelModel # 不会自动转换内部 dict 的 key。手动 snake_case → camelCase。 # CHANGE 2026-03-29 | 递归处理嵌套 list[dict](如 assistants 数组) def _to_camel(key: str) -> str: parts = key.split("_") return parts[0] + "".join(p.capitalize() for p in parts[1:]) def _camel_dict(d: dict) -> dict: result = {} for k, v in d.items(): ck = _to_camel(k) if isinstance(v, list): result[ck] = [_camel_dict(i) if isinstance(i, dict) else i for i in v] elif isinstance(v, dict): result[ck] = _camel_dict(v) else: result[ck] = v return result camel_items = [_camel_dict(item) for item in items] return { "items": camel_items, "total": result["total"], "page": result["page"], "page_size": result["page_size"], } finally: conn.close() # --------------------------------------------------------------------------- # BOARD-3 财务看板 # --------------------------------------------------------------------------- # CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 | 缓存/日粒度查询路由 COMPLETED_PERIODS = {"lastMonth", "lastWeek", "lastQuarter", "quarter3", "half6"} CURRENT_PERIODS = {"month", "week", "quarter"} @trace_service("获取财务看板", "Get finance board") async def get_finance_board( time: str, area: str, compare: int, site_id: int ) -> dict: """ BOARD-3:财务看板。6 板块独立查询、独立降级。 CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 | - 已完成周期先查缓存 → 未命中从日粒度表 SUM → 写缓存 - 当期周期直接从日粒度表 SUM,不查缓存 - overview/revenue 改为从 dws_finance_area_daily 按 area_code 查询 - cashflow/expense/coach_analysis 不变(始终用全局数据) - area≠all 时 recharge 返回 null - area≠all 时 overview 覆盖逻辑保留 - compare=1 时对上期执行同样缓存/日粒度逻辑 """ 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(time, 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, area) 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, prev_start_str, prev_end_str, compare) # CHANGE 2026-03-28 | 非全部区域时,用 revenue 的数据覆盖 overview 的发生额/优惠/确认收入 if area != "all" and revenue: overview["occurrence"] = revenue.get("total_occurrence", 0.0) overview["discount"] = revenue.get("discount_total", 0.0) overview["confirmed_revenue"] = revenue.get("confirmed_total", 0.0) # discount_rate 重算 occ = overview["occurrence"] overview["discount_rate"] = (overview["discount"] / occ) if occ > 0 else 0.0 # CHANGE 2026-03-29 | area≠all 时隐藏实收流水(现金流 4 项无法按区域拆分) overview["cash_in"] = None overview["cash_out"] = None overview["cash_balance"] = None overview["balance_rate"] = None # 移除现金流环比字段(如有) for f in ("cash_in", "cash_out", "cash_balance", "balance_rate"): overview.pop(f"{f}_compare", None) overview.pop(f"{f}_down", None) overview.pop(f"{f}_flat", None) # CHANGE 2026-03-29 | area≠all 时隐藏现金流入和现金流出板块 cashflow = None expense = None if area == "all": 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, area) 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, area: str = "all", ) -> dict: """经营一览板块。 CHANGE 2026-04-08 | P0 修复发生额失真 | area=all 时回退到 dws_finance_daily_summary(get_finance_overview), 仅 area≠all 时走 dws_finance_area_daily(get_finance_overview_area)。 原因:area_daily 的 all 行只聚合有桌台映射的结算单,漏算无桌台单据约 12%。 """ try: if area == "all": data = fdw_queries.get_finance_overview(conn, site_id, start, end) else: data = fdw_queries.get_finance_overview_area(conn, site_id, start, end, area) except Exception: logger.warning("overview 查询失败,降级为空", exc_info=True) return _empty_overview() result = {**data} if compare == 1 and prev_start and prev_end: try: if area == "all": prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end) else: prev = fdw_queries.get_finance_overview_area(conn, site_id, prev_start, prev_end, area) _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", "all_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, prev_start: str | None = None, prev_end: str | None = None, compare: int = 0, ) -> dict: """应计收入板块。 CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 | 改为从 dws_finance_area_daily 按 area_code 查询(通过 get_finance_revenue_area), 然后在 Python 层构建 structure_rows / discount_items / channel_items 保持返回结构不变。 """ try: if area == "all": # CHANGE 2026-03-29 | area=all 走旧版查询,保留收入结构的区域子行拆分 data = fdw_queries.get_finance_revenue(conn, site_id, start, end, area) else: raw = fdw_queries.get_finance_revenue_area(conn, site_id, start, end, area) data = _format_revenue_from_area(raw, conn, site_id, start, end, area) except Exception: logger.warning("revenue 查询失败,降级为空", exc_info=True) return _empty_revenue() if compare == 1 and prev_start and prev_end: try: if area == "all": prev = fdw_queries.get_finance_revenue(conn, site_id, prev_start, prev_end, area) else: prev_raw = fdw_queries.get_finance_revenue_area(conn, site_id, prev_start, prev_end, area) prev = _format_revenue_from_area(prev_raw, conn, site_id, prev_start, prev_end, area) # 总计环比 _attach_compare(data, data, prev, [ "total_occurrence", "discount_total", "confirmed_total", ]) # structure_rows 行级环比(按 id 匹配) prev_struct = {r["id"]: r for r in prev.get("structure_rows", [])} for row in data.get("structure_rows", []): prev_row = prev_struct.get(row["id"], {}) cmp = calc_compare( Decimal(str(row.get("booked", 0))), Decimal(str(prev_row.get("booked", 0))), ) row["booked_compare"] = cmp["compare"] # price_items 行级环比(按 label 匹配) prev_prices = {r["label"]: r for r in prev.get("price_items", [])} for item in data.get("price_items", []): prev_item = prev_prices.get(item["label"], {}) cmp = calc_compare( Decimal(str(item.get("amount", 0))), Decimal(str(prev_item.get("amount", 0))), ) item["compare"] = cmp["compare"] # discount_items 行级环比(按 label 匹配) prev_discounts = {r["label"]: r for r in prev.get("discount_items", [])} for item in data.get("discount_items", []): prev_item = prev_discounts.get(item["label"], {}) cmp = calc_compare( Decimal(str(item.get("amount", 0))), Decimal(str(prev_item.get("amount", 0))), ) item["compare"] = cmp["compare"] # channel_items 行级环比(按 label 匹配) prev_channels = {r["label"]: r for r in prev.get("channel_items", [])} for item in data.get("channel_items", []): prev_item = prev_channels.get(item["label"], {}) cmp = calc_compare( Decimal(str(item.get("amount", 0))), Decimal(str(prev_item.get("amount", 0))), ) item["compare"] = cmp["compare"] except Exception: logger.warning("revenue 环比查询失败", exc_info=True) return data def _format_revenue_from_area( raw: dict, conn: Any, site_id: int, start: str, end: str, area: str, ) -> dict: """将 get_finance_revenue_area 的原始聚合数据格式化为前端期望的 revenue 结构。 CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 | 从 dws_finance_area_daily 聚合数据构建 structure_rows / discount_items / channel_items, 保持与旧 get_finance_revenue 返回结构完全一致。 """ total_table_charge = raw.get("table_fee_amount", 0.0) total_goods = raw.get("goods_amount", 0.0) total_pd = raw.get("assistant_pd_amount", 0.0) total_cx = raw.get("assistant_cx_amount", 0.0) total_income = raw.get("total_occurrence", 0.0) # 构建 structure_rows(简化版:不再按物理区域拆分子行,因为 area_daily 已按 area_code 聚合) structure_rows = [ {"id": "table_charge", "name": "开台与包厢", "desc": None, "is_sub": False, "amount": total_table_charge, "discount": 0.0, "booked": total_table_charge}, {"id": "assistant_pd", "name": "助教 基础课", "desc": None, "is_sub": False, "amount": total_pd, "discount": 0.0, "booked": total_pd}, {"id": "assistant_cx", "name": "助教 激励课", "desc": None, "is_sub": False, "amount": total_cx, "discount": 0.0, "booked": total_cx}, {"id": "goods", "name": "食品酒水", "desc": None, "is_sub": False, "amount": total_goods, "discount": 0.0, "booked": total_goods}, ] # 发生额构成 price_items = [ {"label": "开台消费", "amount": total_table_charge}, {"label": "酒水商品", "amount": total_goods}, {"label": "助教服务", "amount": total_pd + total_cx}, ] # 优惠拆分(5 项,与旧逻辑一致) groupbuy_d = raw.get("discount_groupbuy", 0.0) vip_d = raw.get("discount_vip", 0.0) manual_d = raw.get("discount_manual", 0.0) gift_card_d = raw.get("discount_gift_card", 0.0) # 其他 = discount_rounding + discount_other rounding_d = raw.get("discount_rounding", 0.0) other_d = raw.get("discount_other", 0.0) discount_items = [ {"label": "团购优惠", "amount": groupbuy_d}, {"label": "会员折扣", "amount": vip_d}, {"label": "手动调整", "amount": manual_d + other_d}, {"label": "赠送卡抵扣", "desc": "台桌卡+酒水卡+抵用券", "amount": gift_card_d}, {"label": "其他优惠", "desc": "免单+抹零", "amount": rounding_d}, ] total_discount = raw.get("discount_total", 0.0) # 回填收入结构表的优惠分摊 if total_table_charge > 0 and total_discount > 0: for row in structure_rows: if row["id"] == "table_charge": row["discount"] = total_discount row["booked"] = total_table_charge - total_discount # 渠道分布(从 dws_finance_area_daily 的 all 行获取,因为渠道数据仅 all 有值) # 需要额外查询 all 行的渠道数据 try: channel_data = _get_channel_items(conn, site_id, start, end) except Exception: logger.warning("revenue 渠道数据查询失败,降级为空", exc_info=True) channel_data = [ {"label": "储值卡结算冲销", "amount": 0.0}, {"label": "现金/线上支付", "amount": 0.0}, {"label": "团购核销确认收入", "desc": "团购成交价", "amount": 0.0}, ] confirmed_total = total_income - abs(total_discount) return { "structure_rows": structure_rows, "price_items": price_items, "total_occurrence": total_income, "discount_items": discount_items, "discount_total": total_discount, "confirmed_total": confirmed_total, "channel_items": channel_data, } def _get_channel_items(conn: Any, site_id: int, start: str, end: str) -> list[dict]: """从 v_dws_finance_daily_summary 获取渠道分布数据(全局数据,不按区域拆分)。""" from app.services.fdw_queries import _fdw_context with _fdw_context(conn, site_id) as cur: cur.execute( """ SELECT COALESCE(SUM(cash_pay_amount), 0) AS cash_pay, COALESCE(SUM(groupbuy_pay_amount), 0) AS groupbuy_pay, COALESCE(SUM(cash_card_consume), 0) AS cash_card, COALESCE(SUM(gift_card_consume), 0) AS gift_card FROM app.v_dws_finance_daily_summary WHERE stat_date >= %s::date AND stat_date <= %s::date """, (start, end), ) ch = cur.fetchone() cash_pay = float(ch[0]) if ch and ch[0] is not None else 0.0 groupbuy_pay = float(ch[1]) if ch and ch[1] is not None else 0.0 cash_card = float(ch[2]) if ch and ch[2] is not None else 0.0 gift_card_consume = float(ch[3]) if ch and ch[3] is not None else 0.0 return [ {"label": "储值卡结算冲销", "amount": cash_card + gift_card_consume}, {"label": "现金/线上支付", "amount": cash_pay}, {"label": "团购核销确认收入", "desc": "团购成交价", "amount": groupbuy_pay}, ] 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} if compare == 1 and prev_start and prev_end: try: prev = fdw_queries.get_finance_cashflow(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"] # consume_items 行级环比(按 label 匹配) prev_consumes = {r["label"]: r for r in prev.get("consume_items", [])} for item in data.get("consume_items", []): prev_item = prev_consumes.get(item["label"], {}) cmp = calc_compare( Decimal(str(item.get("amount", 0))), Decimal(str(prev_item.get("amount", 0))), ) item["compare"] = cmp["compare"] item["down"] = cmp["is_down"] # recharge_items 行级环比(按 label 匹配) prev_recharges = {r["label"]: r for r in prev.get("recharge_items", [])} for item in data.get("recharge_items", []): prev_item = prev_recharges.get(item["label"], {}) cmp = calc_compare( Decimal(str(item.get("amount", 0))), Decimal(str(prev_item.get("amount", 0))), ) item["compare"] = cmp["compare"] except Exception: logger.warning("cashflow 环比查询失败", exc_info=True) 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, area: str = "all", ) -> dict: """助教分析板块。 CHANGE 2026-03-29 | Prompt: 助教分析按区域细化 | area=all 走现有 salary_calc 查询,area≠all 走 coach_area_hours JOIN salary_calc。 """ try: if area == "all": data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end) else: data = fdw_queries.get_finance_coach_analysis_area(conn, site_id, start, end, area) 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: if area == "all": prev = fdw_queries.get_finance_coach_analysis( conn, site_id, prev_start, prev_end ) else: prev = fdw_queries.get_finance_coach_analysis_area( conn, site_id, prev_start, prev_end, area ) 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", ]) # 行级环比(按 level 匹配) prev_rows = {r["level"]: r for r in prev_t.get("rows", [])} for row in cur_t.get("rows", []): prev_row = prev_rows.get(row["level"], {}) pay_cmp = calc_compare(Decimal(str(row.get("pay", 0))), Decimal(str(prev_row.get("pay", 0)))) row["pay_compare"] = pay_cmp["compare"] row["pay_down"] = pay_cmp["is_down"] share_cmp = calc_compare(Decimal(str(row.get("share", 0))), Decimal(str(prev_row.get("share", 0)))) row["share_compare"] = share_cmp["compare"] row["share_down"] = share_cmp["is_down"] hourly_cmp = calc_compare(Decimal(str(row.get("hourly", 0))), Decimal(str(prev_row.get("hourly", 0)))) row["hourly_compare"] = hourly_cmp["compare"] row["hourly_flat"] = hourly_cmp["is_flat"] 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": [], "discount_total": 0.0, "confirmed_total": 0.0, "channel_items": [], }