Files
Neo-ZQYY/apps/backend/app/services/board_service.py
Neo 2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:32:07 +08:00

1205 lines
48 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
from app.trace.decorators import trace_service
# ---------------------------------------------------------------------------
# 通用工具函数
# ---------------------------------------------------------------------------
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()
# --- 当月cap 到今天)---
if time_enum == "month":
start = today.replace(day=1)
end = today
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())
end = today
return monday, end
# --- 上周 ---
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
# --- 本季度cap 到今天)---
if time_enum == "quarter":
q_start_month = (today.month - 1) // 3 * 3 + 1
start = date(today.year, q_start_month, 1)
end = today
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(
time_enum: str, start_date: date, end_date: date
) -> tuple[date, date]:
"""
根据当期范围和周期类型计算上期同期日期范围。
CHANGE 2026-03-28 | 环比改为同期对比:
- month: 当期 3/1~3/28 → 上期 2/1~2/28上月同日
- week: 当期 周一~周四 → 上期 上周一~上周四(上周同天数)
- quarter: 当期 1/1~3/28 → 上期 去年10/1~10/28 对应天数
- lastMonth: 2/1~2/28 → 1/1~1/28再上月同天数
- lastWeek: 上周一~上周日 → 再上周一~再上周日
- lastQuarter: 上季度完整 → 再上季度完整
- quarter3/half6: 往前推等长天数(无明确"同期"概念)
"""
elapsed_days = (end_date - start_date).days # 当期已过天数0-indexed
# 月度类上月1日 + 同样天数
if time_enum in ("month", "last_month", "lastMonth"):
prev_start = _month_offset(start_date, -1)
# 上月同日,但不超过上月末日
prev_end_day = min(end_date.day, calendar.monthrange(prev_start.year, prev_start.month)[1])
prev_end = prev_start.replace(day=prev_end_day)
return prev_start, prev_end
# 周度类:往前推 7 天
if time_enum in ("week", "lastWeek"):
prev_start = start_date - timedelta(days=7)
prev_end = end_date - timedelta(days=7)
return prev_start, prev_end
# 季度类:上季度首日 + 同样天数
if time_enum in ("quarter", "last_quarter", "lastQuarter"):
prev_q_start = _month_offset(start_date, -3)
prev_end = prev_q_start + timedelta(days=elapsed_days)
# 不超过上季度末日
prev_q_end_month = prev_q_start.month + 2
prev_q_end_max = date(prev_q_start.year, prev_q_end_month,
calendar.monthrange(prev_q_start.year, prev_q_end_month)[1])
if prev_end > prev_q_end_max:
prev_end = prev_q_end_max
return prev_q_start, prev_end
# 其他quarter3/half6往前推等长天数
period_length = elapsed_days + 1
prev_end = start_date - timedelta(days=1)
prev_start = prev_end - timedelta(days=period_length - 1)
return prev_start, prev_end
@trace_service(description_zh="计算对比数据", description_en="Calc Compare")
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),
}
# 项目标签 category_code → 前端显示文本 / CSS 类名
_SKILL_DISPLAY = {
"BILLIARD": "🎱",
"SNOOKER": "",
"MAHJONG": "🀄",
"KTV": "🎤",
}
_SKILL_CLS = {
"BILLIARD": "skill--chinese",
"SNOOKER": "skill--snooker",
"MAHJONG": "skill--mahjong",
"KTV": "skill--karaoke",
}
_SORT_DIM_MAP = {
"perf_desc": "perf", "perf_asc": "perf",
"salary_desc": "salary", "salary_asc": "salary",
"sv_desc": "sv", "task_desc": "task",
}
# ---------------------------------------------------------------------------
# BOARD-1 助教看板
# ---------------------------------------------------------------------------
@trace_service("获取助教看板", "Get coach board")
async def get_coach_board(
sort: str, skill: str, time: str, page: int, page_size: int, 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. 查询档位配置,计算距升档(仅本月/上月有意义)
tier_nodes: list[float] = []
show_perf_gap = time in ("month", "last_month")
if show_perf_gap:
try:
tiers = fdw_queries.get_performance_tiers(conn, site_id)
tier_nodes = [float(t["min_hours"]) for t in tiers] if tiers else []
except Exception as e:
logger.warning("BOARD-1 档位配置查询失败: %s", e, exc_info=True)
# 7. 组装扁平响应
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 = float(sal.get("effective_hours", 0) or 0)
salary_val = float(sal.get("gross_salary", 0) or 0)
task_recall = tasks.get("recall", 0)
task_callback = tasks.get("callback", 0)
# 折前课时:当 effective_hours != raw_hours 时显示(惩罚扣减导致的差异)
# 惩罚规则:同台 >2 助教重叠per_hour_contribution < 24 元时按比例扣减
raw_hours = float(sal.get("raw_hours", 0) or 0)
perf_hours_before = None
if abs(perf_hours - raw_hours) > 0.01:
perf_hours_before = raw_hours
# 计算距升档差距
perf_gap = None
perf_reached = False
if tier_nodes and perf_hours is not None:
# 找到下一个未达到的档位
for threshold in tier_nodes:
if perf_hours < threshold:
gap = threshold - perf_hours
perf_gap = f"距升档 {gap:.1f}h"
break
else:
perf_reached = True # 已达到最高档
items.append({
"id": aid,
"name": name,
"initial": initial,
"avatar_gradient": "",
"level": sal.get("level_name", a.get("level", "")),
# CHANGE 2026-03-29 | 从 get_all_assistants 返回的 skill 字段取项目标签
# Schema 要求 list[CoachSkillItem]{text, cls}),不是纯字符串
# text 映射为中文短名 + emojicls 映射为 CSS 类名
"skills": [
{"text": _SKILL_DISPLAY.get(s, s), "cls": _SKILL_CLS.get(s, "")}
for s in (a.get("skill") or "").split(",") if s
],
"top_customers": top_custs,
"perf_hours": perf_hours,
"perf_hours_before": perf_hours_before,
"perf_gap": perf_gap,
"perf_reached": perf_reached,
"salary": salary_val,
"salary_perf_hours": perf_hours,
"salary_perf_before": perf_hours_before,
"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. 排序id 作 tiebreaker 保证分页稳定)
sort_key, sort_desc = _SORT_KEY_MAP.get(sort, ("perf_hours", True))
items.sort(key=lambda x: (x.get(sort_key, 0), x.get("id", 0)), reverse=sort_desc)
# 移除内部排序字段
for item in items:
item.pop("task_total", None)
# 8. 分页
total = len(items)
start = (page - 1) * page_size
items = items[start : start + page_size]
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"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 维度)。
CHANGE 2026-04-08 | Fix-13 改造
CHANGE 2026-04-13 | 狭义召回recall 改为从 coach_tasks 统计 status='completed'
不再使用 recall_events广义。recall + callback 统一口径。
"""
if not assistant_ids:
return {}
result: dict[int, dict] = {}
try:
with conn.cursor() as cur:
# 狭义召回+回访完成数:均从 coach_tasks 统计status='completed' 表示助教亲自完成
cur.execute(
"""
SELECT assistant_id, task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = ANY(%s)
AND site_id = %s
AND completed_at >= %s::date
AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
AND status = 'completed'
GROUP BY assistant_id, task_type
""",
(assistant_ids, site_id, start_date, end_date),
)
for row in cur.fetchall():
aid, task_type, cnt = row[0], row[1], row[2] or 0
result.setdefault(aid, {"recall": 0, "callback": 0})
if task_type in ("high_priority_recall", "priority_recall"):
result[aid]["recall"] += cnt
elif task_type == "follow_up_visit":
result[aid]["callback"] += cnt
conn.commit()
except Exception:
logger.warning("BOARD-1 任务查询失败,降级为空", exc_info=True)
return result
# ---------------------------------------------------------------------------
# BOARD-2 客户看板
# ---------------------------------------------------------------------------
def _batch_ideal_days(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, int]:
"""批量查询客户理想到店间隔天数balance/recharge 维度头部用)。"""
from app.services.fdw_queries import _fdw_context
result: dict[int, int] = {}
try:
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, COALESCE(ideal_interval_days, 0)
FROM app.v_dws_member_winback_index
WHERE member_id = ANY(%s)
""",
(member_ids,),
)
for row in cur.fetchall():
result[row[0]] = int(row[1]) if row[1] is not None else 0
except Exception:
logger.warning("_batch_ideal_days 查询失败", exc_info=True)
return result
def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[dict]]:
"""批量查询客户-助教服务明细loyal 维度 coachDetails 用)。每个客户前 5 个。"""
from app.services.fdw_queries import _fdw_context
result: dict[int, list[dict]] = {mid: [] for mid in member_ids}
try:
with _fdw_context(conn, site_id) as cur:
# CHANGE 2026-03-29 | coach_spend 改为从 dwd_assistant_service_log 聚合 60 天消费
cur.execute(
"""
SELECT ri.member_id,
COALESCE(da.nickname, da.real_name, '') AS name,
ri.rs_display,
ri.session_count,
ri.total_duration_minutes,
COALESCE(s60.spend_60d, 0) AS spend_60d
FROM app.v_dws_member_assistant_relation_index ri
LEFT JOIN app.v_dim_assistant da
ON ri.assistant_id = da.assistant_id AND da.scd2_is_current = 1
LEFT JOIN (
SELECT tenant_member_id, site_assistant_id,
SUM(ledger_amount) AS spend_60d
FROM app.v_dwd_assistant_service_log
WHERE is_delete = 0
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
AND tenant_member_id = ANY(%s)
GROUP BY tenant_member_id, site_assistant_id
) s60 ON ri.member_id = s60.tenant_member_id
AND ri.assistant_id = s60.site_assistant_id
WHERE ri.member_id = ANY(%s)
AND (da.leave_status IS NULL OR da.leave_status = 0)
ORDER BY ri.member_id, ri.rs_display DESC
""",
(member_ids, member_ids),
)
for row in cur.fetchall():
mid = row[0]
if mid in result and len(result[mid]) < 5:
svc_count = row[3] or 0
total_mins = float(row[4]) if row[4] else 0.0
avg_dur = round(total_mins / 60 / svc_count, 1) if svc_count > 0 else 0.0
result[mid].append({
"name": row[1] or "",
"cls": "",
"heart_score": float(row[2]) if row[2] is not None else 0.0,
"avg_duration": f"{avg_dur}h",
"service_count": str(svc_count),
"coach_spend": float(row[5]) if row[5] is not None else 0.0,
"relation_idx": float(row[2]) if row[2] is not None else 0.0,
})
except Exception:
logger.warning("_batch_coach_details 查询失败", exc_info=True)
return result
def _batch_member_projects(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[str]]:
"""批量查询客户项目标签BOARD-2 用)。通过 FDW 视图查询。"""
from app.services.fdw_queries import _fdw_context
result: dict[int, list[str]] = {mid: [] for mid in member_ids}
try:
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, array_agg(DISTINCT category_code)
FROM app.v_dws_member_project_tag
WHERE member_id = ANY(%s) AND is_tagged = true
GROUP BY member_id
""",
(member_ids,),
)
for row in cur.fetchall():
mid = row[0]
codes = row[1] or []
if mid in result:
result[mid] = [c for c in codes if c]
except Exception:
logger.warning("_batch_member_projects 查询失败", exc_info=True)
return result
# 维度 → 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",
}
@trace_service("获取客户看板", "Get customer board")
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)
# 2b. 批量查询客户项目标签
member_projects: dict[int, list[str]] = {}
if member_ids:
try:
member_projects = _batch_member_projects(conn, site_id, member_ids)
except Exception:
logger.warning("BOARD-2 客户项目标签查询失败,降级为空", exc_info=True)
# 2c. balance/recharge 维度:补充 ideal_days
if dimension in ("balance", "recharge") and member_ids:
try:
ideal_map = _batch_ideal_days(conn, site_id, member_ids)
for item in items:
mid = item.get("member_id", 0)
if item.get("ideal_days") is None:
item["ideal_days"] = ideal_map.get(mid, 0)
except Exception:
logger.warning("BOARD-2 ideal_days 查询失败", 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, [])
item["projects"] = member_projects.get(mid, [])
# 3b. loyal 维度:为每个客户补充 coach_details前 5 个助教的服务明细)
if dimension == "loyal" and member_ids:
try:
coach_details_map = _batch_coach_details(conn, site_id, member_ids)
for item in items:
mid = item.get("member_id", 0)
item["coach_details"] = coach_details_map.get(mid, [])
except Exception:
logger.warning("BOARD-2 loyal coachDetails 查询失败", exc_info=True)
for item in items:
item["coach_details"] = []
# CHANGE 2026-03-28 | P5 联调修复items 是 list[dict]Pydantic CamelModel
# 不会自动转换内部 dict 的 key。手动 snake_case → camelCase。
# CHANGE 2026-03-29 | 递归处理嵌套 list[dict](如 assistants 数组)
def _to_camel(key: str) -> str:
parts = key.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
def _camel_dict(d: dict) -> dict:
result = {}
for k, v in d.items():
ck = _to_camel(k)
if isinstance(v, list):
result[ck] = [_camel_dict(i) if isinstance(i, dict) else i for i in v]
elif isinstance(v, dict):
result[ck] = _camel_dict(v)
else:
result[ck] = v
return result
camel_items = [_camel_dict(item) for item in items]
return {
"items": camel_items,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
}
finally:
conn.close()
# ---------------------------------------------------------------------------
# BOARD-3 财务看板
# ---------------------------------------------------------------------------
# CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 | 缓存/日粒度查询路由
COMPLETED_PERIODS = {"lastMonth", "lastWeek", "lastQuarter", "quarter3", "half6"}
CURRENT_PERIODS = {"month", "week", "quarter"}
@trace_service("获取财务看板", "Get finance board")
async def get_finance_board(
time: str, area: str, compare: int, site_id: int
) -> dict:
"""
BOARD-3财务看板。6 板块独立查询、独立降级。
CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 |
- 已完成周期先查缓存 → 未命中从日粒度表 SUM → 写缓存
- 当期周期直接从日粒度表 SUM不查缓存
- overview/revenue 改为从 dws_finance_area_daily 按 area_code 查询
- cashflow/expense/coach_analysis 不变(始终用全局数据)
- area≠all 时 recharge 返回 null
- area≠all 时 overview 覆盖逻辑保留
- compare=1 时对上期执行同样缓存/日粒度逻辑
"""
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(time, 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, area)
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,
prev_start_str, prev_end_str, compare)
# CHANGE 2026-03-28 | 非全部区域时,用 revenue 的数据覆盖 overview 的发生额/优惠/确认收入
if area != "all" and revenue:
overview["occurrence"] = revenue.get("total_occurrence", 0.0)
overview["discount"] = revenue.get("discount_total", 0.0)
overview["confirmed_revenue"] = revenue.get("confirmed_total", 0.0)
# discount_rate 重算
occ = overview["occurrence"]
overview["discount_rate"] = (overview["discount"] / occ) if occ > 0 else 0.0
# CHANGE 2026-03-29 | area≠all 时隐藏实收流水(现金流 4 项无法按区域拆分)
overview["cash_in"] = None
overview["cash_out"] = None
overview["cash_balance"] = None
overview["balance_rate"] = None
# 移除现金流环比字段(如有)
for f in ("cash_in", "cash_out", "cash_balance", "balance_rate"):
overview.pop(f"{f}_compare", None)
overview.pop(f"{f}_down", None)
overview.pop(f"{f}_flat", None)
# CHANGE 2026-03-29 | area≠all 时隐藏现金流入和现金流出板块
cashflow = None
expense = None
if area == "all":
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, area)
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,
area: str = "all",
) -> dict:
"""经营一览板块。
CHANGE 2026-04-08 | P0 修复发生额失真 |
area=all 时回退到 dws_finance_daily_summaryget_finance_overview
仅 area≠all 时走 dws_finance_area_dailyget_finance_overview_area
原因area_daily 的 all 行只聚合有桌台映射的结算单,漏算无桌台单据约 12%
"""
try:
if area == "all":
data = fdw_queries.get_finance_overview(conn, site_id, start, end)
else:
data = fdw_queries.get_finance_overview_area(conn, site_id, start, end, area)
except Exception:
logger.warning("overview 查询失败,降级为空", exc_info=True)
return _empty_overview()
result = {**data}
if compare == 1 and prev_start and prev_end:
try:
if area == "all":
prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end)
else:
prev = fdw_queries.get_finance_overview_area(conn, site_id, prev_start, prev_end, area)
_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", "all_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,
prev_start: str | None = None, prev_end: str | None = None, compare: int = 0,
) -> dict:
"""应计收入板块。
CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 |
改为从 dws_finance_area_daily 按 area_code 查询(通过 get_finance_revenue_area
然后在 Python 层构建 structure_rows / discount_items / channel_items 保持返回结构不变。
"""
try:
if area == "all":
# CHANGE 2026-03-29 | area=all 走旧版查询,保留收入结构的区域子行拆分
data = fdw_queries.get_finance_revenue(conn, site_id, start, end, area)
else:
raw = fdw_queries.get_finance_revenue_area(conn, site_id, start, end, area)
data = _format_revenue_from_area(raw, conn, site_id, start, end, area)
except Exception:
logger.warning("revenue 查询失败,降级为空", exc_info=True)
return _empty_revenue()
if compare == 1 and prev_start and prev_end:
try:
if area == "all":
prev = fdw_queries.get_finance_revenue(conn, site_id, prev_start, prev_end, area)
else:
prev_raw = fdw_queries.get_finance_revenue_area(conn, site_id, prev_start, prev_end, area)
prev = _format_revenue_from_area(prev_raw, conn, site_id, prev_start, prev_end, area)
# 总计环比
_attach_compare(data, data, prev, [
"total_occurrence", "discount_total", "confirmed_total",
])
# structure_rows 行级环比(按 id 匹配)
prev_struct = {r["id"]: r for r in prev.get("structure_rows", [])}
for row in data.get("structure_rows", []):
prev_row = prev_struct.get(row["id"], {})
cmp = calc_compare(
Decimal(str(row.get("booked", 0))),
Decimal(str(prev_row.get("booked", 0))),
)
row["booked_compare"] = cmp["compare"]
# price_items 行级环比(按 label 匹配)
prev_prices = {r["label"]: r for r in prev.get("price_items", [])}
for item in data.get("price_items", []):
prev_item = prev_prices.get(item["label"], {})
cmp = calc_compare(
Decimal(str(item.get("amount", 0))),
Decimal(str(prev_item.get("amount", 0))),
)
item["compare"] = cmp["compare"]
# discount_items 行级环比(按 label 匹配)
prev_discounts = {r["label"]: r for r in prev.get("discount_items", [])}
for item in data.get("discount_items", []):
prev_item = prev_discounts.get(item["label"], {})
cmp = calc_compare(
Decimal(str(item.get("amount", 0))),
Decimal(str(prev_item.get("amount", 0))),
)
item["compare"] = cmp["compare"]
# channel_items 行级环比(按 label 匹配)
prev_channels = {r["label"]: r for r in prev.get("channel_items", [])}
for item in data.get("channel_items", []):
prev_item = prev_channels.get(item["label"], {})
cmp = calc_compare(
Decimal(str(item.get("amount", 0))),
Decimal(str(prev_item.get("amount", 0))),
)
item["compare"] = cmp["compare"]
except Exception:
logger.warning("revenue 环比查询失败", exc_info=True)
return data
def _format_revenue_from_area(
raw: dict, conn: Any, site_id: int, start: str, end: str, area: str,
) -> dict:
"""将 get_finance_revenue_area 的原始聚合数据格式化为前端期望的 revenue 结构。
CHANGE 2026-04-01 | board-finance-dws-area-refactor 9.1 |
从 dws_finance_area_daily 聚合数据构建 structure_rows / discount_items / channel_items
保持与旧 get_finance_revenue 返回结构完全一致。
"""
total_table_charge = raw.get("table_fee_amount", 0.0)
total_goods = raw.get("goods_amount", 0.0)
total_pd = raw.get("assistant_pd_amount", 0.0)
total_cx = raw.get("assistant_cx_amount", 0.0)
total_income = raw.get("total_occurrence", 0.0)
# 构建 structure_rows简化版不再按物理区域拆分子行因为 area_daily 已按 area_code 聚合)
structure_rows = [
{"id": "table_charge", "name": "开台与包厢", "desc": None,
"is_sub": False, "amount": total_table_charge,
"discount": 0.0, "booked": total_table_charge},
{"id": "assistant_pd", "name": "助教 基础课", "desc": None,
"is_sub": False, "amount": total_pd,
"discount": 0.0, "booked": total_pd},
{"id": "assistant_cx", "name": "助教 激励课", "desc": None,
"is_sub": False, "amount": total_cx,
"discount": 0.0, "booked": total_cx},
{"id": "goods", "name": "食品酒水", "desc": None,
"is_sub": False, "amount": total_goods,
"discount": 0.0, "booked": total_goods},
]
# 发生额构成
price_items = [
{"label": "开台消费", "amount": total_table_charge},
{"label": "酒水商品", "amount": total_goods},
{"label": "助教服务", "amount": total_pd + total_cx},
]
# 优惠拆分5 项,与旧逻辑一致)
groupbuy_d = raw.get("discount_groupbuy", 0.0)
vip_d = raw.get("discount_vip", 0.0)
manual_d = raw.get("discount_manual", 0.0)
gift_card_d = raw.get("discount_gift_card", 0.0)
# 其他 = discount_rounding + discount_other
rounding_d = raw.get("discount_rounding", 0.0)
other_d = raw.get("discount_other", 0.0)
discount_items = [
{"label": "团购优惠", "amount": groupbuy_d},
{"label": "会员折扣", "amount": vip_d},
{"label": "手动调整", "amount": manual_d + other_d},
{"label": "赠送卡抵扣", "desc": "台桌卡+酒水卡+抵用券", "amount": gift_card_d},
{"label": "其他优惠", "desc": "免单+抹零", "amount": rounding_d},
]
total_discount = raw.get("discount_total", 0.0)
# 回填收入结构表的优惠分摊
if total_table_charge > 0 and total_discount > 0:
for row in structure_rows:
if row["id"] == "table_charge":
row["discount"] = total_discount
row["booked"] = total_table_charge - total_discount
# 渠道分布(从 dws_finance_area_daily 的 all 行获取,因为渠道数据仅 all 有值)
# 需要额外查询 all 行的渠道数据
try:
channel_data = _get_channel_items(conn, site_id, start, end)
except Exception:
logger.warning("revenue 渠道数据查询失败,降级为空", exc_info=True)
channel_data = [
{"label": "储值卡结算冲销", "amount": 0.0},
{"label": "现金/线上支付", "amount": 0.0},
{"label": "团购核销确认收入", "desc": "团购成交价", "amount": 0.0},
]
confirmed_total = total_income - abs(total_discount)
return {
"structure_rows": structure_rows,
"price_items": price_items,
"total_occurrence": total_income,
"discount_items": discount_items,
"discount_total": total_discount,
"confirmed_total": confirmed_total,
"channel_items": channel_data,
}
def _get_channel_items(conn: Any, site_id: int, start: str, end: str) -> list[dict]:
"""从 v_dws_finance_daily_summary 获取渠道分布数据(全局数据,不按区域拆分)。"""
from app.services.fdw_queries import _fdw_context
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COALESCE(SUM(cash_pay_amount), 0) AS cash_pay,
COALESCE(SUM(groupbuy_pay_amount), 0) AS groupbuy_pay,
COALESCE(SUM(cash_card_consume), 0) AS cash_card,
COALESCE(SUM(gift_card_consume), 0) AS gift_card
FROM app.v_dws_finance_daily_summary
WHERE stat_date >= %s::date AND stat_date <= %s::date
""",
(start, end),
)
ch = cur.fetchone()
cash_pay = float(ch[0]) if ch and ch[0] is not None else 0.0
groupbuy_pay = float(ch[1]) if ch and ch[1] is not None else 0.0
cash_card = float(ch[2]) if ch and ch[2] is not None else 0.0
gift_card_consume = float(ch[3]) if ch and ch[3] is not None else 0.0
return [
{"label": "储值卡结算冲销", "amount": cash_card + gift_card_consume},
{"label": "现金/线上支付", "amount": cash_pay},
{"label": "团购核销确认收入", "desc": "团购成交价", "amount": groupbuy_pay},
]
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}
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_cashflow(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"]
# consume_items 行级环比(按 label 匹配)
prev_consumes = {r["label"]: r for r in prev.get("consume_items", [])}
for item in data.get("consume_items", []):
prev_item = prev_consumes.get(item["label"], {})
cmp = calc_compare(
Decimal(str(item.get("amount", 0))),
Decimal(str(prev_item.get("amount", 0))),
)
item["compare"] = cmp["compare"]
item["down"] = cmp["is_down"]
# recharge_items 行级环比(按 label 匹配)
prev_recharges = {r["label"]: r for r in prev.get("recharge_items", [])}
for item in data.get("recharge_items", []):
prev_item = prev_recharges.get(item["label"], {})
cmp = calc_compare(
Decimal(str(item.get("amount", 0))),
Decimal(str(prev_item.get("amount", 0))),
)
item["compare"] = cmp["compare"]
except Exception:
logger.warning("cashflow 环比查询失败", exc_info=True)
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,
area: str = "all",
) -> dict:
"""助教分析板块。
CHANGE 2026-03-29 | Prompt: 助教分析按区域细化 |
area=all 走现有 salary_calc 查询area≠all 走 coach_area_hours JOIN salary_calc。
"""
try:
if area == "all":
data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end)
else:
data = fdw_queries.get_finance_coach_analysis_area(conn, site_id, start, end, area)
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:
if area == "all":
prev = fdw_queries.get_finance_coach_analysis(
conn, site_id, prev_start, prev_end
)
else:
prev = fdw_queries.get_finance_coach_analysis_area(
conn, site_id, prev_start, prev_end, area
)
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",
])
# 行级环比(按 level 匹配)
prev_rows = {r["level"]: r for r in prev_t.get("rows", [])}
for row in cur_t.get("rows", []):
prev_row = prev_rows.get(row["level"], {})
pay_cmp = calc_compare(Decimal(str(row.get("pay", 0))), Decimal(str(prev_row.get("pay", 0))))
row["pay_compare"] = pay_cmp["compare"]
row["pay_down"] = pay_cmp["is_down"]
share_cmp = calc_compare(Decimal(str(row.get("share", 0))), Decimal(str(prev_row.get("share", 0))))
row["share_compare"] = share_cmp["compare"]
row["share_down"] = share_cmp["is_down"]
hourly_cmp = calc_compare(Decimal(str(row.get("hourly", 0))), Decimal(str(prev_row.get("hourly", 0))))
row["hourly_compare"] = hourly_cmp["compare"]
row["hourly_flat"] = hourly_cmp["is_flat"]
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": [],
"discount_total": 0.0,
"confirmed_total": 0.0,
"channel_items": [],
}