# -*- 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 logger = logging.getLogger(__name__) # ── 颜色/样式映射 ────────────────────────────────────────── LEVEL_COLOR_MAP = { "星级": "#FF6B6B", "高级": "#FFA726", "中级": "#42A5F5", "初级": "#66BB6A", } TASK_TYPE_MAP = { "follow_up_visit": {"label": "回访", "color": "#42A5F5", "bg_class": "bg-blue"}, "high_priority_recall": {"label": "紧急召回", "color": "#FF6B6B", "bg_class": "bg-red"}, "priority_recall": {"label": "优先召回", "color": "#FFA726", "bg_class": "bg-orange"}, } LEVEL_BG_MAP = { "星级": "bg-red", "高级": "bg-orange", "中级": "bg-blue", "初级": "bg-green", } 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() # ── 3.1 核心函数 ────────────────────────────────────────── async def get_customer_detail(customer_id: int, site_id: int) -> dict: """ 客户详情(CUST-1)。 核心字段查询失败 → 500;扩展模块查询失败 → 空默认值(优雅降级)。 """ conn = _get_biz_connection() try: # ── 核心字段(失败直接抛 500)── member_info_map = fdw_queries.get_member_info(conn, site_id, [customer_id]) 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]) 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) 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]) 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) 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 = [] 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": None, "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: 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,按 created_at 倒序。 """ with conn.cursor() as cur: cur.execute( """ SELECT clue_type, clue_text FROM public.member_retention_clue WHERE member_id = %s ORDER BY created_at DESC """, (customer_id,), ) rows = cur.fetchall() return [{"type": r[0] or "", "text": r[1] or ""} for r in rows] def _build_notes(customer_id: int, conn) -> list[dict]: """ 构建 notes 模块。 查询 biz.notes WHERE target_type='member',最多 20 条,按 created_at 倒序。 """ with conn.cursor() as cur: cur.execute( """ SELECT id, type, created_at, content FROM biz.notes WHERE target_type = 'member' AND target_id = %s ORDER BY created_at DESC LIMIT 20 """, (customer_id,), ) rows = cur.fetchall() return [ { "id": r[0], "tag_label": r[1] or "", "created_at": r[2].isoformat() if r[2] else "", "content": r[3] or "", } for r in rows ] # ── 3.3 消费记录 ────────────────────────────────────────── def _build_consumption_records( customer_id: int, site_id: int, conn ) -> list[dict]: """ 构建 consumptionRecords 模块。 调用 fdw_queries.get_consumption_records() 获取结算单列表。 ⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(items_sum 口径)。 ⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。 ⚠️ 废单排除: is_delete = 0,正向交易: settle_type IN (1, 3)。 """ raw_records = fdw_queries.get_consumption_records( conn, site_id, customer_id, limit=50, offset=0 ) result = [] for rec in raw_records: # 构建 coaches 子数组 coaches = [] pd_money = rec.get("assistant_pd_money", 0.0) cx_money = rec.get("assistant_cx_money", 0.0) if pd_money: coaches.append({ "name": rec.get("assistant_name", ""), "level": rec.get("level", ""), "level_color": LEVEL_COLOR_MAP.get(rec.get("level", ""), ""), "course_type": "基础课", "hours": rec.get("service_hours", 0.0), "perf_hours": None, "fee": pd_money, }) if cx_money: coaches.append({ "name": rec.get("assistant_name", ""), "level": rec.get("level", ""), "level_color": LEVEL_COLOR_MAP.get(rec.get("level", ""), ""), "course_type": "激励课", "hours": 0.0, "perf_hours": None, "fee": cx_money, }) settle_time = rec.get("settle_time") date_str = settle_time.strftime("%Y-%m-%d") if settle_time else "" start_str = rec.get("start_time") end_str = rec.get("end_time") result.append({ "id": rec.get("id", ""), "type": "table", "date": date_str, "table_name": str(rec.get("table_id")) if rec.get("table_id") else None, "start_time": start_str.isoformat() if start_str else None, "end_time": end_str.isoformat() if end_str else None, "duration": int(rec.get("service_hours", 0) * 60), "table_fee": rec.get("table_charge_money", 0.0), "table_orig_price": None, "coaches": coaches, "food_amount": rec.get("goods_money", 0.0), "food_orig_price": None, "total_amount": rec.get("total_amount", 0.0), "total_orig_price": rec.get("total_amount", 0.0), "pay_method": "", "recharge_amount": None, }) return result # ── 3.4 关联助教任务(T2-2)────────────────────────────── def _build_coach_tasks( customer_id: int, site_id: int, conn ) -> list[dict]: """ 构建 coachTasks 模块。 1. 查询 biz.coach_tasks WHERE member_id=customer_id 2. 对每位助教:fdw_queries 获取等级、近 60 天统计 3. 映射 levelColor/taskColor/bgClass """ 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 [] # 收集所有助教 ID,批量查询信息 assistant_ids = list({r[1] for r in rows if r[1]}) # 获取助教等级(通过 salary_calc) import datetime now = datetime.date.today() salary_map = {} for aid in assistant_ids: try: sc = fdw_queries.get_salary_calc(conn, site_id, aid, now.year, now.month) if sc: salary_map[aid] = sc except Exception: logger.warning("查询助教 %s 绩效失败", aid, exc_info=True) # 获取助教姓名 assistant_info_map = {} for aid in assistant_ids: try: info = fdw_queries.get_assistant_info(conn, site_id, aid) if info: assistant_info_map[aid] = info except Exception: logger.warning("查询助教 %s 信息失败", aid, exc_info=True) result = [] for row in rows: task_id, assistant_id, task_type, status, updated_at = row a_info = assistant_info_map.get(assistant_id, {}) sc = salary_map.get(assistant_id, {}) level = sc.get("coach_level", a_info.get("level", "")) name = a_info.get("name", "") task_meta = TASK_TYPE_MAP.get(task_type, { "label": task_type or "", "color": "#999", "bg_class": "bg-gray", }) # 近 60 天统计 try: stats = fdw_queries.get_coach_60d_stats( conn, site_id, assistant_id, customer_id ) except Exception: stats = {"service_count": 0, "total_hours": 0.0, "avg_hours": 0.0} metrics = [ {"label": "服务次数", "value": str(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}, ] result.append({ "name": name, "level": level, "level_color": LEVEL_COLOR_MAP.get(level, ""), "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": updated_at.isoformat() if updated_at else None, "metrics": metrics, }) return result # ── 3.5 最亲密助教(T2-3)────────────────────────────── def _build_favorite_coaches( customer_id: int, site_id: int, conn ) -> list[dict]: """ 构建 favoriteCoaches 模块。 1. fdw_queries.get_relation_index() → 关系指数列表(已按降序排列,rs_display 0-10 刻度) 2. emoji 四级映射(P6 AC3):>8.5→💖 / >7→🧡 / >5→💛 / ≤5→💙 3. stats 4 项指标 """ relations = fdw_queries.get_relation_index(conn, site_id, customer_id) if not relations: return [] # 获取助教姓名 assistant_ids = [r["assistant_id"] for r in relations if r.get("assistant_id")] assistant_info_map = {} for aid in assistant_ids: try: info = fdw_queries.get_assistant_info(conn, site_id, aid) if info: assistant_info_map[aid] = info except Exception: logger.warning("查询助教 %s 信息失败", aid, 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, {}) # 4-level heart icon 映射(P6 AC3,rs_display 0-10 刻度) emoji = compute_heart_icon(Decimal(str(ri))) if ri > 8.5: index_color, bg_class = "#FF6B6B", "bg-red" elif ri > 7: index_color, bg_class = "#FF8C00", "bg-orange" elif ri > 5: index_color, bg_class = "#FFA726", "bg-yellow" else: index_color, bg_class = "#5B9BD5", "bg-blue" stats = [ {"label": "基础课时", "value": f"¥{rel.get('total_income', 0):.0f}", "color": None}, {"label": "激励课时", "value": "¥0", "color": None}, {"label": "上课次数", "value": str(rel.get("service_count", 0)), "color": None}, {"label": "总时长", "value": f"{rel.get('total_hours', 0):.1f}h", "color": None}, ] result.append({ "emoji": emoji, "name": a_info.get("name", ""), "relation_index": f"{ri:.2f}", "index_color": index_color, "bg_class": bg_class, "stats": stats, }) return result # ── CUST-2 客户服务记录(T2-4)────────────────────────── async def get_customer_records( customer_id: int, site_id: int, year: int, month: int, table: str | None, page: int, page_size: int, ) -> dict: """ 客户服务记录(CUST-2)。 1. fdw_queries.get_member_info() → customerName/customerPhone(DQ-6) 2. fdw_queries.get_customer_service_records() → 按月分页记录 + total_count 3. 聚合 monthCount/monthHours(从 total_count 和记录工时) 4. fdw_queries.get_total_service_count() → totalServiceCount(跨月) 5. 构建 ServiceRecordItem 列表,含 recordType/isEstimate 6. hasMore = total_count > page * page_size """ conn = _get_biz_connection() try: # ── 客户基础信息(DQ-6)── member_info_map = fdw_queries.get_member_info(conn, site_id, [customer_id]) 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, ) # ── 月度统计汇总(从全量 total_count + 当页记录工时聚合)── # monthCount = 当月总记录数(不是当页),monthHours = 当月总工时 # 需要单独查询当月汇总,因为分页记录只是子集 month_count, month_hours = _get_month_aggregation( conn, site_id, customer_id, year, month, table ) # ── 累计服务总次数(跨所有月份)── total_service_count = fdw_queries.get_total_service_count( conn, site_id, customer_id ) # ── 构建记录列表 ── 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": str(rec.get("table_id")) if rec.get("table_id") else 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": None, }) has_more = total_count > page * page_size return { "customer_name": customer_name, "customer_phone": phone, "customer_phone_full": phone_full, "relation_index": "", "tables": [], "total_service_count": total_service_count, "month_count": month_count, "month_hours": round(month_hours, 2), "records": records, "has_more": has_more, } finally: conn.close() def _get_month_aggregation( conn, site_id: int, customer_id: int, year: int, month: int, table: str | None, ) -> tuple[int, float]: """ 查询当月汇总统计(monthCount + monthHours)。 复用 fdw_queries 的 _fdw_context 直连 ETL 库。 ⚠️ 废单排除: is_delete = 0。 """ 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 table: base_where += " AND site_table_id::text = %s" params.append(table) with fdw_queries._fdw_context(conn, site_id) 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