Files
Neo-ZQYY/apps/backend/app/services/board_service.py

664 lines
23 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.
# AI_CHANGELOG
# - 2026-03-20 | Prompt: RNS1.3 E2E 修复 | _build_recharge 环比比较逻辑修正compare 参数传递),
# _empty_revenue 新增确保包含所有必需字段skills 暂返回空列表
"""
看板业务逻辑服务层。
提供 BOARD-1助教看板、BOARD-2客户看板、BOARD-3财务看板
日期范围计算、环比计算等通用工具函数,以及各看板的编排函数。
"""
from __future__ import annotations
import calendar
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP
# ---------------------------------------------------------------------------
# 通用工具函数
# ---------------------------------------------------------------------------
def _calc_date_range(
time_enum: str, ref_date: date | None = None
) -> tuple[date, date]:
"""
根据时间枚举计算当期日期范围。
支持 BOARD-1 的 6 种枚举snake_case和 BOARD-3 的 8 种枚举camelCase
同义枚举(如 last_month / lastMonth映射到相同逻辑。
返回 (start_date, end_date),均为 date 类型end_date 为区间末日(含)。
"""
today = ref_date or date.today()
# --- 当月 ---
if time_enum == "month":
start = today.replace(day=1)
end = today.replace(day=calendar.monthrange(today.year, today.month)[1])
return start, end
# --- 上月 ---
if time_enum in ("last_month", "lastMonth"):
first_of_this_month = today.replace(day=1)
last_month_end = first_of_this_month - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
return last_month_start, last_month_end
# --- 本周(周一 ~ 周日)---
if time_enum == "week":
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
# --- 上周 ---
if time_enum == "lastWeek":
this_monday = today - timedelta(days=today.weekday())
last_sunday = this_monday - timedelta(days=1)
last_monday = last_sunday - timedelta(days=6)
return last_monday, last_sunday
# --- 本季度 ---
if time_enum == "quarter":
q_start_month = (today.month - 1) // 3 * 3 + 1
start = date(today.year, q_start_month, 1)
q_end_month = q_start_month + 2
end = date(today.year, q_end_month, calendar.monthrange(today.year, q_end_month)[1])
return start, end
# --- 上季度 ---
if time_enum in ("last_quarter", "lastQuarter"):
q_start_month = (today.month - 1) // 3 * 3 + 1
# 上季度末日 = 本季度首日前一天
this_q_start = date(today.year, q_start_month, 1)
prev_q_end = this_q_start - timedelta(days=1)
prev_q_start_month = (prev_q_end.month - 1) // 3 * 3 + 1
prev_q_start = date(prev_q_end.year, prev_q_start_month, 1)
return prev_q_start, prev_q_end
# --- 前 3 个月(不含本月)---
if time_enum in ("last_3m", "quarter3"):
# end = 上月末日
first_of_this_month = today.replace(day=1)
end = first_of_this_month - timedelta(days=1)
# start = 往前推 3 个月的首日
start = _month_offset(first_of_this_month, -3)
return start, end
# --- 前 6 个月(不含本月)---
if time_enum in ("last_6m", "half6"):
first_of_this_month = today.replace(day=1)
end = first_of_this_month - timedelta(days=1)
start = _month_offset(first_of_this_month, -6)
return start, end
raise ValueError(f"不支持的时间枚举: {time_enum}")
def _month_offset(d: date, months: int) -> date:
"""将日期 d 偏移 months 个月,保持 day=1。仅用于内部月份偏移计算。"""
# d 应为某月 1 日
total_months = d.year * 12 + (d.month - 1) + months
y, m = divmod(total_months, 12)
m += 1
return date(y, m, 1)
def _calc_prev_range(start_date: date, end_date: date) -> tuple[date, date]:
"""
根据当期范围计算上期日期范围。
上期长度等于当期长度prev_end = start_date - 1 天。
"""
period_length = (end_date - start_date).days + 1
prev_end = start_date - timedelta(days=1)
prev_start = prev_end - timedelta(days=period_length - 1)
return prev_start, prev_end
def calc_compare(current: Decimal, previous: Decimal) -> dict:
"""
统一环比计算。
返回:
- compare: str — "12.5%" / "新增" / "持平"
- is_down: bool — 是否下降
- is_flat: bool — 是否持平
规则:
- previous=0, current≠0 → "新增", is_down=False, is_flat=False
- previous=0, current=0 → "持平", is_down=False, is_flat=True
- 正常计算: (current - previous) / previous × 100%
- 正值 → is_down=False; 负值 → is_down=True; 零 → is_flat=True
"""
if previous == 0:
if current != 0:
return {"compare": "新增", "is_down": False, "is_flat": False}
return {"compare": "持平", "is_down": False, "is_flat": True}
diff = current - previous
pct = (diff / previous * 100).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
if pct > 0:
return {"compare": f"{pct}%", "is_down": False, "is_flat": False}
if pct < 0:
return {"compare": f"{abs(pct)}%", "is_down": True, "is_flat": False}
# pct == 0当期 == 上期)
return {"compare": "持平", "is_down": False, "is_flat": True}
import logging
from typing import Any
from fastapi import HTTPException
from app.services import fdw_queries
logger = logging.getLogger(__name__)
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
return get_connection()
# ---------------------------------------------------------------------------
# 排序映射
# ---------------------------------------------------------------------------
_SORT_KEY_MAP = {
"perf_desc": ("perf_hours", True),
"perf_asc": ("perf_hours", False),
"salary_desc": ("salary", True),
"salary_asc": ("salary", False),
"sv_desc": ("sv_amount", True),
"task_desc": ("task_total", True),
}
_SORT_DIM_MAP = {
"perf_desc": "perf", "perf_asc": "perf",
"salary_desc": "salary", "salary_asc": "salary",
"sv_desc": "sv", "task_desc": "task",
}
# ---------------------------------------------------------------------------
# BOARD-1 助教看板
# ---------------------------------------------------------------------------
async def get_coach_board(
sort: str, skill: str, time: str, site_id: int
) -> dict:
"""
BOARD-1助教看板。扁平返回所有维度字段。
参数互斥time=last_6m + sort=sv_desc → HTTP 400。
"""
# 参数互斥校验
if time == "last_6m" and sort == "sv_desc":
raise HTTPException(
status_code=400,
detail="最近6个月不支持客源储值排序",
)
start_date, end_date = _calc_date_range(time)
start_str = str(start_date)
end_str = str(end_date)
conn = _get_connection()
try:
# 1. 助教列表
assistants = fdw_queries.get_all_assistants(conn, site_id, skill)
if not assistants:
return {"items": [], "dim_type": _SORT_DIM_MAP.get(sort, "perf")}
aid_list = [a["assistant_id"] for a in assistants]
# 2. 批量查询绩效
salary_map = fdw_queries.get_salary_calc_batch(
conn, site_id, aid_list, start_str, end_str
)
# 3. Top 客户(降级为空)
top_map: dict[int, list[str]] = {}
try:
top_map = fdw_queries.get_top_customers_for_coaches(
conn, site_id, aid_list
)
except Exception:
logger.warning("BOARD-1 topCustomers 查询失败,降级为空", exc_info=True)
# 4. 储值数据
sv_map: dict[int, dict] = {}
try:
sv_map = fdw_queries.get_coach_sv_data(
conn, site_id, aid_list, start_str, end_str
)
except Exception:
logger.warning("BOARD-1 sv 数据查询失败,降级为空", exc_info=True)
# 5. 任务数据
task_map = _query_coach_tasks(conn, site_id, aid_list, start_str, end_str)
# 6. 组装扁平响应
items = []
for a in assistants:
aid = a["assistant_id"]
sal = salary_map.get(aid, {})
sv = sv_map.get(aid, {})
tasks = task_map.get(aid, {})
top_custs = top_map.get(aid, [])
name = a["name"]
initial = name[0] if name else ""
perf_hours = sal.get("effective_hours", 0.0)
salary_val = sal.get("gross_salary", 0.0)
task_recall = tasks.get("recall", 0)
task_callback = tasks.get("callback", 0)
items.append({
"id": aid,
"name": name,
"initial": initial,
"avatar_gradient": "",
"level": sal.get("level_name", a.get("level", "")),
"skills": [], # CHANGE 2026-03-20 | v_dim_assistant 无 skill 列,暂返回空
"top_customers": top_custs,
"perf_hours": perf_hours,
"perf_hours_before": None,
"perf_gap": None,
"perf_reached": False,
"salary": salary_val,
"salary_perf_hours": perf_hours,
"salary_perf_before": None,
"sv_amount": sv.get("sv_amount", 0.0),
"sv_customer_count": sv.get("sv_customer_count", 0),
"sv_consume": sv.get("sv_consume", 0.0),
"task_recall": task_recall,
"task_callback": task_callback,
"task_total": task_recall + task_callback,
})
# 7. 排序
sort_key, sort_desc = _SORT_KEY_MAP.get(sort, ("perf_hours", True))
items.sort(key=lambda x: x.get(sort_key, 0), reverse=sort_desc)
# 移除内部排序字段
for item in items:
item.pop("task_total", None)
return {
"items": items,
"dim_type": _SORT_DIM_MAP.get(sort, "perf"),
}
finally:
conn.close()
def _query_coach_tasks(
conn: Any, site_id: int, assistant_ids: list[int],
start_date: str, end_date: str,
) -> dict[int, dict]:
"""
查询助教任务完成数BOARD-1 task 维度)。
来源: biz.coach_tasks按 task_type 分类统计 recall/callback。
"""
if not assistant_ids:
return {}
result: dict[int, dict] = {}
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT assistant_id,
COUNT(*) FILTER (WHERE task_type = 'recall') AS recall_count,
COUNT(*) FILTER (WHERE task_type = 'callback') AS callback_count
FROM biz.coach_tasks
WHERE assistant_id = ANY(%s)
AND site_id = %s
AND completed_at >= %s::date
AND completed_at <= %s::date
AND status = 'completed'
GROUP BY assistant_id
""",
(assistant_ids, site_id, start_date, end_date),
)
for row in cur.fetchall():
result[row[0]] = {
"recall": row[1] or 0,
"callback": row[2] or 0,
}
conn.commit()
except Exception:
logger.warning("BOARD-1 任务查询失败,降级为空", exc_info=True)
return result
# ---------------------------------------------------------------------------
# BOARD-2 客户看板
# ---------------------------------------------------------------------------
# 维度 → FDW 查询函数映射
_DIMENSION_QUERY_MAP = {
"recall": "get_customer_board_recall",
"potential": "get_customer_board_potential",
"balance": "get_customer_board_balance",
"recharge": "get_customer_board_recharge",
"recent": "get_customer_board_recent",
"spend60": "get_customer_board_spend60",
"freq60": "get_customer_board_freq60",
"loyal": "get_customer_board_loyal",
}
async def get_customer_board(
dimension: str, project: str, page: int, page_size: int, site_id: int
) -> dict:
"""
BOARD-2客户看板。按维度返回专属字段 + 分页。
"""
query_fn_name = _DIMENSION_QUERY_MAP.get(dimension)
if not query_fn_name:
raise HTTPException(status_code=400, detail=f"不支持的维度: {dimension}")
query_fn = getattr(fdw_queries, query_fn_name)
conn = _get_connection()
try:
# 1. 按维度查询分页数据
result = query_fn(conn, site_id, project, page, page_size)
items = result["items"]
# 2. 批量查询客户关联助教
member_ids = [item["member_id"] for item in items if item.get("member_id")]
assistants_map: dict[int, list[dict]] = {}
if member_ids:
try:
assistants_map = fdw_queries.get_customer_assistants(
conn, site_id, member_ids
)
except Exception:
logger.warning("BOARD-2 客户助教查询失败,降级为空", exc_info=True)
# 3. 组装响应(添加基础字段 + assistants
for item in items:
mid = item.get("member_id", 0)
name = item.get("name", "")
item["id"] = mid
item["initial"] = name[0] if name else ""
item["avatar_cls"] = ""
item["assistants"] = assistants_map.get(mid, [])
return {
"items": items,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
}
finally:
conn.close()
# ---------------------------------------------------------------------------
# BOARD-3 财务看板
# ---------------------------------------------------------------------------
async def get_finance_board(
time: str, area: str, compare: int, site_id: int
) -> dict:
"""
BOARD-3财务看板。6 板块独立查询、独立降级。
area≠all 时 recharge 返回 null。
compare=1 时计算上期范围并调用 calc_compare。
compare=0 时环比字段为 None序列化时排除
"""
start_date, end_date = _calc_date_range(time)
start_str = str(start_date)
end_str = str(end_date)
prev_start_str = None
prev_end_str = None
if compare == 1:
prev_start, prev_end = _calc_prev_range(start_date, end_date)
prev_start_str = str(prev_start)
prev_end_str = str(prev_end)
conn = _get_connection()
try:
# 各板块独立 try/except
overview = _build_overview(conn, site_id, start_str, end_str,
prev_start_str, prev_end_str, compare)
recharge = None
if area == "all":
recharge = _build_recharge(conn, site_id, start_str, end_str,
prev_start_str, prev_end_str, compare)
revenue = _build_revenue(conn, site_id, start_str, end_str, area)
cashflow = _build_cashflow(conn, site_id, start_str, end_str,
prev_start_str, prev_end_str, compare)
expense = _build_expense(conn, site_id, start_str, end_str,
prev_start_str, prev_end_str, compare)
coach_analysis = _build_coach_analysis(conn, site_id, start_str, end_str,
prev_start_str, prev_end_str, compare)
return {
"overview": overview,
"recharge": recharge,
"revenue": revenue,
"cashflow": cashflow,
"expense": expense,
"coach_analysis": coach_analysis,
}
finally:
conn.close()
def _build_overview(
conn: Any, site_id: int, start: str, end: str,
prev_start: str | None, prev_end: str | None, compare: int,
) -> dict:
"""经营一览板块。"""
try:
data = fdw_queries.get_finance_overview(conn, site_id, start, end)
except Exception:
logger.warning("overview 查询失败,降级为空", exc_info=True)
return _empty_overview()
result = {**data}
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end)
_attach_compare(result, data, prev, [
"occurrence", "discount", "discount_rate", "confirmed_revenue",
"cash_in", "cash_out", "cash_balance", "balance_rate",
])
except Exception:
logger.warning("overview 环比查询失败", exc_info=True)
return result
def _build_recharge(
conn: Any, site_id: int, start: str, end: str,
prev_start: str | None, prev_end: str | None, compare: int,
) -> dict | None:
"""预收资产板块。"""
try:
data = fdw_queries.get_finance_recharge(conn, site_id, start, end)
except Exception:
logger.warning("recharge 查询失败,降级为 null", exc_info=True)
return None
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_recharge(conn, site_id, prev_start, prev_end)
_attach_compare(data, data, prev, [
"actual_income", "first_charge", "renew_charge",
"consumed", "card_balance",
])
# 赠送卡矩阵环比
for i, row in enumerate(data.get("gift_rows", [])):
prev_row = prev.get("gift_rows", [{}] * 3)[i] if i < len(prev.get("gift_rows", [])) else {}
for key in ["total", "liquor", "table_fee", "voucher"]:
# gift_rows 的 cell 是 GiftCell dict{"value": float}
cur_cell = row.get(key, {})
prev_cell = prev_row.get(key, {})
cur_val = Decimal(str(cur_cell.get("value", 0) if isinstance(cur_cell, dict) else cur_cell))
prev_val = Decimal(str(prev_cell.get("value", 0) if isinstance(prev_cell, dict) else prev_cell))
cmp = calc_compare(cur_val, prev_val)
if isinstance(cur_cell, dict):
cur_cell["compare"] = cmp["compare"]
cur_cell["down"] = cmp["is_down"]
cur_cell["flat"] = cmp["is_flat"]
else:
row[key] = {"value": float(cur_val), "compare": cmp["compare"], "down": cmp["is_down"], "flat": cmp["is_flat"]}
except Exception:
logger.warning("recharge 环比查询失败", exc_info=True)
return data
def _build_revenue(
conn: Any, site_id: int, start: str, end: str, area: str,
) -> dict:
"""应计收入板块。"""
try:
return fdw_queries.get_finance_revenue(conn, site_id, start, end, area)
except Exception:
logger.warning("revenue 查询失败,降级为空", exc_info=True)
return _empty_revenue()
def _build_cashflow(
conn: Any, site_id: int, start: str, end: str,
prev_start: str | None, prev_end: str | None, compare: int,
) -> dict:
"""现金流入板块。"""
try:
data = fdw_queries.get_finance_cashflow(conn, site_id, start, end)
except Exception:
logger.warning("cashflow 查询失败,降级为空", exc_info=True)
return {"consume_items": [], "recharge_items": [], "total": 0.0}
return data
def _build_expense(
conn: Any, site_id: int, start: str, end: str,
prev_start: str | None, prev_end: str | None, compare: int,
) -> dict:
"""现金流出板块。"""
try:
data = fdw_queries.get_finance_expense(conn, site_id, start, end)
except Exception:
logger.warning("expense 查询失败,降级为空", exc_info=True)
return {
"operation_items": [], "fixed_items": [],
"coach_items": [], "platform_items": [], "total": 0.0,
}
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_expense(conn, site_id, prev_start, prev_end)
total_cmp = calc_compare(
Decimal(str(data["total"])), Decimal(str(prev["total"]))
)
data["total_compare"] = total_cmp["compare"]
data["total_down"] = total_cmp["is_down"]
data["total_flat"] = total_cmp["is_flat"]
except Exception:
logger.warning("expense 环比查询失败", exc_info=True)
return data
def _build_coach_analysis(
conn: Any, site_id: int, start: str, end: str,
prev_start: str | None, prev_end: str | None, compare: int,
) -> dict:
"""助教分析板块。"""
try:
data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end)
except Exception:
logger.warning("coachAnalysis 查询失败,降级为空", exc_info=True)
empty_table = {"total_pay": 0.0, "total_share": 0.0, "avg_hourly": 0.0, "rows": []}
return {"basic": empty_table, "incentive": {**empty_table}}
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_coach_analysis(
conn, site_id, prev_start, prev_end
)
for key in ("basic", "incentive"):
cur_t = data[key]
prev_t = prev[key]
_attach_compare(cur_t, cur_t, prev_t, [
"total_pay", "total_share", "avg_hourly",
])
except Exception:
logger.warning("coachAnalysis 环比查询失败", exc_info=True)
return data
# ---------------------------------------------------------------------------
# 环比辅助
# ---------------------------------------------------------------------------
def _attach_compare(
target: dict, current: dict, previous: dict, fields: list[str]
) -> None:
"""为 target dict 中的指定字段附加环比三元组。"""
for field in fields:
cur_val = Decimal(str(current.get(field, 0)))
prev_val = Decimal(str(previous.get(field, 0)))
cmp = calc_compare(cur_val, prev_val)
target[f"{field}_compare"] = cmp["compare"]
target[f"{field}_down"] = cmp["is_down"]
target[f"{field}_flat"] = cmp["is_flat"]
# ---------------------------------------------------------------------------
# 空默认值工厂(优雅降级用)
# ---------------------------------------------------------------------------
def _empty_overview() -> dict:
return {
"occurrence": 0.0, "discount": 0.0, "discount_rate": 0.0,
"confirmed_revenue": 0.0, "cash_in": 0.0, "cash_out": 0.0,
"cash_balance": 0.0, "balance_rate": 0.0,
}
def _empty_revenue() -> dict:
"""应计收入空默认值(优雅降级用)。
CHANGE 2026-03-20 | 新增,确保包含所有必需字段:
price_items, total_occurrence, confirmed_total, channel_items
"""
return {
"structure_rows": [],
"price_items": [],
"total_occurrence": 0.0,
"discount_items": [],
"confirmed_total": 0.0,
"channel_items": [],
}