Files
Neo-ZQYY/apps/backend/app/services/customer_service.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

1171 lines
44 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
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/summarycreated_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]
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()
NOTE_TYPE_LABELS = {"normal": "备注", "system": "系统", "ai": "AI"}
return [
{
"id": r[0],
"tag_label": NOTE_TYPE_LABELS.get(r[1], r[1] 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_consumption_records(
customer_id: int, site_id: int, conn, *, etl_conn: Any = None
) -> 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=5, offset=0, etl_conn=etl_conn
)
result = []
# 批量查询台桌名称
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
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)
level_code = rec.get("assistant_level")
level_name = level_map.get(level_code, "") if level_code else ""
if pd_money:
hrs = rec.get("service_hours", 0.0)
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "基础课",
"hours": f"{hrs:.1f}h",
"perf_hours": None,
"fee": pd_money,
})
if cx_money:
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "激励课",
"hours": "0h",
"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_raw = rec.get("start_time")
end_raw = rec.get("end_time")
# 格式化时间为 HH:mm
start_str = start_raw.strftime("%H:%M") if start_raw else None
end_str = end_raw.strftime("%H:%M") if end_raw else None
# 格式化时长为 Xh Xmin
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_charge_money + adjust_amount台费调整/大客户优惠)
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)
# 总金额原价consume_money > items_sum 时显示)
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
result.append({
"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,
"total_amount": total_actual,
"total_orig_price": total_orig,
"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 模块。
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_idauth.user_assistant_binding
2. fdw_queries.get_member_info() → customerName/customerPhoneDQ-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_amountitems_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_consumption_records 的构建逻辑。
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount。
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
"""
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
result = []
for rec in raw_records:
coaches = []
pd_money = rec.get("assistant_pd_money", 0.0)
cx_money = rec.get("assistant_cx_money", 0.0)
level_code = rec.get("assistant_level")
level_name = level_map.get(level_code, "") if level_code else ""
if pd_money:
hrs = rec.get("service_hours", 0.0)
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "基础课",
"hours": f"{hrs:.1f}h",
"perf_hours": None,
"fee": pd_money,
})
if cx_money:
coaches.append({
"name": rec.get("assistant_name", ""),
"level": level_name,
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
"course_type": "激励课",
"hours": "0h",
"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_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
result.append({
"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,
"total_amount": total_actual,
"total_orig_price": total_orig,
"pay_method": "",
"recharge_amount": None,
})
return result
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),
}