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>
This commit is contained in:
@@ -17,11 +17,8 @@ from decimal import Decimal
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services import fdw_queries
|
||||
from app.services.task_manager import (
|
||||
_get_assistant_id,
|
||||
compute_income_trend,
|
||||
map_course_type_class,
|
||||
)
|
||||
from app.services.task_manager import _get_assistant_id, compute_income_trend
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,22 +34,8 @@ def _get_connection():
|
||||
# 纯函数:可被属性测试直接调用
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 头像颜色预定义集合
|
||||
_AVATAR_COLORS = [
|
||||
"#0052d9", "#e34d59", "#00a870", "#ed7b2f",
|
||||
"#0594fa", "#a25eb5", "#f6c244", "#2ba471",
|
||||
]
|
||||
|
||||
|
||||
def avatar_char_color(name: str) -> tuple[str, str]:
|
||||
"""从客户姓名计算 avatarChar 和 avatarColor。"""
|
||||
if not name:
|
||||
return ("?", _AVATAR_COLORS[0])
|
||||
char = name[0]
|
||||
color = _AVATAR_COLORS[ord(char) % len(_AVATAR_COLORS)]
|
||||
return (char, color)
|
||||
|
||||
|
||||
@trace_service(description_zh="format_income_desc", description_en="Format Income Desc")
|
||||
def format_income_desc(rate: float, hours: float) -> str:
|
||||
"""
|
||||
格式化收入明细描述。
|
||||
@@ -65,15 +48,17 @@ def format_income_desc(rate: float, hours: float) -> str:
|
||||
return f"{rate_str}元/h × {hours_str}h"
|
||||
|
||||
|
||||
@trace_service(description_zh="group_records_by_date", description_en="Group Records By Date")
|
||||
def group_records_by_date(
|
||||
records: list[dict], *, include_avatar: bool = False
|
||||
records: list[dict], *, include_avatar: bool = False,
|
||||
rs_map: dict[int, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
将服务记录按日期分组为 DateGroup 结构。
|
||||
|
||||
参数:
|
||||
records: 服务记录列表(已按 settle_time DESC 排序)
|
||||
include_avatar: 是否包含 avatarChar/avatarColor(PERF-1 需要,PERF-2 不需要)
|
||||
include_avatar: 是否包含 member_id(PERF-1 需要前端计算头像颜色)
|
||||
|
||||
返回按日期倒序排列的 DateGroup 列表。
|
||||
"""
|
||||
@@ -95,24 +80,33 @@ def group_records_by_date(
|
||||
end_time = rec.get("end_time")
|
||||
time_range = _format_time_range(start_time, end_time)
|
||||
|
||||
raw_course_type = rec.get("course_type", "")
|
||||
type_class = map_course_type_class(raw_course_type)
|
||||
# CHANGE 2026-03-24 | 课程类型直接用数据库原始值(skill_name),不做二次映射
|
||||
raw_course_type = rec.get("course_type", "") or "基础课"
|
||||
customer_name = rec.get("customer_name") or "未知客户"
|
||||
|
||||
record_item: dict = {
|
||||
"customer_name": customer_name,
|
||||
"time_range": time_range,
|
||||
"hours": f"{rec.get('service_hours', 0.0):g}",
|
||||
"course_type": raw_course_type or "基础课",
|
||||
"course_type_class": type_class,
|
||||
"hours": f"{rec.get('service_hours', 0.0):.1f}",
|
||||
"course_type": raw_course_type,
|
||||
"location": rec.get("table_name") or "",
|
||||
"income": f"{rec.get('income', 0.0):.2f}",
|
||||
}
|
||||
|
||||
# CHANGE 2026-03-24 | 头像颜色改为前端根据 member_id 计算,后端只传 member_id 和首字
|
||||
if include_avatar:
|
||||
char, color = avatar_char_color(customer_name)
|
||||
record_item["avatar_char"] = char
|
||||
record_item["avatar_color"] = color
|
||||
mid = rec.get("member_id")
|
||||
record_item["member_id"] = mid
|
||||
# 散客/未知客户(member_id 为空、0、负数)→ "?"
|
||||
if not mid or mid <= 0:
|
||||
record_item["avatar_char"] = "?"
|
||||
else:
|
||||
record_item["avatar_char"] = customer_name[0] if customer_name else "?"
|
||||
# CHANGE 2026-03-27 | 关系爱心标识:注入 heart_score(RS 分数)
|
||||
if rs_map and mid:
|
||||
record_item["heart_score"] = rs_map.get(mid, 0.0)
|
||||
else:
|
||||
record_item["heart_score"] = 0.0
|
||||
|
||||
groups[date_key].append(record_item)
|
||||
|
||||
@@ -125,7 +119,7 @@ def group_records_by_date(
|
||||
total_income = sum(float(r["income"]) for r in recs)
|
||||
result.append({
|
||||
"date": date_key,
|
||||
"total_hours": f"{total_hours:g}",
|
||||
"total_hours": f"{total_hours:.1f}",
|
||||
"total_income": f"{total_income:.2f}",
|
||||
"records": recs,
|
||||
})
|
||||
@@ -133,6 +127,7 @@ def group_records_by_date(
|
||||
return result
|
||||
|
||||
|
||||
@trace_service(description_zh="paginate_records", description_en="Paginate Records")
|
||||
def paginate_records(
|
||||
records: list[dict], page: int, page_size: int
|
||||
) -> tuple[list[dict], bool]:
|
||||
@@ -149,6 +144,7 @@ def paginate_records(
|
||||
return page_records, has_more
|
||||
|
||||
|
||||
@trace_service(description_zh="compute_summary", description_en="Compute Summary")
|
||||
def compute_summary(records: list[dict]) -> dict:
|
||||
"""
|
||||
计算月度汇总。
|
||||
@@ -204,6 +200,7 @@ def _format_date_label(dt) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@trace_service("获取绩效概览", "Get performance overview")
|
||||
async def get_overview(
|
||||
user_id: int, site_id: int, year: int, month: int
|
||||
) -> dict:
|
||||
@@ -244,11 +241,30 @@ async def get_overview(
|
||||
)
|
||||
|
||||
# 按日期分组(含 avatar)
|
||||
date_groups = group_records_by_date(all_records, include_avatar=True)
|
||||
# CHANGE 2026-03-27 | 批量查 RS 分数,注入到服务记录和客户列表
|
||||
member_ids = list({r.get("member_id") for r in all_records if r.get("member_id")})
|
||||
rs_map: dict[int, float] = {}
|
||||
if member_ids:
|
||||
try:
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s AND member_id = ANY(%s)
|
||||
""",
|
||||
(assistant_id, member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rs_map[row[0]] = float(row[1])
|
||||
except Exception:
|
||||
logger.warning("查询 RS 分数失败", exc_info=True)
|
||||
|
||||
date_groups = group_records_by_date(all_records, include_avatar=True, rs_map=rs_map)
|
||||
|
||||
# ── 4. 新客/常客列表 ──
|
||||
new_customers, regular_customers = _build_customer_lists(
|
||||
conn, site_id, assistant_id, year, month, all_records
|
||||
conn, site_id, assistant_id, year, month, all_records, rs_map=rs_map
|
||||
)
|
||||
|
||||
# ── 5. 构建响应 ──
|
||||
@@ -266,6 +282,7 @@ async def get_overview(
|
||||
FROM auth.user_assistant_binding uab
|
||||
JOIN auth.users u ON uab.user_id = u.id
|
||||
WHERE uab.assistant_id = %s AND uab.site_id = %s
|
||||
AND uab.is_removed = false
|
||||
LIMIT 1
|
||||
""",
|
||||
(assistant_id, site_id),
|
||||
@@ -279,27 +296,76 @@ async def get_overview(
|
||||
logger.warning("查询助教信息失败", exc_info=True)
|
||||
|
||||
current_income = salary["total_income"] if salary else 0.0
|
||||
basic_rate = salary["basic_rate"] if salary else 0.0
|
||||
incentive_rate = salary["incentive_rate"] if salary else 0.0
|
||||
# CHANGE 2026-03-24 | basic_rate/incentive_rate 改为助教到手单价(客户价 - 球房提成),
|
||||
# 不再使用 base_course_price/bonus_course_price(客户收费标准)
|
||||
base_course_price = salary["basic_rate"] if salary else 0.0 # 客户收费标准
|
||||
bonus_course_price = salary["incentive_rate"] if salary else 0.0 # 客户收费标准
|
||||
base_deduction = salary["base_deduction"] if salary else 0.0
|
||||
bonus_deduction_ratio = salary["bonus_deduction_ratio"] if salary else 0.0
|
||||
# 助教到手单价 = 客户价 - 球房提成
|
||||
basic_rate = base_course_price - base_deduction
|
||||
incentive_rate = bonus_course_price * (1 - bonus_deduction_ratio)
|
||||
|
||||
basic_hours = salary["basic_hours"] if salary else 0.0
|
||||
bonus_hours = salary["bonus_hours"] if salary else 0.0
|
||||
pd_money = salary["assistant_pd_money_total"] if salary else 0.0
|
||||
cx_money = salary["assistant_cx_money_total"] if salary else 0.0
|
||||
top_rank_bonus = salary["top_rank_bonus"] if salary else 0.0
|
||||
recharge_commission = salary["recharge_commission"] if salary else 0.0
|
||||
|
||||
# 收入明细项
|
||||
income_items = _build_income_items(
|
||||
basic_rate, incentive_rate, basic_hours, bonus_hours,
|
||||
pd_money, cx_money,
|
||||
top_rank_bonus=top_rank_bonus,
|
||||
recharge_commission=recharge_commission,
|
||||
)
|
||||
|
||||
# 档位信息
|
||||
next_basic_rate = salary["next_tier_basic_rate"] if salary else 0.0
|
||||
next_incentive_rate = salary["next_tier_incentive_rate"] if salary else 0.0
|
||||
upgrade_hours = salary["next_tier_hours"] if salary else 0.0
|
||||
# CHANGE 2026-03-24 | 档位信息从 cfg_performance_tier 配置表计算,
|
||||
# 复用 task_manager._build_performance_summary 的逻辑
|
||||
total_hours = salary["total_hours"] if salary else 0.0
|
||||
upgrade_hours_needed = max(0.0, upgrade_hours - total_hours)
|
||||
tier_completed = salary["tier_completed"] if salary else False
|
||||
upgrade_bonus = 0.0 if tier_completed else (salary["bonus_money"] if salary else 0.0)
|
||||
tiers: list[dict] = []
|
||||
try:
|
||||
tiers = fdw_queries.get_performance_tiers(conn, site_id)
|
||||
except Exception:
|
||||
logger.warning("查询 cfg_performance_tier 失败", exc_info=True)
|
||||
|
||||
# 找到当前档位和下一档
|
||||
tier_completed = False
|
||||
next_tier_hours = 0.0
|
||||
current_tier_data = None
|
||||
next_tier_data = None
|
||||
if tiers:
|
||||
for i, t in enumerate(tiers):
|
||||
if t["min_hours"] > total_hours:
|
||||
next_tier_data = t
|
||||
current_tier_data = tiers[i - 1] if i > 0 else tiers[0]
|
||||
next_tier_hours = t["min_hours"]
|
||||
break
|
||||
if next_tier_data is None:
|
||||
# 已达到或超过最高档
|
||||
tier_completed = True
|
||||
current_tier_data = tiers[-1]
|
||||
|
||||
upgrade_hours_needed = max(0.0, next_tier_hours - total_hours) if not tier_completed else 0.0
|
||||
|
||||
# 下一档到手费率
|
||||
if next_tier_data:
|
||||
next_basic_rate = base_course_price - next_tier_data["base_deduction"]
|
||||
next_incentive_rate = bonus_course_price * (1 - next_tier_data["bonus_deduction_ratio"])
|
||||
else:
|
||||
next_basic_rate = 0.0
|
||||
next_incentive_rate = 0.0
|
||||
|
||||
# bonus_money: 升到下一档后因抽成降低能多拿的钱
|
||||
# 公式同 task_manager._build_performance_summary
|
||||
upgrade_bonus = 0.0
|
||||
if not tier_completed and current_tier_data and next_tier_data:
|
||||
base_ded_diff = current_tier_data["base_deduction"] - next_tier_data["base_deduction"]
|
||||
base_saving = next_tier_data["min_hours"] * base_ded_diff if base_ded_diff > 0 else 0.0
|
||||
bonus_ratio_diff = current_tier_data["bonus_deduction_ratio"] - next_tier_data["bonus_deduction_ratio"]
|
||||
bonus_saving = bonus_hours * bonus_course_price * bonus_ratio_diff if bonus_ratio_diff > 0 else 0.0
|
||||
upgrade_bonus = round(base_saving + bonus_saving, 2)
|
||||
|
||||
return {
|
||||
"coach_name": coach_name,
|
||||
@@ -335,27 +401,49 @@ def _build_income_items(
|
||||
bonus_hours: float,
|
||||
pd_money: float,
|
||||
cx_money: float,
|
||||
*,
|
||||
top_rank_bonus: float = 0.0,
|
||||
recharge_commission: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""构建收入明细项列表。"""
|
||||
"""
|
||||
构建收入明细项列表。
|
||||
|
||||
CHANGE 2026-03-24 | 始终显示所有项(基础课、激励课、Top3销冠奖、充值提成),即使为 0;
|
||||
Top3销冠奖为 0 时 desc 显示"继续努力"。
|
||||
"""
|
||||
items = []
|
||||
|
||||
# 基础课收入
|
||||
if basic_hours > 0 or pd_money > 0:
|
||||
items.append({
|
||||
"icon": "💰",
|
||||
"label": "基础课收入",
|
||||
"desc": format_income_desc(basic_rate, basic_hours),
|
||||
"value": f"¥{pd_money:,.2f}",
|
||||
})
|
||||
# 基础课收入(始终显示)
|
||||
items.append({
|
||||
"icon": "💰",
|
||||
"label": "基础课收入",
|
||||
"desc": format_income_desc(basic_rate, basic_hours),
|
||||
"value": f"¥{pd_money:,.2f}",
|
||||
})
|
||||
|
||||
# 激励课收入
|
||||
if bonus_hours > 0 or cx_money > 0:
|
||||
items.append({
|
||||
"icon": "🎯",
|
||||
"label": "激励课收入",
|
||||
"desc": format_income_desc(incentive_rate, bonus_hours),
|
||||
"value": f"¥{cx_money:,.2f}",
|
||||
})
|
||||
# 激励课收入(始终显示)
|
||||
items.append({
|
||||
"icon": "🎯",
|
||||
"label": "激励课收入",
|
||||
"desc": format_income_desc(incentive_rate, bonus_hours),
|
||||
"value": f"¥{cx_money:,.2f}",
|
||||
})
|
||||
|
||||
# Top3销冠奖(始终显示,为 0 时 desc 显示"继续努力")
|
||||
items.append({
|
||||
"icon": "🏆",
|
||||
"label": "Top3销冠奖",
|
||||
"desc": "继续努力" if top_rank_bonus == 0 else "本月销冠奖励",
|
||||
"value": f"¥{top_rank_bonus:,.2f}",
|
||||
})
|
||||
|
||||
# CHANGE 2026-03-24 | 充值提成(始终显示)
|
||||
items.append({
|
||||
"icon": "💳",
|
||||
"label": "充值提成",
|
||||
"desc": "充值激励",
|
||||
"value": f"¥{recharge_commission:,.2f}",
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
@@ -367,12 +455,17 @@ def _build_customer_lists(
|
||||
year: int,
|
||||
month: int,
|
||||
all_records: list[dict],
|
||||
*,
|
||||
rs_map: dict[int, float] | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
构建新客和常客列表。
|
||||
|
||||
新客: 本月有服务记录但本月之前无记录的客户
|
||||
常客: 本月服务次数 ≥ 2 的客户
|
||||
常客: 本月服务次数 ≥ 2 的客户(统计数据拉近90天)
|
||||
|
||||
CHANGE 2026-03-24 | 头像颜色改为前端根据 member_id 计算,后端只传 member_id 和首字。
|
||||
CHANGE 2026-03-24 | 常客展示数据改为近90天聚合(判定标准不变:本月≥2次)。
|
||||
"""
|
||||
if not all_records:
|
||||
return [], []
|
||||
@@ -395,16 +488,12 @@ def _build_customer_lists(
|
||||
stats["count"] += 1
|
||||
stats["total_hours"] += rec.get("service_hours", 0.0)
|
||||
stats["total_income"] += rec.get("income", 0.0)
|
||||
# 更新最后服务时间(记录已按 settle_time DESC 排序,第一条即最新)
|
||||
if stats["last_service"] is None:
|
||||
stats["last_service"] = rec.get("settle_time")
|
||||
|
||||
member_ids = list(member_stats.keys())
|
||||
|
||||
# 查询历史记录(本月之前是否有服务记录)
|
||||
# ⚠️ 直连 ETL 库查询 app.v_dwd_assistant_service_log RLS 视图
|
||||
# 列名映射: assistant_id → site_assistant_id, member_id → tenant_member_id,
|
||||
# is_trash → is_delete (int, 0=正常), settle_time → create_time
|
||||
# 查询历史记录(本月之前是否有服务记录)— 用于新客判定
|
||||
historical_members: set[int] = set()
|
||||
try:
|
||||
start_date = f"{year}-{month:02d}-01"
|
||||
@@ -425,33 +514,64 @@ def _build_customer_lists(
|
||||
except Exception:
|
||||
logger.warning("查询历史客户记录失败", exc_info=True)
|
||||
|
||||
# 查询近90天聚合数据(常客展示用)
|
||||
if month == 12:
|
||||
next_month_start = f"{year + 1}-01-01"
|
||||
else:
|
||||
next_month_start = f"{year}-{month + 1:02d}-01"
|
||||
stats_90d: dict[int, dict] = {}
|
||||
try:
|
||||
rows_90d = fdw_queries.get_service_records_90days(
|
||||
conn, site_id, assistant_id, next_month_start,
|
||||
)
|
||||
for r in rows_90d:
|
||||
stats_90d[r["member_id"]] = r
|
||||
except Exception:
|
||||
logger.warning("查询近90天服务记录失败", exc_info=True)
|
||||
|
||||
new_customers = []
|
||||
regular_customers = []
|
||||
|
||||
for mid, stats in member_stats.items():
|
||||
# CHANGE 2026-03-27 | 过滤散客/未知客户(member_id ≤ 0),不进入新客和常客列表
|
||||
if mid <= 0:
|
||||
continue
|
||||
name = stats["customer_name"]
|
||||
char, color = avatar_char_color(name)
|
||||
char = name[0] if name else "?"
|
||||
|
||||
# 新客:历史无记录
|
||||
if mid not in historical_members:
|
||||
last_service_dt = stats["last_service"]
|
||||
new_customers.append({
|
||||
"name": name,
|
||||
"member_id": mid,
|
||||
"avatar_char": char,
|
||||
"avatar_color": color,
|
||||
"last_service": _format_date_label(last_service_dt),
|
||||
"count": stats["count"],
|
||||
"heart_score": rs_map.get(mid, 0.0) if rs_map else 0.0,
|
||||
})
|
||||
|
||||
# 常客:本月 ≥ 2 次
|
||||
# 常客:本月 ≥ 2 次(展示数据用90天聚合)
|
||||
if stats["count"] >= 2:
|
||||
s90 = stats_90d.get(mid)
|
||||
if s90:
|
||||
reg_count = s90["count"]
|
||||
reg_hours = round(s90["total_hours"], 2)
|
||||
reg_income = s90["total_income"]
|
||||
else:
|
||||
# 90天查询失败时回退到本月数据
|
||||
reg_count = stats["count"]
|
||||
reg_hours = round(stats["total_hours"], 2)
|
||||
reg_income = stats["total_income"]
|
||||
|
||||
regular_customers.append({
|
||||
"name": name,
|
||||
"member_id": mid,
|
||||
"avatar_char": char,
|
||||
"avatar_color": color,
|
||||
"hours": round(stats["total_hours"], 2),
|
||||
"income": f"¥{stats['total_income']:,.2f}",
|
||||
"count": stats["count"],
|
||||
"hours": reg_hours,
|
||||
"income": f"¥{reg_income:,.2f}",
|
||||
"count": reg_count,
|
||||
"heart_score": rs_map.get(mid, 0.0) if rs_map else 0.0,
|
||||
})
|
||||
|
||||
# 新客按最后服务时间倒序
|
||||
@@ -470,6 +590,7 @@ def _build_customer_lists(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@trace_service("获取绩效明细", "Get performance records")
|
||||
async def get_records(
|
||||
user_id: int, site_id: int,
|
||||
year: int, month: int, page: int, page_size: int,
|
||||
@@ -506,8 +627,27 @@ async def get_records(
|
||||
# 判断 hasMore
|
||||
has_more = len(all_records) > page * page_size
|
||||
|
||||
# 按日期分组(不含 avatar)
|
||||
date_groups = group_records_by_date(page_records, include_avatar=False)
|
||||
# CHANGE 2026-03-27 | 批量查 RS 分数,注入到服务记录
|
||||
page_member_ids = list({r.get("member_id") for r in page_records if r.get("member_id")})
|
||||
rs_map: dict[int, float] = {}
|
||||
if page_member_ids:
|
||||
try:
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s AND member_id = ANY(%s)
|
||||
""",
|
||||
(assistant_id, page_member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rs_map[row[0]] = float(row[1])
|
||||
except Exception:
|
||||
logger.warning("PERF-2 查询 RS 分数失败", exc_info=True)
|
||||
|
||||
# 按日期分组(含 member_id / avatar_char,前端计算头像颜色)
|
||||
date_groups = group_records_by_date(page_records, include_avatar=True, rs_map=rs_map)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
|
||||
Reference in New Issue
Block a user