Files
Neo-ZQYY/apps/backend/app/services/customer_service.py
Neo 2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:32:07 +08:00

1107 lines
41 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]
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_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_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),
}