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:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -15,6 +15,8 @@ import calendar
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP
from app.trace.decorators import trace_service
# ---------------------------------------------------------------------------
# 通用工具函数
@@ -34,10 +36,10 @@ def _calc_date_range(
"""
today = ref_date or date.today()
# --- 当月 ---
# --- 当月cap 到今天)---
if time_enum == "month":
start = today.replace(day=1)
end = today.replace(day=calendar.monthrange(today.year, today.month)[1])
end = today
return start, end
# --- 上月 ---
@@ -47,11 +49,11 @@ def _calc_date_range(
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
end = today
return monday, end
# --- 上周 ---
if time_enum == "lastWeek":
@@ -60,12 +62,11 @@ def _calc_date_range(
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)
q_end_month = q_start_month + 2
end = date(today.year, q_end_month, calendar.monthrange(today.year, q_end_month)[1])
end = today
return start, end
# --- 上季度 ---
@@ -106,18 +107,57 @@ def _month_offset(d: date, months: int) -> date:
return date(y, m, 1)
def _calc_prev_range(start_date: date, end_date: date) -> tuple[date, date]:
def _calc_prev_range(
time_enum: str, start_date: date, end_date: date
) -> tuple[date, date]:
"""
根据当期范围计算上期日期范围。
根据当期范围和周期类型计算上期同期日期范围。
上期长度等于当期长度prev_end = start_date - 1 天。
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: 往前推等长天数(无明确"同期"概念)
"""
period_length = (end_date - start_date).days + 1
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:
"""
统一环比计算。
@@ -178,6 +218,20 @@ _SORT_KEY_MAP = {
"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",
@@ -190,8 +244,9 @@ _SORT_DIM_MAP = {
# ---------------------------------------------------------------------------
@trace_service("获取助教看板", "Get coach board")
async def get_coach_board(
sort: str, skill: str, time: str, site_id: int
sort: str, skill: str, time: str, page: int, page_size: int, site_id: int
) -> dict:
"""
BOARD-1助教看板。扁平返回所有维度字段。
@@ -244,7 +299,17 @@ async def get_coach_board(
# 5. 任务数据
task_map = _query_coach_tasks(conn, site_id, aid_list, start_str, end_str)
# 6. 组装扁平响应
# 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"]
@@ -256,26 +321,52 @@ async def get_coach_board(
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)
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", "")),
"skills": [], # CHANGE 2026-03-20 | v_dim_assistant skill 列,暂返回空
# 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": None,
"perf_gap": None,
"perf_reached": False,
"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": None,
"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),
@@ -292,8 +383,16 @@ async def get_coach_board(
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:
@@ -318,8 +417,8 @@ def _query_coach_tasks(
cur.execute(
"""
SELECT assistant_id,
COUNT(*) FILTER (WHERE task_type = 'recall') AS recall_count,
COUNT(*) FILTER (WHERE task_type = 'callback') AS callback_count
COUNT(*) FILTER (WHERE task_type IN ('high_priority_recall', 'priority_recall')) AS recall_count,
COUNT(*) FILTER (WHERE task_type = 'relationship_building') AS callback_count
FROM biz.coach_tasks
WHERE assistant_id = ANY(%s)
AND site_id = %s
@@ -346,6 +445,106 @@ def _query_coach_tasks(
# 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",
@@ -359,6 +558,7 @@ _DIMENSION_QUERY_MAP = {
}
@trace_service("获取客户看板", "Get customer board")
async def get_customer_board(
dimension: str, project: str, page: int, page_size: int, site_id: int
) -> dict:
@@ -388,6 +588,25 @@ async def get_customer_board(
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)
@@ -396,9 +615,43 @@ async def get_customer_board(
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": items,
"items": camel_items,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
@@ -412,15 +665,26 @@ async def get_customer_board(
# ---------------------------------------------------------------------------
# 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 板块独立查询、独立降级。
area≠all 时 recharge 返回 null。
compare=1 时计算上期范围并调用 calc_compare。
compare=0 时环比字段为 None序列化时排除
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)
@@ -429,7 +693,7 @@ async def get_finance_board(
prev_start_str = None
prev_end_str = None
if compare == 1:
prev_start, prev_end = _calc_prev_range(start_date, end_date)
prev_start, prev_end = _calc_prev_range(time, start_date, end_date)
prev_start_str = str(prev_start)
prev_end_str = str(prev_end)
@@ -437,23 +701,47 @@ async def get_finance_board(
try:
# 各板块独立 try/except
overview = _build_overview(conn, site_id, start_str, end_str,
prev_start_str, prev_end_str, compare)
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)
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,
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)
prev_start_str, prev_end_str, compare, area)
return {
"overview": overview,
@@ -470,10 +758,15 @@ async def get_finance_board(
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-01 | board-finance-dws-area-refactor 9.1 |
改为从 dws_finance_area_daily 按 area_code 查询(通过 get_finance_overview_area
"""
try:
data = fdw_queries.get_finance_overview(conn, site_id, start, end)
data = fdw_queries.get_finance_overview_area(conn, site_id, start, end, area)
except Exception:
logger.warning("overview 查询失败,降级为空", exc_info=True)
return _empty_overview()
@@ -482,7 +775,7 @@ def _build_overview(
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_overview(conn, site_id, prev_start, prev_end)
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",
@@ -509,7 +802,7 @@ def _build_recharge(
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",
"consumed", "card_balance", "all_card_balance",
])
# 赠送卡矩阵环比
for i, row in enumerate(data.get("gift_rows", [])):
@@ -535,14 +828,192 @@ def _build_recharge(
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:
return fdw_queries.get_finance_revenue(conn, site_id, start, end, area)
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,
@@ -555,6 +1026,37 @@ def _build_cashflow(
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
@@ -590,10 +1092,18 @@ def _build_expense(
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:
data = fdw_queries.get_finance_coach_analysis(conn, site_id, start, end)
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": []}
@@ -601,15 +1111,33 @@ def _build_coach_analysis(
if compare == 1 and prev_start and prev_end:
try:
prev = fdw_queries.get_finance_coach_analysis(
conn, site_id, prev_start, prev_end
)
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)
@@ -658,6 +1186,7 @@ def _empty_revenue() -> dict:
"price_items": [],
"total_occurrence": 0.0,
"discount_items": [],
"discount_total": 0.0,
"confirmed_total": 0.0,
"channel_items": [],
}