# -*- coding: utf-8 -*- """ 客户服务 —— CUST-1 客户详情、CUST-2 客户服务记录。 数据来源: - ETL 直连(fdw_queries):会员信息、余额、消费、关系指数、服务记录 - 业务库(biz.*):AI 缓存、维客线索、备注、助教任务 ⚠️ DWD-DOC 强制规则: - 规则 1: 金额使用 items_sum 口径(ledger_amount),禁止 consume_money - 规则 2: 助教费用使用 assistant_pd_money + assistant_cx_money,禁止 service_fee - DQ-6: 会员信息通过 member_id JOIN v_dim_member (scd2_is_current=1) - DQ-7: 余额通过 member_id JOIN v_dim_member_card_account (scd2_is_current=1) - 废单排除: is_delete = 0 """ from __future__ import annotations import json import logging from fastapi import HTTPException from decimal import Decimal from app.services import fdw_queries from app.services.task_generator import compute_heart_icon from app.trace.decorators import trace_service logger = logging.getLogger(__name__) # ── 颜色/样式映射 ────────────────────────────────────────── LEVEL_COLOR_MAP = { "星级": "#FF6B6B", "高级": "#FFA726", "中级": "#42A5F5", "初级": "#66BB6A", } TASK_TYPE_MAP = { "follow_up_visit": {"label": "回访", "color": "teal", "bg_class": "coach-card-teal"}, "high_priority_recall": {"label": "高优先召回", "color": "red", "bg_class": "coach-card-red"}, "priority_recall": {"label": "优先召回", "color": "orange", "bg_class": "coach-card-orange"}, "relationship_building": {"label": "关系构建", "color": "pink", "bg_class": "coach-card-pink"}, } LEVEL_BG_MAP = { "星级": "coach-card-red", "高级": "coach-card-orange", "中级": "coach-card-teal", "初级": "coach-card-pink", } def _mask_phone(phone: str | None) -> str: """手机号脱敏:139****5678 格式。""" if not phone or len(phone) < 7: return phone or "" return f"{phone[:3]}****{phone[-4:]}" def _get_biz_connection(): """延迟导入业务库连接。""" from app.database import get_connection return get_connection() def _get_assistant_id(conn, user_id: int, site_id: int) -> int | None: """从 user_assistant_binding 获取当前用户的 assistant_id。""" with conn.cursor() as cur: cur.execute( """ SELECT assistant_id FROM auth.user_assistant_binding WHERE user_id = %s AND site_id = %s AND is_removed = false LIMIT 1 """, (user_id, site_id), ) row = cur.fetchone() if not row or row[0] is None: # CHANGE 2026-03-27 | E3: assistant_id 缺失时记录警告,便于排查到手收入为 0 的问题 logger.warning( "assistant_id 未找到或为 NULL: user_id=%s, site_id=%s, row=%s → salary_calc 将跳过,到手收入=0", user_id, site_id, row, ) return None return row[0] # ── 3.1 核心函数 ────────────────────────────────────────── @trace_service("获取客户详情", "Get customer detail") async def get_customer_detail(customer_id: int, site_id: int) -> dict: """ 客户详情(CUST-1)。 核心字段查询失败 → 500;扩展模块查询失败 → 空默认值(优雅降级)。 """ conn = _get_biz_connection() try: # CHANGE 2026-03-27 | E1: ETL 连接复用,一次请求只建一个 ETL 连接 from app.database import get_etl_readonly_connection etl_conn = get_etl_readonly_connection(site_id) try: # ── 核心字段(失败直接抛 500)── member_info_map = fdw_queries.get_member_info(conn, site_id, [customer_id], etl_conn=etl_conn) if customer_id not in member_info_map: raise HTTPException(status_code=404, detail="客户不存在") info = member_info_map[customer_id] phone_full = info.get("mobile") or "" phone = _mask_phone(phone_full) name = info.get("nickname") or "" # Banner 字段:查询失败返回 null(需求 1.7) balance = None try: balance_map = fdw_queries.get_member_balance(conn, site_id, [customer_id], etl_conn=etl_conn) if customer_id in balance_map: balance = float(balance_map[customer_id]) except Exception: logger.warning("查询 balance 失败,降级为 null", exc_info=True) consumption_60d = None try: val = fdw_queries.get_consumption_60d(conn, site_id, customer_id, etl_conn=etl_conn) if val is not None: consumption_60d = float(val) except Exception: logger.warning("查询 consumption_60d 失败,降级为 null", exc_info=True) days_since_visit = None try: visit_map = fdw_queries.get_last_visit_days(conn, site_id, [customer_id], etl_conn=etl_conn) if customer_id in visit_map: days_since_visit = visit_map[customer_id] except Exception: logger.warning("查询 daysSinceVisit 失败,降级为 null", exc_info=True) # ── 扩展模块(独立 try/except 优雅降级)── try: ai_insight = _build_ai_insight(customer_id, conn) except Exception: logger.warning("构建 aiInsight 失败,降级为空", exc_info=True) ai_insight = {"summary": "", "strategies": []} try: retention_clues = _build_retention_clues(customer_id, conn) except Exception: logger.warning("构建 retentionClues 失败,降级为空列表", exc_info=True) retention_clues = [] try: notes = _build_notes(customer_id, conn) except Exception: logger.warning("构建 notes 失败,降级为空列表", exc_info=True) notes = [] try: consumption_records = _build_consumption_records(customer_id, site_id, conn, etl_conn=etl_conn) except Exception: logger.warning("构建 consumptionRecords 失败,降级为空列表", exc_info=True) consumption_records = [] try: coach_tasks = _build_coach_tasks(customer_id, site_id, conn) except Exception: logger.warning("构建 coachTasks 失败,降级为空列表", exc_info=True) coach_tasks = [] try: favorite_coaches = _build_favorite_coaches(customer_id, site_id, conn) except Exception: logger.warning("构建 favoriteCoaches 失败,降级为空列表", exc_info=True) favorite_coaches = [] # ideal_interval 从 winback_index 查询 ideal_interval = None try: with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur: cur.execute( "SELECT ideal_interval_days FROM app.v_dws_member_winback_index WHERE member_id = %s", (customer_id,), ) row = cur.fetchone() if row and row[0] is not None: ideal_interval = int(row[0]) except Exception: logger.warning("查询 idealInterval 失败,降级为 null", exc_info=True) return { "id": customer_id, "name": name, "phone": phone, "phone_full": phone_full, "avatar": "", "member_level": "", "relation_index": "", "tags": [], # Banner "balance": balance, "consumption_60d": consumption_60d, "ideal_interval": ideal_interval, "days_since_visit": days_since_visit, # 扩展模块 "ai_insight": ai_insight, "coach_tasks": coach_tasks, "favorite_coaches": favorite_coaches, "retention_clues": retention_clues, "consumption_records": consumption_records, "notes": notes, } finally: etl_conn.close() finally: conn.close() # ── 3.2 AI 洞察 / 维客线索 / 备注 ────────────────────────── def _build_ai_insight(customer_id: int, conn) -> dict: """ 构建 aiInsight 模块。 查询 biz.ai_cache WHERE cache_type='app4_analysis' AND target_id=customerId, 解析 result_json JSON。无缓存时返回空默认值。 """ with conn.cursor() as cur: cur.execute( """ SELECT result_json FROM biz.ai_cache WHERE cache_type = 'app4_analysis' AND target_id = %s ORDER BY created_at DESC LIMIT 1 """, (str(customer_id),), ) row = cur.fetchone() if not row or not row[0]: return {"summary": "", "strategies": []} try: data = json.loads(row[0]) if isinstance(row[0], str) else row[0] except (json.JSONDecodeError, TypeError): return {"summary": "", "strategies": []} summary = data.get("summary", "") strategies_raw = data.get("strategies", []) strategies = [] for s in strategies_raw: if isinstance(s, dict): strategies.append({ "color": s.get("color", ""), "text": s.get("text", ""), }) return {"summary": summary, "strategies": strategies} def _build_retention_clues(customer_id: int, conn) -> list[dict]: """ 构建 retentionClues 模块。 查询 public.member_retention_clue,按 recorded_at 倒序。 """ # CHANGE 2026-03-23 | BUG: clue_type/clue_text 列不存在,应为 category/summary;created_at → recorded_at with conn.cursor() as cur: cur.execute( """ SELECT category, summary FROM public.member_retention_clue WHERE member_id = %s AND is_hidden = false ORDER BY recorded_at DESC """, (customer_id,), ) rows = cur.fetchall() return [{"type": r[0] or "", "text": r[1] or ""} for r in rows] NOTE_TYPE_LABELS = {"normal": "备注", "follow_up": "回访", "system": "系统", "ai": "AI"} def _build_notes(customer_id: int, conn) -> list[dict]: """ 构建 notes 模块。 查询 biz.notes WHERE target_type='member',最多 20 条,按 created_at 倒序。 JOIN auth.users 获取创建者名称,JOIN auth.user_site_roles + auth.roles 获取角色。 """ with conn.cursor() as cur: cur.execute( """ SELECT n.id, n.type, n.created_at, n.content, COALESCE(u.nickname, '') AS creator_name, COALESCE(r.name, '') AS role_name FROM biz.notes n LEFT JOIN auth.users u ON n.user_id = u.id LEFT JOIN auth.user_site_roles usr ON n.user_id = usr.user_id AND usr.is_removed = false LEFT JOIN auth.roles r ON usr.role_id = r.id WHERE n.target_type = 'member' AND n.target_id = %s ORDER BY n.created_at DESC LIMIT 20 """, (customer_id,), ) rows = cur.fetchall() return [ { "id": r[0], "tag_label": NOTE_TYPE_LABELS.get(r[1], r[1] or "备注"), "creator_name": r[4] or "", "creator_role": r[5] or "", "created_at": r[2].strftime("%Y-%m-%d %H:%M") if r[2] else "", "content": r[3] or "", } for r in rows ] # ── 3.3 消费记录 ────────────────────────────────────────── def _build_coaches_from_json(coaches_json: list, level_map: dict) -> list[dict]: """从 SQL json_agg 结果构建 coaches 子数组。""" coaches = [] for c in coaches_json: level_code = c.get("assistant_level") level_name = level_map.get(level_code, "") if level_code else "" hrs = float(c.get("service_hours") or 0) fee = float(c.get("ledger_amount") or 0) if fee or hrs: coaches.append({ "name": c.get("assistant_name", ""), "level": level_name, "level_color": LEVEL_COLOR_MAP.get(level_name, ""), "course_type": c.get("course_type") or "基础课", "hours": f"{hrs:.1f}h", "perf_hours": None, "fee": fee, }) return coaches def _build_settlement_card(rec: dict, table_name_map: dict, level_map: dict) -> dict: """从一条结算单级记录构建前端卡片数据。""" import json as _json coaches_json = rec.get("coaches_json") or [] if isinstance(coaches_json, str): coaches_json = _json.loads(coaches_json) coaches = _build_coaches_from_json(coaches_json, level_map) settle_time = rec.get("settle_time") date_str = settle_time.strftime("%Y-%m-%d") if settle_time else "" start_raw = rec.get("start_time") end_raw = rec.get("end_time") start_str = start_raw.strftime("%H:%M") if start_raw else None end_str = end_raw.strftime("%H:%M") if end_raw else None svc_hours = rec.get("service_hours", 0.0) dur_h = int(svc_hours) dur_m = int((svc_hours - dur_h) * 60) duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min" table_fee = rec.get("table_charge_money", 0.0) adjust = rec.get("adjust_amount", 0.0) table_orig = None if adjust > 0.01: table_orig = round(table_fee + adjust, 2) total_actual = rec.get("total_amount", 0.0) consume_orig = rec.get("consume_money", 0.0) total_orig = consume_orig if consume_orig > total_actual + 0.01 else None return { "id": rec.get("id", ""), "type": "table", "date": date_str, "table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))), "start_time": start_str, "end_time": end_str, "duration": duration_str, "table_fee": table_fee, "table_orig_price": table_orig, "coaches": coaches, "food_amount": rec.get("goods_money", 0.0), "food_orig_price": None, "food_detail": rec.get("drinks"), "total_amount": total_actual, "total_orig_price": total_orig, "pay_method": "", "recharge_amount": None, } def _build_consumption_records( customer_id: int, site_id: int, conn, *, etl_conn: Any = None ) -> list[dict]: """ 构建 consumptionRecords 模块。 按结算单粒度返回,同一结算单下多个助教聚合到 coaches 数组。 ⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)(items_sum 口径)。 ⚠️ 废单排除: is_delete = 0,正向交易: settle_type IN (1, 3)。 """ raw_records = fdw_queries.get_consumption_records( conn, site_id, customer_id, limit=5, offset=0, etl_conn=etl_conn ) # 批量查询台桌名称 table_ids = list({rec.get("table_id") for rec in raw_records if rec.get("table_id")}) table_name_map: dict = {} if table_ids and etl_conn: try: with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur: cur.execute( """ SELECT table_id, table_name FROM app.v_dim_table WHERE table_id = ANY(%s) AND scd2_is_current = 1 """, (table_ids,), ) for row in cur.fetchall(): table_name_map[row[0]] = row[1] or str(row[0]) except Exception: logger.warning("批量查询台桌名称失败", exc_info=True) # 获取等级映射 level_map = {} try: level_map = fdw_queries.get_level_map(conn, site_id) except Exception: pass return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records] # ── 3.4 关联助教任务(T2-2)────────────────────────────── def _build_coach_tasks( customer_id: int, site_id: int, conn ) -> list[dict]: """ 构建 coachTasks 模块。 CHANGE 2026-03-29 | 性能优化:所有助教信息改为批量查询,消除 N+1 """ with conn.cursor() as cur: cur.execute( """ SELECT id, assistant_id, task_type, status, updated_at FROM biz.coach_tasks WHERE member_id = %s AND status IN ('active', 'inactive') ORDER BY created_at DESC """, (customer_id,), ) rows = cur.fetchall() if not rows: return [] assistant_ids = list({r[1] for r in rows if r[1]}) # 批量查询助教姓名和等级(一次 FDW 查询) assistant_info_map: dict = {} try: with fdw_queries._fdw_context(conn, site_id) as cur: cur.execute( """ SELECT da.assistant_id, COALESCE(da.nickname, da.real_name, '') AS name, da.level FROM app.v_dim_assistant da WHERE da.assistant_id = ANY(%s) AND da.scd2_is_current = 1 """, (assistant_ids,), ) level_map = fdw_queries.get_level_map(conn, site_id) for row in cur.fetchall(): assistant_info_map[row[0]] = { "name": row[1], "level": level_map.get(row[2], "") if row[2] else "", } except Exception: logger.warning("批量查询助教信息失败", exc_info=True) # 批量查询 60 天统计(一次 FDW 查询) stats_map: dict = {} try: with fdw_queries._fdw_context(conn, site_id) as cur: cur.execute( """ SELECT site_assistant_id, COUNT(*) AS service_count, SUM(income_seconds) / 3600.0 AS total_hours FROM app.v_dwd_assistant_service_log WHERE tenant_member_id = %s AND site_assistant_id = ANY(%s) AND is_delete = 0 AND create_time >= CURRENT_DATE - INTERVAL '60 days' GROUP BY site_assistant_id """, (customer_id, assistant_ids), ) for row in cur.fetchall(): svc = int(row[1]) if row[1] else 0 hrs = float(row[2]) if row[2] else 0.0 stats_map[row[0]] = { "service_count": svc, "total_hours": round(hrs, 1), "avg_hours": round(hrs / svc, 1) if svc > 0 else 0.0, } except Exception: logger.warning("批量查询 60 天统计失败", exc_info=True) # 批量查询 RSI(关系指数,用于爱心标识) rsi_map: dict = {} try: with fdw_queries._fdw_context(conn, site_id) as cur: cur.execute( """ SELECT assistant_id, rs_display FROM app.v_dws_member_assistant_relation_index WHERE member_id = %s AND assistant_id = ANY(%s) """, (customer_id, assistant_ids), ) for row in cur.fetchall(): rsi_map[row[0]] = float(row[1]) if row[1] is not None else 0.0 except Exception: logger.warning("批量查询 RSI 失败", exc_info=True) result = [] for row in rows: task_id, assistant_id, task_type, status, updated_at = row # CHANGE 2026-03-29 | relationship_building 任务按 RSI 过滤(与任务列表页规则一致) # 规则:1 < RS < 6 才显示,RS ≤ 1 或 RS ≥ 6 不显示 if task_type == "relationship_building": rs = rsi_map.get(assistant_id, 0.0) if rs <= 1.0 or rs >= 6.0: continue a_info = assistant_info_map.get(assistant_id, {}) level = a_info.get("level", "") name = a_info.get("name", "") stats = stats_map.get(assistant_id, {"service_count": 0, "total_hours": 0.0, "avg_hours": 0.0}) task_meta = TASK_TYPE_MAP.get(task_type, { "label": task_type or "", "color": "#999", "bg_class": "bg-gray", }) metrics = [ {"label": "近60天次数", "value": f"{stats['service_count']}次", "color": None}, {"label": "总时长", "value": f"{stats['total_hours']:.1f}h", "color": None}, {"label": "次均时长", "value": f"{stats['avg_hours']:.1f}h", "color": None}, ] # 格式化上次服务时间:MM-DD HH:mm last_svc = "" if updated_at: last_svc = updated_at.strftime("%m-%d %H:%M") result.append({ "name": name, "level": level, "level_color": LEVEL_COLOR_MAP.get(level, ""), "heart_score": rsi_map.get(assistant_id, 0.0), "task_type": task_meta["label"], "task_color": task_meta["color"], "bg_class": LEVEL_BG_MAP.get(level, task_meta["bg_class"]), "status": status, "last_service": last_svc, "metrics": metrics, }) return result # ── 3.5 最亲密助教(T2-3)────────────────────────────── def _build_favorite_coaches( customer_id: int, site_id: int, conn ) -> list[dict]: """ 构建 favoriteCoaches 模块。 CHANGE 2026-03-29 | 性能优化:助教信息改为批量查询,避免 N+1 串行查询 """ relations = fdw_queries.get_relation_index(conn, site_id, customer_id) if not relations: return [] # 批量获取助教姓名(一次查询替代 N 次) assistant_ids = [r["assistant_id"] for r in relations if r.get("assistant_id")] assistant_info_map: dict = {} if assistant_ids: try: with fdw_queries._fdw_context(conn, site_id) as cur: cur.execute( """ SELECT assistant_id, COALESCE(nickname, real_name, '') AS name, level FROM app.v_dim_assistant WHERE assistant_id = ANY(%s) AND scd2_is_current = 1 """, (assistant_ids,), ) level_map = fdw_queries.get_level_map(conn, site_id) for row in cur.fetchall(): assistant_info_map[row[0]] = { "name": row[1], "level": level_map.get(row[2], "") if row[2] else "", } except Exception: logger.warning("批量查询助教信息失败", exc_info=True) result = [] for rel in relations: ri = rel.get("relation_index", 0.0) aid = rel.get("assistant_id") a_info = assistant_info_map.get(aid, {}) emoji = compute_heart_icon(Decimal(str(ri))) if ri > 8.5: index_color, bg_class = "#FF6B6B", "fav-card-pink" elif ri > 7: index_color, bg_class = "#FF8C00", "fav-card-amber" elif ri > 5: index_color, bg_class = "#FFA726", "fav-card-amber" else: index_color, bg_class = "#5B9BD5", "fav-card-blue" svc_count = rel.get("service_count", 0) total_hrs = rel.get("total_hours", 0) stats = [ {"label": "基础", "value": f"{total_hrs:.0f}h", "color": None}, {"label": "激励", "value": "0h", "color": None}, {"label": "上课", "value": f"{svc_count}次", "color": None}, {"label": "总时长", "value": f"{total_hrs:.1f}h", "color": None}, ] result.append({ "emoji": emoji, "name": a_info.get("name", ""), "heart_score": ri, "level": a_info.get("level", ""), "relation_index": f"{ri:.1f}", "index_color": index_color, "bg_class": bg_class, "stats": stats, }) return result # ── CUST-2 客户服务记录(T2-4)────────────────────────── @trace_service("获取客户服务记录", "Get customer records") async def get_customer_records( customer_id: int, site_id: int, user_id: int, year: int, month: int, table: str | None, page: int, page_size: int, ) -> dict: """ 客户服务记录(CUST-2)。 CHANGE 2026-03-27 | 按助教过滤 + 到手收入计算 1. 从 user_id 获取 assistant_id(auth.user_assistant_binding) 2. fdw_queries.get_member_info() → customerName/customerPhone(DQ-6) 3. fdw_queries.get_customer_service_records() → 按月分页记录(按 assistant_id 过滤) 4. 聚合 monthCount/monthHours/monthIncome(到手收入) 5. fdw_queries.get_total_service_count() → totalServiceCount(当前助教对该客户的全部历史) 6. 构建 ServiceRecordItem 列表,含 recordType/isEstimate/到手收入 7. hasMore = total_count > page * page_size """ conn = _get_biz_connection() try: # ── 获取当前用户的 assistant_id ── assistant_id = _get_assistant_id(conn, user_id, site_id) # CHANGE 2026-03-26 | ETL 连接复用:一次请求只建一个 ETL 连接 from app.database import get_etl_readonly_connection etl_conn = get_etl_readonly_connection(site_id) try: # CHANGE 2026-03-27 | 统一卡片数据:到手收入已在 fdw_queries SQL 层通过 # salary_calc JOIN 计算,不再需要 Python 层单独查费率参数 # ── 客户基础信息(DQ-6)── member_info_map = fdw_queries.get_member_info( conn, site_id, [customer_id], etl_conn=etl_conn, ) if customer_id not in member_info_map: raise HTTPException(status_code=404, detail="客户不存在") info = member_info_map[customer_id] phone_full = info.get("mobile") or "" phone = _mask_phone(phone_full) customer_name = info.get("nickname") or "" # ── 按月分页服务记录 ── offset = (page - 1) * page_size records_raw, total_count = fdw_queries.get_customer_service_records( conn, site_id, customer_id, year, month, table, limit=page_size, offset=offset, etl_conn=etl_conn, assistant_id=assistant_id, ) # ── 月度统计汇总(从全量 total_count + 当页记录工时聚合)── # monthCount = 当月总记录数(不是当页),monthHours = 当月总工时 # 需要单独查询当月汇总,因为分页记录只是子集 month_count, month_hours = _get_month_aggregation( conn, site_id, customer_id, year, month, table, etl_conn=etl_conn, assistant_id=assistant_id, ) # ── 累计服务总次数(跨所有月份)── total_service_count = fdw_queries.get_total_service_count( conn, site_id, customer_id, etl_conn=etl_conn, assistant_id=assistant_id, ) # ── 构建记录列表 ── # CHANGE 2026-03-27 | 统一卡片数据:fdw_queries 已返回 table_name/income(到手)/drinks # 不再需要 Python 层计算到手收入和台桌名称映射 records = [] for rec in records_raw: create_time = rec.get("create_time") date_str = create_time.strftime("%Y-%m-%d") if create_time else "" start_time = rec.get("start_time") end_time = rec.get("end_time") # 时间范围格式化 time_range = None if start_time and end_time: time_range = f"{start_time.strftime('%H:%M')}-{end_time.strftime('%H:%M')}" # recordType: 根据 course_type 判断 course_type = rec.get("course_type", "") record_type = "recharge" if "充值" in course_type else "course" # type / type_class 映射 if record_type == "recharge": type_label = "充值" type_class = "tag-recharge" else: type_label = course_type or "课程" type_class = "tag-course" records.append({ "id": str(rec.get("id", "")), "date": date_str, "time_range": time_range, "table": rec.get("table_name") or None, "type": type_label, "type_class": type_class, "record_type": record_type, "duration": rec.get("service_hours", 0.0), "duration_raw": rec.get("service_hours_raw"), "income": rec.get("income", 0.0), "is_estimate": rec.get("is_estimate", False), "drinks": rec.get("drinks"), }) has_more = total_count > page * page_size # CHANGE 2026-03-27 | 月度到手收入汇总 month_income = round(sum(r["income"] for r in records), 2) # 如果是分页的,需要用全量数据计算(当前只有当页数据) # 但月度汇总已经有 _get_month_aggregation,这里用当页记录的到手收入 # 后续可优化为 SQL 层面计算 # CHANGE 2026-03-27 | 关系指数:查询当前助教与该客户的 RS 指数 relation_index_str = "" if assistant_id: relations = fdw_queries.get_relation_index( conn, site_id, customer_id, ) for rel in relations: if rel.get("assistant_id") == assistant_id: ri = rel.get("relation_index", 0.0) relation_index_str = f"{ri:.1f}" if ri else "" break return { "customer_name": customer_name, "customer_phone": phone, "customer_phone_full": phone_full, "relation_index": relation_index_str, "tables": [], "total_service_count": total_service_count, "month_count": month_count, "month_hours": round(month_hours, 2), "month_income": month_income, "records": records, "has_more": has_more, } finally: etl_conn.close() finally: conn.close() def _get_month_aggregation( conn, site_id: int, customer_id: int, year: int, month: int, table: str | None, *, etl_conn: Any = None, assistant_id: int | None = None, ) -> tuple[int, float]: """ 查询当月汇总统计(monthCount + monthHours)。 复用 fdw_queries 的 _fdw_context 直连 ETL 库。 ⚠️ 废单排除: is_delete = 0。 CHANGE 2026-03-27 | 新增 assistant_id 过滤。 """ start_date = f"{year}-{month:02d}-01" if month == 12: end_date = f"{year + 1}-01-01" else: end_date = f"{year}-{month + 1:02d}-01" base_where = """ tenant_member_id = %s AND is_delete = 0 AND create_time >= %s::timestamptz AND create_time < %s::timestamptz """ params: list = [customer_id, start_date, end_date] if assistant_id: base_where += " AND site_assistant_id = %s" params.append(assistant_id) if table: base_where += " AND site_table_id::text = %s" params.append(table) with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur: cur.execute( f""" SELECT COUNT(*) AS month_count, COALESCE(SUM(income_seconds / 3600.0), 0) AS month_hours FROM app.v_dwd_assistant_service_log WHERE {base_where} """, params, ) row = cur.fetchone() if not row: return 0, 0.0 return row[0] or 0, float(row[1]) if row[1] is not None else 0.0 # ── CUST-3: 客户消费记录(按月)────────────────────────────── @trace_service("获取客户消费记录", "Get customer consumption records") async def get_customer_consumption_records( customer_id: int, site_id: int, year: int, month: int, ) -> dict: """ 客户消费记录(CUST-3)。 按月份过滤,复用 _build_consumption_records 的逻辑。 返回 banner 数据 + 月度汇总(到店次数/消费总额/充值总额)+ 消费记录列表。 ⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(items_sum 口径)。 ⚠️ 废单排除: is_delete = 0,正向交易: settle_type IN (1, 3)。 """ conn = _get_biz_connection() try: from app.database import get_etl_readonly_connection etl_conn = get_etl_readonly_connection(site_id) try: # ── Banner 数据(复用 CUST-1 逻辑)── member_info_map = fdw_queries.get_member_info( conn, site_id, [customer_id], etl_conn=etl_conn, ) if customer_id not in member_info_map: raise HTTPException(status_code=404, detail="客户不存在") info = member_info_map[customer_id] phone_full = info.get("mobile") or "" phone = _mask_phone(phone_full) name = info.get("nickname") or "" balance = None try: balance_map = fdw_queries.get_member_balance( conn, site_id, [customer_id], etl_conn=etl_conn, ) if customer_id in balance_map: balance = float(balance_map[customer_id]) except Exception: logger.warning("查询 balance 失败,降级为 null", exc_info=True) consumption_60d = None try: val = fdw_queries.get_consumption_60d( conn, site_id, customer_id, etl_conn=etl_conn, ) if val is not None: consumption_60d = float(val) except Exception: logger.warning("查询 consumption_60d 失败,降级为 null", exc_info=True) days_since_visit = None try: visit_map = fdw_queries.get_last_visit_days( conn, site_id, [customer_id], etl_conn=etl_conn, ) if customer_id in visit_map: days_since_visit = visit_map[customer_id] except Exception: logger.warning("查询 daysSinceVisit 失败,降级为 null", exc_info=True) ideal_interval = None try: with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur: cur.execute( "SELECT ideal_interval_days FROM app.v_dws_member_winback_index WHERE member_id = %s", (customer_id,), ) row = cur.fetchone() if row and row[0] is not None: ideal_interval = int(row[0]) except Exception: logger.warning("查询 idealInterval 失败,降级为 null", exc_info=True) # ── 月度消费记录(带月份过滤)── start_date = f"{year}-{month:02d}-01" if month == 12: end_date = f"{year + 1}-01-01" else: end_date = f"{year}-{month + 1:02d}-01" raw_records = _get_consumption_records_by_month( conn, site_id, customer_id, start_date, end_date, etl_conn=etl_conn, ) # ── 月度汇总(从 settlement_head 聚合)── month_summary = _get_consumption_month_summary( conn, site_id, customer_id, start_date, end_date, etl_conn=etl_conn, ) return { # Banner "id": customer_id, "name": name, "phone": phone, "phone_full": phone_full, "balance": balance, "consumption_60d": consumption_60d, "ideal_interval": ideal_interval, "days_since_visit": days_since_visit, # 月度汇总 "visit_count": month_summary["visit_count"], "consume_total": month_summary["consume_total"], "recharge_total": month_summary["recharge_total"], # 消费记录 "records": raw_records, } finally: etl_conn.close() finally: conn.close() def _get_consumption_records_by_month( conn, site_id: int, customer_id: int, start_date: str, end_date: str, *, etl_conn=None, ) -> list[dict]: """ 按月份过滤的消费记录,复用 _build_settlement_card 构建逻辑。 ⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)(items_sum 口径)。 """ raw_records = fdw_queries.get_consumption_records( conn, site_id, customer_id, limit=200, offset=0, etl_conn=etl_conn, start_date=start_date, end_date=end_date, ) # 批量查询台桌名称 table_ids = list({rec.get("table_id") for rec in raw_records if rec.get("table_id")}) table_name_map: dict = {} if table_ids and etl_conn: try: with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur: cur.execute( """ SELECT table_id, table_name FROM app.v_dim_table WHERE table_id = ANY(%s) AND scd2_is_current = 1 """, (table_ids,), ) for row in cur.fetchall(): table_name_map[row[0]] = row[1] or str(row[0]) except Exception: logger.warning("批量查询台桌名称失败", exc_info=True) # 获取等级映射 level_map = {} try: level_map = fdw_queries.get_level_map(conn, site_id) except Exception: pass return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records] def _get_consumption_month_summary( conn, site_id: int, customer_id: int, start_date: str, end_date: str, *, etl_conn=None, ) -> dict: """ 月度消费汇总:到店次数、消费总额、充值总额。 到店次数:当月不同日期的结算单数(去重 settle_date)。 消费总额:当月 ledger_amount 合计(items_sum 口径)。 充值总额:当月 recharge_card_amount 合计(从 settlement_head)。 ⚠️ 废单排除: is_delete = 0,正向交易: settle_type IN (1, 3)。 """ visit_count = 0 consume_total = 0.0 recharge_total = 0.0 try: with fdw_queries._fdw_context(conn, site_id, etl_conn=etl_conn) as cur: # 到店次数 + 消费总额(从 service_log) cur.execute( """ SELECT COUNT(DISTINCT sl.create_time::date) AS visit_days, COALESCE(SUM(sl.ledger_amount), 0) AS consume_total FROM app.v_dwd_assistant_service_log sl LEFT JOIN app.v_dwd_settlement_head sh ON sl.order_settle_id = sh.order_settle_id WHERE sl.tenant_member_id = %s AND sl.is_delete = 0 AND sh.settle_type IN (1, 3) AND sl.create_time >= %s::timestamptz AND sl.create_time < %s::timestamptz """, (customer_id, start_date, end_date), ) row = cur.fetchone() if row: visit_count = row[0] or 0 consume_total = float(row[1]) if row[1] else 0.0 # 充值总额(从 settlement_head 的 recharge_card_amount) cur.execute( """ SELECT COALESCE(SUM(sh.recharge_card_amount), 0) FROM app.v_dwd_settlement_head sh WHERE sh.tenant_member_id = %s AND sh.is_delete = 0 AND sh.recharge_card_amount > 0 AND sh.settle_time >= %s::timestamptz AND sh.settle_time < %s::timestamptz """, (customer_id, start_date, end_date), ) row = cur.fetchone() if row: recharge_total = float(row[0]) if row[0] else 0.0 except Exception: logger.warning("查询月度消费汇总失败", exc_info=True) return { "visit_count": visit_count, "consume_total": round(consume_total, 2), "recharge_total": round(recharge_total, 2), }