664 lines
23 KiB
Python
664 lines
23 KiB
Python
# 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": [],
|
||
}
|