feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations

This commit is contained in:
Neo
2026-03-20 01:43:48 +08:00
parent 075caf067f
commit 79f9a0e1da
437 changed files with 118603 additions and 976 deletions

View File

@@ -0,0 +1,708 @@
# -*- coding: utf-8 -*-
"""
助教服务 —— COACH-1 助教详情。
数据来源:
- ETL 直连fdw_queries助教信息、绩效、TOP 客户、服务记录、历史月份
- 业务库biz.*):助教任务、备注
⚠️ 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 datetime
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": "回访", "class": "tag-callback"},
"high_priority_recall": {"label": "紧急召回", "class": "tag-recall"},
"priority_recall": {"label": "优先召回", "class": "tag-recall"},
}
# 头像渐变色池(循环使用)
AVATAR_GRADIENTS = [
"from-blue-400 to-blue-600",
"from-green-400 to-green-600",
"from-purple-400 to-purple-600",
"from-orange-400 to-orange-600",
"from-pink-400 to-pink-600",
]
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6 修复 | 删除硬编码 DEFAULT_TIER_NODES
# 档位节点改为从 cfg_performance_tier 配置表动态读取。
# 旧值 [0, 100, 130, 160, 190, 220] 与配置表实际值 [0, 120, 150, 180, 210] 不一致。
_FALLBACK_TIER_NODES: list[float] = [0, 120, 150, 180, 210] # 仅在配置表查询失败时使用
def _get_biz_connection():
"""延迟导入业务库连接。"""
from app.database import get_connection
return get_connection()
def _get_initial(name: str) -> str:
"""取姓氏首字作为头像文字。"""
return name[0] if name else "?"
def _get_avatar_gradient(index: int) -> str:
"""根据索引循环分配头像渐变色。"""
return AVATAR_GRADIENTS[index % len(AVATAR_GRADIENTS)]
def _format_currency(amount: float) -> str:
"""格式化金额¥6,950。"""
if amount >= 10000:
return f"¥{amount:,.0f}"
return f"¥{amount:,.0f}"
# ── 6.1 核心函数 ──────────────────────────────────────────
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
"""
助教详情COACH-1
核心字段查询失败 → 500扩展模块查询失败 → 空默认值(优雅降级)。
"""
conn = _get_biz_connection()
try:
# ── 核心字段(失败直接抛 500──
assistant_info = fdw_queries.get_assistant_info(conn, site_id, coach_id)
if not assistant_info:
raise HTTPException(status_code=404, detail="助教不存在")
now = datetime.date.today()
# 绩效数据(当月)
salary_this = fdw_queries.get_salary_calc(
conn, site_id, coach_id, now.year, now.month
)
if not salary_this:
salary_this = {}
# customerBalance该助教所有客户余额合计
customer_balance = 0.0
try:
top_custs = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=1000)
member_ids = [c["member_id"] for c in top_custs if c.get("member_id")]
if member_ids:
balance_map = fdw_queries.get_member_balance(conn, site_id, member_ids)
customer_balance = sum(float(v) for v in balance_map.values())
except Exception:
logger.warning("查询 customerBalance 失败,降级为 0", exc_info=True)
# tasksCompleted当月已完成任务数
tasks_completed = 0
try:
month_start = now.replace(day=1)
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*)
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status = 'completed'
AND updated_at >= %s
""",
(coach_id, month_start),
)
row = cur.fetchone()
tasks_completed = row[0] if row else 0
except Exception:
logger.warning("查询 tasksCompleted 失败,降级为 0", exc_info=True)
# customerCount不重复客户数从 top_customers 获取)
customer_count = 0
try:
cc_map = fdw_queries.get_monthly_customer_count(
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
)
customer_count = sum(cc_map.values())
except Exception:
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
performance = {
"monthly_hours": salary_this.get("total_hours", 0.0),
"monthly_salary": salary_this.get("total_income", 0.0),
"customer_balance": customer_balance,
"tasks_completed": tasks_completed,
"perf_current": salary_this.get("total_hours", 0.0),
# CHANGE 2026-03-19 | perf_target 从 tier_nodes 推算,不再依赖 salary_calc 的硬编码 0
"perf_target": 0.0, # 占位,下方用 tier_nodes 覆盖
}
# ── 扩展模块(独立 try/except 优雅降级)──
# 收入明细 + 档位
try:
income = _build_income(conn, site_id, coach_id, now)
except Exception:
logger.warning("构建 income 失败,降级为空", exc_info=True)
income = {"this_month": [], "last_month": []}
try:
tier_nodes = _build_tier_nodes(conn, site_id)
except Exception:
logger.warning("构建 tierNodes 失败,降级为 fallback", exc_info=True)
tier_nodes = list(_FALLBACK_TIER_NODES)
# CHANGE 2026-03-19 | 用 tier_nodes 推算 perf_target下一档 min_hours
current_hours = performance["perf_current"]
perf_target = tier_nodes[-1] if tier_nodes else 0.0 # 默认最高档
for node in tier_nodes:
if node > current_hours:
perf_target = node
break
performance["perf_target"] = perf_target
# TOP 客户
try:
top_customers = _build_top_customers(conn, site_id, coach_id)
except Exception:
logger.warning("构建 topCustomers 失败,降级为空列表", exc_info=True)
top_customers = []
# 近期服务记录
try:
service_records = _build_service_records(conn, site_id, coach_id)
except Exception:
logger.warning("构建 serviceRecords 失败,降级为空列表", exc_info=True)
service_records = []
# 任务分组
try:
task_groups = _build_task_groups(coach_id, site_id, conn)
except Exception:
logger.warning("构建 taskGroups 失败,降级为空", exc_info=True)
task_groups = {"visible_tasks": [], "hidden_tasks": [], "abandoned_tasks": []}
# 备注
try:
notes = _build_notes(coach_id, site_id, conn)
except Exception:
logger.warning("构建 notes 失败,降级为空列表", exc_info=True)
notes = []
# 历史月份
try:
history_months = _build_history_months(coach_id, site_id, conn)
except Exception:
logger.warning("构建 historyMonths 失败,降级为空列表", exc_info=True)
history_months = []
return {
# 基础信息
"id": coach_id,
"name": assistant_info.get("name", ""),
"avatar": assistant_info.get("avatar", ""),
"level": salary_this.get("coach_level", assistant_info.get("level", "")),
"skills": assistant_info.get("skills", []),
"work_years": assistant_info.get("work_years", 0.0),
"customer_count": customer_count,
"hire_date": assistant_info.get("hire_date"),
# 绩效
"performance": performance,
# 收入
"income": income,
# 档位
"tier_nodes": tier_nodes,
# 任务分组
"visible_tasks": task_groups["visible_tasks"],
"hidden_tasks": task_groups["hidden_tasks"],
"abandoned_tasks": task_groups["abandoned_tasks"],
# TOP 客户
"top_customers": top_customers,
# 近期服务记录
"service_records": service_records,
# 历史月份
"history_months": history_months,
# 备注
"notes": notes,
}
finally:
conn.close()
# ── 6.2 收入明细 + 档位 ──────────────────────────────────
def _build_income(
conn, site_id: int, coach_id: int, now: datetime.date
) -> dict:
"""
构建 income 模块。
thisMonth/lastMonth 各含 4 项:
- 基础课时费base_income / assistant_pd_money
- 激励课时费bonus_income / assistant_cx_money
- 充值提成
- 酒水提成
⚠️ DWD-DOC 规则 2: 使用 assistant_pd_money + assistant_cx_money 拆分。
"""
# 当月
salary_this = fdw_queries.get_salary_calc(
conn, site_id, coach_id, now.year, now.month
) or {}
# 上月
if now.month == 1:
last_year, last_month = now.year - 1, 12
else:
last_year, last_month = now.year, now.month - 1
salary_last = fdw_queries.get_salary_calc(
conn, site_id, coach_id, last_year, last_month
) or {}
def _make_items(salary: dict) -> list[dict]:
return [
{
"label": "基础课时费",
"amount": f"¥{salary.get('assistant_pd_money_total', 0.0):,.0f}",
"color": "#42A5F5",
},
{
"label": "激励课时费",
"amount": f"¥{salary.get('assistant_cx_money_total', 0.0):,.0f}",
"color": "#FFA726",
},
{
"label": "充值提成",
"amount": f"¥{salary.get('bonus_money', 0.0):,.0f}",
"color": "#66BB6A",
},
{
"label": "酒水提成",
"amount": f"¥{salary.get('room_income', 0.0):,.0f}",
"color": "#AB47BC",
},
]
return {
"this_month": _make_items(salary_this),
"last_month": _make_items(salary_last),
}
def _build_tier_nodes(conn: Any, site_id: int) -> list[float]:
"""
从 cfg_performance_tier 配置表构建 tierNodes 档位节点数组。
⚠️ feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码。
"""
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6 修复 | 从配置表动态读取档位节点
try:
tiers = fdw_queries.get_performance_tiers(conn, site_id)
if tiers:
return [t["min_hours"] for t in tiers]
except Exception:
logger.warning("查询 cfg_performance_tier 失败,使用 fallback", exc_info=True)
return list(_FALLBACK_TIER_NODES)
# ── 6.3 TOP 客户 + 近期服务记录 ──────────────────────────
def _build_top_customers(
conn, site_id: int, coach_id: int
) -> list[dict]:
"""
构建 topCustomers 模块(最多 20 条)。
⚠️ DQ-6: 客户姓名通过 v_dim_member 获取。
⚠️ DQ-7: 余额通过 v_dim_member_card_account 获取。
⚠️ DWD-DOC 规则 1: consume 使用 ledger_amountitems_sum 口径)。
heartEmoji 四级映射P6 AC3rs_display 0-10 刻度):
- score > 8.5 → "💖"
- score > 7 → "🧡"
- score > 5 → "💛"
- score ≤ 5 → "💙"
"""
raw = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=20)
if not raw:
return []
# 获取关系指数(用于 heartEmoji
# 批量获取所有客户的关系指数
member_ids = [c["member_id"] for c in raw if c.get("member_id")]
relation_map: dict[int, float] = {}
for mid in member_ids:
try:
rels = fdw_queries.get_relation_index(conn, site_id, mid)
for r in rels:
if r.get("assistant_id") == coach_id:
relation_map[mid] = r.get("relation_index", 0.0)
break
except Exception:
pass
result = []
for i, cust in enumerate(raw):
mid = cust.get("member_id")
name = cust.get("customer_name", "")
score = relation_map.get(mid, 0.0)
# 四级 heart icon 映射P6 AC3rs_display 0-10 刻度)
heart_emoji = compute_heart_icon(Decimal(str(score)))
if score > 8.5:
score_color = "#FF6B6B"
elif score > 7:
score_color = "#FF8C00"
elif score > 5:
score_color = "#FFA726"
else:
score_color = "#5B9BD5"
balance = cust.get("customer_balance", 0.0)
consume = cust.get("total_consume", 0.0)
result.append({
"id": mid or 0,
"name": name,
"initial": _get_initial(name),
"avatar_gradient": _get_avatar_gradient(i),
"heart_emoji": heart_emoji,
"relation_score": f"{score:.2f}",
"score_color": score_color,
"service_count": cust.get("service_count", 0),
"balance": _format_currency(balance),
"consume": _format_currency(consume),
})
return result
def _build_service_records(
conn, site_id: int, coach_id: int
) -> list[dict]:
"""
构建 serviceRecords 模块。
⚠️ DWD-DOC 规则 1: income 使用 ledger_amount。
⚠️ 废单排除: is_delete = 0。
"""
raw = fdw_queries.get_coach_service_records(
conn, site_id, coach_id, limit=20, offset=0
)
if not raw:
return []
result = []
for i, rec in enumerate(raw):
name = rec.get("customer_name", "")
course_type = rec.get("course_type", "")
# type_class 映射
if "激励" in course_type or "超休" in course_type:
type_class = "tag-bonus"
else:
type_class = "tag-base"
create_time = rec.get("create_time")
date_str = create_time.strftime("%Y-%m-%d %H:%M") if create_time else ""
hours = rec.get("service_hours", 0.0)
income = rec.get("income", 0.0)
result.append({
"customer_id": rec.get("member_id"),
"customer_name": name,
"initial": _get_initial(name),
"avatar_gradient": _get_avatar_gradient(i),
"type": course_type or "课程",
"type_class": type_class,
"table": str(rec.get("table_id")) if rec.get("table_id") else None,
"duration": f"{hours:.1f}h",
"income": _format_currency(income),
"date": date_str,
"perf_hours": None,
})
return result
# ── 6.4 任务分组 + 备注 ──────────────────────────────────
def _build_task_groups(
coach_id: int, site_id: int, conn
) -> dict:
"""
构建任务分组。
1. 查询 biz.coach_tasks WHERE assistant_id=coach_id
2. 按 status 分组active→visibleTasks, inactive→hiddenTasks, abandoned→abandonedTasks
3. visible/hidden关联 biz.notes 获取备注列表
4. abandoned取 abandon_reason
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, member_id, task_type, status, abandon_reason
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status IN ('active', 'inactive', 'abandoned')
ORDER BY created_at DESC
""",
(coach_id,),
)
rows = cur.fetchall()
if not rows:
return {"visible_tasks": [], "hidden_tasks": [], "abandoned_tasks": []}
# 收集客户 ID 批量查询姓名
member_ids = list({r[1] for r in rows if r[1]})
member_name_map: dict[int, str] = {}
if member_ids:
try:
info_map = fdw_queries.get_member_info(conn, site_id, member_ids)
for mid, info in info_map.items():
member_name_map[mid] = info.get("nickname", "")
except Exception:
logger.warning("批量查询客户姓名失败", exc_info=True)
# 收集任务 ID 批量查询备注
task_ids = [r[0] for r in rows if r[3] in ("active", "inactive")]
task_notes_map: dict[int, list[dict]] = {}
if task_ids:
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT task_id, is_pinned, content, created_at
FROM biz.notes
WHERE task_id = ANY(%s)
ORDER BY created_at DESC
""",
(task_ids,),
)
for nr in cur.fetchall():
tid = nr[0]
if tid not in task_notes_map:
task_notes_map[tid] = []
task_notes_map[tid].append({
"pinned": bool(nr[1]),
"text": nr[2] or "",
"date": nr[3].isoformat() if nr[3] else "",
})
except Exception:
logger.warning("批量查询任务备注失败", exc_info=True)
visible_tasks = []
hidden_tasks = []
abandoned_tasks = []
for row in rows:
task_id, member_id, task_type, status, abandon_reason = row
customer_name = member_name_map.get(member_id, "")
task_meta = TASK_TYPE_MAP.get(task_type, {"label": task_type or "", "class": "tag-default"})
if status == "abandoned":
abandoned_tasks.append({
"customer_name": customer_name,
"reason": abandon_reason or "",
})
else:
notes_list = task_notes_map.get(task_id, [])
item = {
"type_label": task_meta["label"],
"type_class": task_meta["class"],
"customer_name": customer_name,
"customer_id": member_id,
"note_count": len(notes_list),
"pinned": any(n.get("pinned") for n in notes_list),
"notes": notes_list if notes_list else None,
}
if status == "active":
visible_tasks.append(item)
else:
hidden_tasks.append(item)
return {
"visible_tasks": visible_tasks,
"hidden_tasks": hidden_tasks,
"abandoned_tasks": abandoned_tasks,
}
def _build_notes(coach_id: int, site_id: int, conn) -> list[dict]:
"""
构建助教相关备注列表(最多 20 条)。
查询 biz.notes 中与该助教任务关联的备注,按 created_at 倒序。
⚠️ DQ-6: 客户姓名通过 member_id JOIN v_dim_member。
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT n.id, n.content, n.created_at, n.ai_score,
n.type AS tag_label,
ct.member_id
FROM biz.notes n
LEFT JOIN biz.coach_tasks ct ON n.task_id = ct.id
WHERE ct.assistant_id = %s
ORDER BY n.created_at DESC
LIMIT 20
""",
(coach_id,),
)
rows = cur.fetchall()
if not rows:
return []
# 批量获取客户姓名DQ-6
member_ids = list({r[5] for r in rows if r[5]})
member_name_map: dict[int, str] = {}
if member_ids:
try:
info_map = fdw_queries.get_member_info(conn, site_id, member_ids)
for mid, info in info_map.items():
member_name_map[mid] = info.get("nickname", "")
except Exception:
logger.warning("查询备注客户姓名失败", exc_info=True)
result = []
for r in rows:
result.append({
"id": r[0],
"content": r[1] or "",
"timestamp": r[2].isoformat() if r[2] else "",
"ai_score": r[3],
"customer_name": member_name_map.get(r[5], ""),
"tag_label": r[4] or "",
"created_at": r[2].isoformat() if r[2] else "",
})
return result
# ── 6.5 历史月份统计T2-6──────────────────────────────
def _build_history_months(
coach_id: int, site_id: int, conn
) -> list[dict]:
"""
构建 historyMonths 模块。
1. fdw_queries.get_salary_calc_multi_months() → 最近 6 个月工时/工资
2. fdw_queries.get_monthly_customer_count() → 各月客户数
3. biz.coach_tasks → 各月回访/召回完成数
4. 本月 estimated=True历史月份 estimated=False
5. 格式化customers→"22人"hours→"87.5h"salary→"¥6,950"
"""
now = datetime.date.today()
# 生成最近 6 个月的月份列表(含本月)
months: list[str] = []
for i in range(6):
y = now.year
m = now.month - i
while m <= 0:
m += 12
y -= 1
months.append(f"{y}-{m:02d}-01")
# 批量查询绩效数据
salary_map = fdw_queries.get_salary_calc_multi_months(
conn, site_id, coach_id, months
)
# 批量查询各月客户数
customer_count_map = fdw_queries.get_monthly_customer_count(
conn, site_id, coach_id, months
)
# 查询各月回访/召回完成数
callback_map: dict[str, int] = {}
recall_map: dict[str, int] = {}
try:
six_months_ago = months[-1] # 最早的月份
with conn.cursor() as cur:
cur.execute(
"""
SELECT DATE_TRUNC('month', updated_at)::date AS month,
task_type,
COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = %s
AND status = 'completed'
AND updated_at >= %s::date
GROUP BY DATE_TRUNC('month', updated_at)::date, task_type
""",
(coach_id, six_months_ago),
)
for row in cur.fetchall():
month_key = str(row[0])
task_type = row[1]
cnt = row[2] or 0
if task_type == "follow_up_visit":
callback_map[month_key] = callback_map.get(month_key, 0) + cnt
elif task_type in ("high_priority_recall", "priority_recall"):
recall_map[month_key] = recall_map.get(month_key, 0) + cnt
except Exception:
logger.warning("查询回访/召回完成数失败", exc_info=True)
# 构建结果
current_month_str = now.strftime("%Y-%m-01")
result = []
for i, month_str in enumerate(months):
salary = salary_map.get(month_str, {})
customers = customer_count_map.get(month_str, 0)
hours = salary.get("effective_hours", 0.0)
salary_amount = salary.get("gross_salary", 0.0)
callback_done = callback_map.get(month_str, 0)
recall_done = recall_map.get(month_str, 0)
# 月份标签
if i == 0:
month_label = "本月"
elif i == 1:
month_label = "上月"
else:
# 提取月份数字
m_num = int(month_str.split("-")[1])
month_label = f"{m_num}"
result.append({
"month": month_label,
"estimated": month_str == current_month_str,
"customers": f"{customers}",
"hours": f"{hours:.1f}h",
"salary": _format_currency(salary_amount),
"callback_done": callback_done,
"recall_done": recall_done,
})
return result