Files
Neo-ZQYY/apps/backend/app/services/customer_service.py
Neo 96dae0c778 fix: F1-5b MP-3 + MP-5 沙箱业务日小程序适配 (W1)
MP-3 customer-detail coachTasks.lastService 业务日上界裁剪:
- apps/backend/app/services/customer_service.py
  - import as_runtime_today_param 从 late import 提至模块顶部
  - _build_coach_tasks 开头取 ref_date,供两段 SQL 共用
  - 第一条直查 biz.coach_tasks 加 `AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz`
  - 删除原方法内重复 ref_date 调用
- 业务影响:sandbox=2026-04-20 时,customer-detail 的"上次服务"
  时间不再展示 sandbox 业务日之后的助教任务更新(沙箱不读未来)
- 测试:apps/backend/tests/test_customer_detail_mp3_lastservice.py
  本地通过,因 .gitignore:71 不入仓(同 T1 / af02446 处理方式)

MP-5 coach-service-records 接入 getBusinessClock:
- apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts
  - import getBusinessClock + data 加 clockYear/clockMonth/clockDay 字段
  - onLoad 改 async,await getBusinessClock() 取 business_year/month/date
  - loadData / switchMonth 4 处 new Date() → clockYear/Month/Day
- 业务影响:sandbox=2026-04-20 时,coach-service-records 默认显示
  "2026 年 4 月"业绩(而非 today 月),canGoNext=false 阻止翻到 5 月,
  "前 5 日预估金额"规则按 sandbox business_date 判断

双口径验证(weixin-devtools-mcp + DB 直查):
- MP-3 4a live: lastService 最大 04-19(无未来时间)
- MP-3 4b sandbox=4-20: 5-01 任务 task_id=8348/8347 完全消失
- MP-5 4a live: clockYear/Month/Day=2026/5/5,monthLabel="2026年5月"
- MP-5 4b sandbox=4-20: monthLabel="2026年4月" + 35 笔/¥4,657
   first group=2026-04-20(后端 SQL 上界裁剪生效)

审计:
- docs/audit/changes/2026-05-05__wave1_f1_5b_mp3_lastservice_upper_bound.md
- docs/audit/changes/2026-05-05__wave1_f1_5b_mp5_coach_service_records_clock.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:43:08 +08:00

1118 lines
42 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.runtime_context import as_runtime_today_param
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
CHANGE 2026-05-05 | F1-5b MP-3: lastService (updated_at) 加 business_date 上界,沙箱不读未来
"""
# 业务日sandbox 取 sandbox_business_datelive 取 CURRENT_DATE。
# 该值同时用于第一条 SQLcoach_tasks 上界)和后续 60 天统计ref_date
ref_date = as_runtime_today_param(site_id, conn=conn)
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')
AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz
ORDER BY created_at DESC
""",
(customer_id, ref_date),
)
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 查询)
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」
# ref_date 已在方法开头取得,此处直接复用
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 >= (%s::date - INTERVAL '60 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
GROUP BY site_assistant_id
""",
(customer_id, assistant_ids, ref_date, ref_date),
)
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
# 2026-05-05: 修字段名,view v_dwd_settlement_head 列名为 member_id / create_time
# (不是 tenant_member_id / settle_time);已撤销订单 view 已过滤,无需 is_delete 条件
cur.execute(
"""
SELECT COALESCE(SUM(sh.recharge_card_amount), 0)
FROM app.v_dwd_settlement_head sh
WHERE sh.member_id = %s
AND sh.recharge_card_amount > 0
AND sh.create_time >= %s::timestamptz
AND sh.create_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),
}