Files
Neo-ZQYY/apps/backend/app/services/customer_service.py

661 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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_amountitems_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 AC3rs_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/customerPhoneDQ-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