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

2352 lines
87 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: R3 项目类型筛选接口重建 | get_skill_types() 从虚构的 v_cfg_skill_type
# 改为查询 app.v_cfg_area_category真实 RLS 视图),头部插入"不限"选项;
# get_all_assistants() 移除 _skill_to_category/_category_to_skill 映射字典,
# 改为 _valid_categories set 直接比较_project_filter_clause() 移除 _project_to_category
# 映射字典,直接用 category_code。
# - 2026-03-20 | Prompt: RNS1.3 FDW 列名修正 | 修正 17 处列名映射design.md 理想名 → 实际视图列名),
# gift_rows 每个 cell 改为 GiftCell dict 避免 Pydantic 校验失败,
# v_dws_member_spending_power_index 降级为空列表skill_filter 暂不生效
"""
ETL RLS 视图查询封装服务
直连 ETL 库test_etl_feiqiu查询 app.v_* RLS 视图,实现门店隔离。
⚠️ 架构说明:不使用 zqyy_app 的 fdw_etl.* foreign table而是直连 ETL 库。
原因postgres_fdw 不传递自定义 GUC 参数到远端连接,导致 RLS 视图的
current_setting('app.current_site_id') 在远端未设置而报错。
直连 ETL 库后SET LOCAL app.current_site_id 在同一连接上生效。
⚠️ DWD-DOC 强制规则在此模块统一实施:
- 规则 1: 收入使用 ledger_amount对应 items_sum 口径)
- 规则 2: 助教费用使用 base_income + bonus_income对应 pd/cx 拆分)
- DQ-6: 会员信息通过 tenant_member_id JOIN v_dim_member (scd2_is_current=1)
- DQ-7: 会员卡通过 tenant_member_id JOIN v_dim_member_card_account (scd2_is_current=1)
- 废单排除: WHERE is_delete = 0RLS 视图基于 dwd_assistant_service_log 基表,
使用 is_delete 而非 _ex 表的 is_trash
列名映射说明design.md 理想名 → 实际视图列名):
app.v_dwd_assistant_service_log:
assistant_id → site_assistant_id | member_id → tenant_member_id
is_trash → is_delete (int, 0=正常) | settle_time → create_time
service_hours → income_seconds/3600 | items_sum → ledger_amount
course_type → skill_name | table_name → site_table_id (仅 ID)
app.v_dws_assistant_salary_calc:
calc_month → salary_month (date) | coach_level → assistant_level_name
tier_index → tier_id | basic_hours → base_hours
total_hours → effective_hours | total_income → gross_salary
basic_rate → base_course_price | incentive_rate → bonus_course_price
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from decimal import Decimal
from typing import Any
logger = logging.getLogger(__name__)
def _get_etl_connection(site_id: int):
"""延迟导入 get_etl_readonly_connection避免模块级导入失败。"""
from app.database import get_etl_readonly_connection
return get_etl_readonly_connection(site_id)
@contextmanager
def _fdw_context(conn: Any, site_id: int):
"""
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id。
⚠️ 不使用 zqyy_app 的 fdw_etl.* foreign table而是直连 ETL 库
查询 app.v_* RLS 视图。原因postgres_fdw 不传递自定义 GUC 参数
到远端连接,导致 RLS 视图的 current_setting('app.current_site_id')
在远端未设置而报错。
conn 参数保留但不用于 FDW 查询(调用方可能还需要它查 biz.* 表)。
ETL 连接在 yield 后自动关闭。
"""
etl_conn = _get_etl_connection(site_id)
try:
with etl_conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
yield cur
etl_conn.commit()
finally:
etl_conn.close()
def get_member_info(
conn: Any, site_id: int, member_ids: list[int]
) -> dict[int, dict]:
"""
批量查询会员信息(昵称、手机号)。
⚠️ DQ-6: 通过 member_id 查询 app.v_dim_member取 scd2_is_current=1
禁止使用 settlement_head.member_phone/member_name。
返回 {member_id: {"nickname": str, "mobile": str}} 映射。
"""
if not member_ids:
return {}
result: dict[int, dict] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT member_id, nickname, mobile
FROM app.v_dim_member
WHERE member_id = ANY(%s) AND scd2_is_current = 1
""",
(member_ids,),
)
for row in cur.fetchall():
result[row[0]] = {"nickname": row[1], "mobile": row[2]}
return result
def get_member_balance(
conn: Any, site_id: int, member_ids: list[int]
) -> dict[int, Decimal]:
"""
批量查询会员储值卡余额。
⚠️ DQ-7: 通过 tenant_member_id 关联 app.v_dim_member_card_account
取 scd2_is_current=1禁止使用 settlement_head.member_card_type_name。
返回 {member_id: balance} 映射。
"""
if not member_ids:
return {}
result: dict[int, Decimal] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT tenant_member_id AS member_id, balance
FROM app.v_dim_member_card_account
WHERE tenant_member_id = ANY(%s) AND scd2_is_current = 1
""",
(member_ids,),
)
for row in cur.fetchall():
result[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal("0")
return result
def get_last_visit_days(
conn: Any, site_id: int, member_ids: list[int]
) -> dict[int, int | None]:
"""
批量查询客户距上次到店天数。
来源: app.v_dwd_assistant_service_log。
废单排除: is_delete = 0RLS 视图使用 is_delete 而非 is_trash
时间字段: create_time对应 design.md 中的 settle_time
会员字段: tenant_member_id对应 design.md 中的 member_id
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
"""
if not member_ids:
return {}
result: dict[int, int | None] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT tenant_member_id,
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
GROUP BY tenant_member_id
""",
(member_ids,),
)
for row in cur.fetchall():
result[row[0]] = row[1]
return result
def get_salary_calc(
conn: Any, site_id: int, assistant_id: int, year: int, month: int
) -> dict | None:
"""
查询助教绩效/档位/收入数据。
来源: app.v_dws_assistant_salary_calc。
列名映射:
salary_month (date, 存储为 YYYY-MM-01) → calc_month
assistant_level_name → coach_level
tier_id → tier_index
base_hours → basic_hours
effective_hours → total_hours
gross_salary → total_income
base_course_price → basic_rate
bonus_course_price → incentive_rate
sprint_bonus → bonus_money
base_income → assistant_pd_money_total
bonus_income → assistant_cx_money_total
不存在的字段使用默认值:
tier_nodes → [] | total_customers → 0
next_tier_* → 0 | tier_completed → False
返回包含档位、工时、收入等字段的 dict无数据时返回 None。
"""
# salary_month 是 date 类型,存储为每月 1 号
calc_month = f"{year}-{month:02d}-01"
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT salary_month,
assistant_level_name,
tier_id,
base_hours,
bonus_hours,
effective_hours,
gross_salary,
base_course_price,
bonus_course_price,
sprint_bonus,
base_income,
bonus_income,
room_hours,
room_income,
total_course_income,
total_bonus
FROM app.v_dws_assistant_salary_calc
WHERE assistant_id = %s AND salary_month = %s::date
""",
(assistant_id, calc_month),
)
row = cur.fetchone()
if not row:
return None
return {
"calc_month": str(row[0]) if row[0] else calc_month,
"coach_level": row[1] or "",
"tier_index": row[2] or 0,
# 视图无 tier_nodes由 coach_service._build_tier_nodes() 从 cfg_performance_tier 读取
"tier_nodes": [],
"basic_hours": float(row[3]) if row[3] is not None else 0.0,
"bonus_hours": float(row[4]) if row[4] is not None else 0.0,
"total_hours": float(row[5]) if row[5] is not None else 0.0,
"total_income": float(row[6]) if row[6] is not None else 0.0,
# 视图无 total_customers需要从服务记录单独统计
"total_customers": 0,
"basic_rate": float(row[7]) if row[7] is not None else 0.0,
"incentive_rate": float(row[8]) if row[8] is not None else 0.0,
# 视图无 next_tier 信息,由 coach_service 从 tier_nodes 推算
"next_tier_basic_rate": 0.0,
"next_tier_incentive_rate": 0.0,
"next_tier_hours": 0.0,
"tier_completed": False,
"bonus_money": float(row[9]) if row[9] is not None else 0.0,
"assistant_pd_money_total": float(row[10]) if row[10] is not None else 0.0,
"assistant_cx_money_total": float(row[11]) if row[11] is not None else 0.0,
# 额外字段:视图中有但 design.md 未列出,保留供后续使用
"room_hours": float(row[12]) if row[12] is not None else 0.0,
"room_income": float(row[13]) if row[13] is not None else 0.0,
"total_course_income": float(row[14]) if row[14] is not None else 0.0,
"total_bonus": float(row[15]) if row[15] is not None else 0.0,
}
def get_performance_tiers(
conn: Any, site_id: int
) -> list[dict]:
"""
查询当前有效的绩效档位配置。
来源: app.v_cfg_performance_tierRLS 视图)。
按 tier_level 升序返回,仅包含当前日期有效的档位。
⚠️ feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码。
返回 [{tier_id, tier_code, tier_name, tier_level, min_hours, max_hours}, ...]。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT tier_id, tier_code, tier_name, tier_level,
min_hours, max_hours
FROM app.v_cfg_performance_tier
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
ORDER BY tier_level
"""
)
rows = cur.fetchall()
return [
{
"tier_id": r[0],
"tier_code": r[1],
"tier_name": r[2],
"tier_level": r[3],
"min_hours": float(r[4]) if r[4] is not None else 0.0,
"max_hours": float(r[5]) if r[5] is not None else None,
}
for r in rows
]
def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
"""
从 cfg_assistant_level_price 动态读取 level_code → level_name 映射。
⚠️ feiqiu-data-rules 规则 6: 等级名称必须从配置表读取,禁止硬编码。
返回 {8: "助教管理", 10: "初级", 20: "中级", 30: "高级", 40: "星级"}。
查询失败时返回空 dict调用方应优雅降级
"""
try:
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT DISTINCT level_code, level_name
FROM app.v_cfg_assistant_level_price
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
ORDER BY level_code
"""
)
return {row[0]: row[1] for row in cur.fetchall()}
except Exception:
return {}
def get_service_records(
conn: Any,
site_id: int,
assistant_id: int,
year: int,
month: int,
limit: int,
offset: int,
) -> list[dict]:
"""
查询助教服务记录明细。
来源: app.v_dwd_assistant_service_log。
列名映射:
assistant_service_id → id
site_assistant_id → assistant_idWHERE 过滤用)
tenant_member_id → member_id
is_delete = 0 → 废单排除RLS 视图用 is_delete 而非 is_trash
create_time → settle_time
start_use_time → start_time
last_use_time → end_time
income_seconds / 3600.0 → service_hours
real_use_seconds / 3600.0 → service_hours_raw
ledger_amount → incomeitems_sum 口径)
skill_name → course_type
⚠️ DQ-6: 客户姓名通过 tenant_member_id LEFT JOIN v_dim_member (scd2_is_current=1)。
返回按 create_time DESC 排序的记录列表。
"""
start_date = f"{year}-{month:02d}-01"
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
records: list[dict] = []
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT sl.assistant_service_id,
dm.nickname AS customer_name,
sl.tenant_member_id,
sl.create_time,
sl.start_use_time,
sl.last_use_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.real_use_seconds / 3600.0 AS service_hours_raw,
sl.skill_name,
sl.site_table_id,
sl.ledger_amount AS income
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm
ON sl.tenant_member_id = dm.member_id
AND dm.scd2_is_current = 1
WHERE sl.site_assistant_id = %s AND sl.is_delete = 0
AND sl.create_time >= %s::timestamptz
AND sl.create_time < %s::timestamptz
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
""",
(assistant_id, start_date, end_date, limit, offset),
)
for row in cur.fetchall():
records.append({
"id": row[0],
"customer_name": row[1],
"member_id": row[2],
"settle_time": row[3],
"start_time": row[4],
"end_time": row[5],
"service_hours": float(row[6]) if row[6] is not None else 0.0,
"service_hours_raw": float(row[7]) if row[7] is not None else 0.0,
"course_type": row[8],
"table_name": str(row[9]) if row[9] is not None else None,
"income": float(row[10]) if row[10] is not None else 0.0,
# is_estimate 不存在于视图中,默认 False
"is_estimate": False,
})
return records
def get_service_records_for_task(
conn: Any,
site_id: int,
assistant_id: int,
member_id: int,
limit: int,
) -> list[dict]:
"""
查询特定客户的服务记录TASK-2 用)。
类似 get_service_records但按 tenant_member_id 过滤,不限月份范围。
⚠️ 废单排除: WHERE is_delete = 0。
⚠️ DQ-6: 客户姓名通过 tenant_member_id LEFT JOIN v_dim_member (scd2_is_current=1)。
返回按 create_time DESC 排序的记录列表,最多 limit 条。
"""
records: list[dict] = []
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT sl.assistant_service_id,
dm.nickname AS customer_name,
sl.tenant_member_id,
sl.create_time,
sl.start_use_time,
sl.last_use_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.real_use_seconds / 3600.0 AS service_hours_raw,
sl.skill_name,
sl.site_table_id,
sl.ledger_amount AS income
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm
ON sl.tenant_member_id = dm.member_id
AND dm.scd2_is_current = 1
WHERE sl.site_assistant_id = %s
AND sl.tenant_member_id = %s
AND sl.is_delete = 0
ORDER BY sl.create_time DESC
LIMIT %s
""",
(assistant_id, member_id, limit),
)
for row in cur.fetchall():
records.append({
"id": row[0],
"customer_name": row[1],
"member_id": row[2],
"settle_time": row[3],
"start_time": row[4],
"end_time": row[5],
"service_hours": float(row[6]) if row[6] is not None else 0.0,
"service_hours_raw": float(row[7]) if row[7] is not None else 0.0,
"course_type": row[8],
"table_name": str(row[9]) if row[9] is not None else None,
"income": float(row[10]) if row[10] is not None else 0.0,
"is_estimate": False,
})
return records
def get_consumption_60d(
conn: Any, site_id: int, member_id: int
) -> Decimal | None:
"""
查询客户近 60 天消费金额。
来源: app.v_dwd_assistant_service_log。
⚠️ DWD-DOC 规则 1: 使用 ledger_amountitems_sum 口径)。
⚠️ 废单排除: is_delete = 0。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COALESCE(SUM(ledger_amount), 0)
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
""",
(member_id,),
)
row = cur.fetchone()
return Decimal(str(row[0])) if row and row[0] is not None else None
def get_relation_index(
conn: Any, site_id: int, member_id: int
) -> list[dict]:
"""
查询客户与助教的关系指数列表。
来源: app.v_dws_member_assistant_relation_index。
返回按 relation_index 降序排列的列表。
"""
records: list[dict] = []
# CHANGE 2026-03-19 | 修正列名:实际视图列为 assistant_id/rs_display/session_count/total_duration_minutes
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT assistant_id,
rs_display AS relation_index,
session_count AS service_count,
total_duration_minutes / 60.0 AS total_hours
FROM app.v_dws_member_assistant_relation_index
WHERE member_id = %s
ORDER BY rs_display DESC
""",
(member_id,),
)
for row in cur.fetchall():
records.append({
"assistant_id": row[0],
"relation_index": float(row[1]) if row[1] is not None else 0.0,
"service_count": row[2] or 0,
"total_hours": float(row[3]) if row[3] is not None else 0.0,
"total_income": 0.0,
})
return records
# AI_CHANGELOG
# - 2026-03-20: R1 修复 — 5 列(table_charge_money, goods_money, assistant_pd_money,
# assistant_cx_money, settle_type)从 sl(service_log) 改为 sh(settlement_head)
# 添加 LEFT JOIN v_dwd_settlement_headWHERE settle_type 引用也改为 sh。
# 原因:这些字段属于结算单头表,不在助教服务日志视图中。
# 验证MCP 端到端查询通过。
def get_consumption_records(
conn: Any, site_id: int, member_id: int, limit: int, offset: int
) -> list[dict]:
"""
查询客户消费记录CUST-1 consumptionRecords 用)。
来源: app.v_dwd_assistant_service_log + v_dwd_settlement_head + v_dim_assistant。
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount来自 service_log
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money来自 settlement_head
⚠️ 费用拆分字段table_charge_money, goods_money, settle_type来自 settlement_head。
⚠️ 废单排除: is_delete = 0。
⚠️ 正向交易: settle_type IN (1, 3)。
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
"""
records: list[dict] = []
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT sl.assistant_service_id AS id,
sl.create_time AS settle_time,
sl.start_use_time AS start_time,
sl.last_use_time AS end_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.ledger_amount AS total_amount,
sl.skill_name AS course_type,
sl.site_table_id AS table_id,
sl.site_assistant_id AS assistant_id,
COALESCE(da.real_name, da.nickname, '') AS assistant_name,
da.level AS assistant_level,
sh.table_charge_money,
sh.goods_money,
sh.assistant_pd_money,
sh.assistant_cx_money,
sh.settle_type
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id
AND da.scd2_is_current = 1
LEFT JOIN app.v_dwd_settlement_head sh
ON sl.order_settle_id = sh.order_settle_id
-- CHANGE 2026-03-20 | R1 修复: 费用拆分字段来自 settlement_head 而非 service_log
-- intent: table_charge_money/goods_money/assistant_pd_money/assistant_cx_money/settle_type
-- 属于结算单头表(dwd_settlement_head),通过 order_settle_id 关联
-- assumption: 每条 service_log 对应一条 settlement_head1:1 或 1:0
-- verify: SELECT count(*) FROM v_dwd_assistant_service_log WHERE order_settle_id IS NULL
WHERE sl.tenant_member_id = %s
AND sl.is_delete = 0
AND sh.settle_type IN (1, 3)
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
""",
(member_id, limit, offset),
)
for row in cur.fetchall():
records.append({
"id": str(row[0]) if row[0] else "",
"settle_time": row[1],
"start_time": row[2],
"end_time": row[3],
"service_hours": float(row[4]) if row[4] is not None else 0.0,
"total_amount": float(row[5]) if row[5] is not None else 0.0,
"course_type": row[6] or "",
"table_id": row[7],
"assistant_id": row[8],
"assistant_name": row[9] or "",
"level": row[10] or "",
"table_charge_money": float(row[11]) if row[11] is not None else 0.0,
"goods_money": float(row[12]) if row[12] is not None else 0.0,
"assistant_pd_money": float(row[13]) if row[13] is not None else 0.0,
"assistant_cx_money": float(row[14]) if row[14] is not None else 0.0,
"settle_type": row[15],
})
return records
def get_total_service_count(
conn: Any, site_id: int, member_id: int
) -> int:
"""
查询客户累计服务总次数(跨所有月份)。
来源: app.v_dwd_assistant_service_log。
⚠️ 废单排除: is_delete = 0。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COUNT(*)
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s AND is_delete = 0
""",
(member_id,),
)
row = cur.fetchone()
return row[0] if row else 0
def get_coach_60d_stats(
conn: Any, site_id: int, assistant_id: int, member_id: int
) -> dict:
"""
查询特定助教对特定客户的近 60 天统计。
来源: app.v_dwd_assistant_service_log。
⚠️ 废单排除: is_delete = 0。
返回 {service_count, total_hours, avg_hours}。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT COUNT(*) AS service_count,
COALESCE(SUM(income_seconds / 3600.0), 0) AS total_hours,
CASE WHEN COUNT(*) > 0
THEN SUM(income_seconds / 3600.0) / COUNT(*)
ELSE 0 END AS avg_hours
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
AND tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
""",
(assistant_id, member_id),
)
row = cur.fetchone()
if not row:
return {"service_count": 0, "total_hours": 0.0, "avg_hours": 0.0}
return {
"service_count": row[0] or 0,
"total_hours": float(row[1]) if row[1] is not None else 0.0,
"avg_hours": float(row[2]) if row[2] is not None else 0.0,
}
def get_customer_service_records(
conn: Any,
site_id: int,
member_id: int,
year: int,
month: int,
table: str | None,
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""
查询客户按月服务记录CUST-2 用)。
来源: app.v_dwd_assistant_service_log + v_dim_assistant。
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
⚠️ DWD-DOC 规则 1: income 使用 ledger_amount。
⚠️ 废单排除: is_delete = 0。
返回 (records, total_count)。
"""
start_date = f"{year}-{month:02d}-01"
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
base_where = """
sl.tenant_member_id = %s
AND sl.is_delete = 0
AND sl.create_time >= %s::timestamptz
AND sl.create_time < %s::timestamptz
"""
params: list = [member_id, start_date, end_date]
if table:
base_where += " AND sl.site_table_id::text = %s"
params.append(table)
records: list[dict] = []
total_count = 0
with _fdw_context(conn, site_id) as cur:
# 总数查询
cur.execute(
f"SELECT COUNT(*) FROM app.v_dwd_assistant_service_log sl WHERE {base_where}",
params,
)
row = cur.fetchone()
total_count = row[0] if row else 0
# 分页记录查询
cur.execute(
f"""
SELECT sl.assistant_service_id AS id,
sl.create_time,
sl.start_use_time,
sl.last_use_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.real_use_seconds / 3600.0 AS service_hours_raw,
sl.ledger_amount AS income,
sl.skill_name AS course_type,
sl.site_table_id,
COALESCE(da.real_name, da.nickname, '') AS assistant_name
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id
AND da.scd2_is_current = 1
WHERE {base_where}
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
""",
params + [limit, offset],
)
for row in cur.fetchall():
records.append({
"id": str(row[0]) if row[0] else "",
"create_time": row[1],
"start_time": row[2],
"end_time": row[3],
"service_hours": float(row[4]) if row[4] is not None else 0.0,
"service_hours_raw": float(row[5]) if row[5] is not None else 0.0,
"income": float(row[6]) if row[6] is not None else 0.0,
"course_type": row[7] or "",
"table_id": row[8],
"assistant_name": row[9] or "",
})
return records, total_count
def get_assistant_info(
conn: Any, site_id: int, assistant_id: int
) -> dict | None:
"""
查询助教基本信息。
来源: app.v_dim_assistant。
返回 {name, avatar, level, skills, hire_date} 或 None。
"""
# CHANGE 2026-03-19 | 修正列名v_dim_assistant 实际列为 real_name/nickname/level(int)/entry_time
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT assistant_id,
COALESCE(real_name, nickname, '') AS name,
level,
entry_time AS hire_date
FROM app.v_dim_assistant
WHERE assistant_id = %s AND scd2_is_current = 1
""",
(assistant_id,),
)
row = cur.fetchone()
if not row:
return None
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6: 等级名称从配置表动态读取
level_map = get_level_map(conn, site_id)
return {
"id": row[0],
"name": row[1] or "",
"level": level_map.get(row[2], "") if row[2] else "",
"hire_date": str(row[3]) if row[3] else None,
# 视图中无 avatar/skills/work_years使用默认值
"avatar": "",
"skills": [],
"work_years": 0.0,
}
def get_salary_calc_multi_months(
conn: Any, site_id: int, assistant_id: int, months: list[str]
) -> dict[str, dict]:
"""
批量查询多个月份的绩效数据。
来源: app.v_dws_assistant_salary_calc。
months 格式: ["2026-03-01", "2026-02-01", ...]
返回 {month_str: {effective_hours, gross_salary, base_income, bonus_income}}。
"""
if not months:
return {}
result: dict[str, dict] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT salary_month,
effective_hours,
gross_salary,
base_income,
bonus_income
FROM app.v_dws_assistant_salary_calc
WHERE assistant_id = %s
AND salary_month = ANY(%s::date[])
ORDER BY salary_month DESC
""",
(assistant_id, months),
)
for row in cur.fetchall():
month_key = str(row[0])
result[month_key] = {
"effective_hours": float(row[1]) if row[1] is not None else 0.0,
"gross_salary": float(row[2]) if row[2] is not None else 0.0,
"base_income": float(row[3]) if row[3] is not None else 0.0,
"bonus_income": float(row[4]) if row[4] is not None else 0.0,
}
return result
def get_monthly_customer_count(
conn: Any, site_id: int, assistant_id: int, months: list[str]
) -> dict[str, int]:
"""
批量查询各月不重复客户数。
来源: app.v_dwd_assistant_service_log。
COUNT(DISTINCT tenant_member_id),过滤 is_delete = 0。
months 格式: ["2026-03-01", "2026-02-01", ...]
返回 {month_str: customer_count}。
"""
if not months:
return {}
# 计算整体时间范围
sorted_months = sorted(months)
start_date = sorted_months[0]
# 最后一个月的下个月
last = sorted_months[-1]
last_parts = last.split("-")
y, m = int(last_parts[0]), int(last_parts[1])
if m == 12:
end_date = f"{y + 1}-01-01"
else:
end_date = f"{y}-{m + 1:02d}-01"
result: dict[str, int] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT DATE_TRUNC('month', create_time)::date AS month,
COUNT(DISTINCT tenant_member_id) AS customer_count
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
AND is_delete = 0
AND create_time >= %s::timestamptz
AND create_time < %s::timestamptz
GROUP BY DATE_TRUNC('month', create_time)::date
""",
(assistant_id, start_date, end_date),
)
for row in cur.fetchall():
result[str(row[0])] = row[1]
return result
def get_coach_top_customers(
conn: Any, site_id: int, assistant_id: int, limit: int = 20
) -> list[dict]:
"""
查询助教 TOP 客户(按服务次数降序)。
来源: app.v_dwd_assistant_service_log + v_dim_member + v_dim_member_card_account。
⚠️ DQ-6: 客户姓名通过 tenant_member_id JOIN v_dim_member。
⚠️ DQ-7: 余额通过 tenant_member_id JOIN v_dim_member_card_account。
⚠️ DWD-DOC 规则 1: consume 使用 ledger_amountitems_sum 口径)。
⚠️ 废单排除: is_delete = 0。
"""
records: list[dict] = []
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT sl.tenant_member_id AS member_id,
dm.nickname AS customer_name,
COUNT(*) AS service_count,
COALESCE(SUM(sl.ledger_amount), 0) AS total_consume,
mca.balance AS customer_balance
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm
ON sl.tenant_member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dim_member_card_account mca
ON sl.tenant_member_id = mca.tenant_member_id AND mca.scd2_is_current = 1
WHERE sl.site_assistant_id = %s
AND sl.is_delete = 0
GROUP BY sl.tenant_member_id, dm.nickname, mca.balance
ORDER BY service_count DESC
LIMIT %s
""",
(assistant_id, limit),
)
for row in cur.fetchall():
records.append({
"member_id": row[0],
"customer_name": row[1] or "",
"service_count": row[2] or 0,
"total_consume": float(row[3]) if row[3] is not None else 0.0,
"customer_balance": float(row[4]) if row[4] is not None else 0.0,
})
return records
def get_coach_service_records(
conn: Any,
site_id: int,
assistant_id: int,
limit: int = 20,
offset: int = 0,
) -> list[dict]:
"""
查询助教近期服务记录COACH-1 serviceRecords 用)。
来源: app.v_dwd_assistant_service_log + v_dim_member。
⚠️ DQ-6: 客户姓名通过 tenant_member_id JOIN v_dim_member。
⚠️ DWD-DOC 规则 1: income 使用 ledger_amount。
⚠️ 废单排除: is_delete = 0。
"""
records: list[dict] = []
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT sl.assistant_service_id AS id,
sl.tenant_member_id AS member_id,
dm.nickname AS customer_name,
sl.create_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.ledger_amount AS income,
sl.skill_name AS course_type,
sl.site_table_id AS table_id
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm
ON sl.tenant_member_id = dm.member_id
AND dm.scd2_is_current = 1
WHERE sl.site_assistant_id = %s
AND sl.is_delete = 0
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
""",
(assistant_id, limit, offset),
)
for row in cur.fetchall():
records.append({
"id": row[0],
"member_id": row[1],
"customer_name": row[2] or "",
"create_time": row[3],
"service_hours": float(row[4]) if row[4] is not None else 0.0,
"income": float(row[5]) if row[5] is not None else 0.0,
"course_type": row[6] or "",
"table_id": row[7],
})
return records
# ---------------------------------------------------------------------------
# BOARD-1 助教看板 FDW 查询
# ---------------------------------------------------------------------------
def get_all_assistants(
conn: Any, site_id: int, skill_filter: str = "ALL"
) -> list[dict]:
"""
查询门店全部助教列表BOARD-1 用)。
CHANGE 2026-03-20 | R3 修复skill_filter 直接接收 category_code
BILLIARD/SNOOKER/MAHJONG/KTV/ALL去掉 chinese→BILLIARD 映射层。
"""
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6: 等级名称从配置表动态读取
# CHANGE 2026-03-20 | R3 修复:去掉 _skill_to_category 映射,直接用 category_code
_valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
level_map = get_level_map(conn, site_id)
records: list[dict] = []
with _fdw_context(conn, site_id) as cur:
# 筛选条件:如果指定了技能,只返回被标记的助教
filter_clause = ""
params: tuple = ()
if skill_filter != "ALL" and skill_filter in _valid_categories:
filter_clause = """
AND da.assistant_id IN (
SELECT apt.assistant_id
FROM app.v_dws_assistant_project_tag apt
WHERE apt.category_code = %s AND apt.is_tagged = true
)
"""
params = (skill_filter,)
cur.execute(
f"""
SELECT da.assistant_id,
COALESCE(da.real_name, da.nickname, '') AS name,
da.level,
array_agg(DISTINCT apt.category_code) FILTER (WHERE apt.is_tagged = true) AS skills
FROM app.v_dim_assistant da
LEFT JOIN app.v_dws_assistant_project_tag apt
ON da.assistant_id = apt.assistant_id
WHERE da.scd2_is_current = 1
{filter_clause}
GROUP BY da.assistant_id, da.real_name, da.nickname, da.level
ORDER BY da.assistant_id
""",
params,
)
for row in cur.fetchall():
skill_codes = row[3] if row[3] else []
# CHANGE 2026-03-20 | R3 修复:直接返回 category_code不再反向映射为旧值
records.append({
"assistant_id": row[0],
"name": row[1] or "",
"skill": ",".join(c for c in skill_codes if c) if skill_codes else "",
"level": level_map.get(row[2], "") if row[2] else "",
})
return records
def get_salary_calc_batch(
conn: Any,
site_id: int,
assistant_ids: list[int],
start_date: str,
end_date: str,
) -> dict[int, dict]:
"""
批量查询助教绩效数据BOARD-1 perf/salary 维度用)。
来源: app.v_dws_assistant_salary_calc。
按 assistant_id 分组,对日期范围内的月份数据聚合。
⚠️ DWD-DOC 规则 1: 收入使用 items_sum 口径gross_salary 对应 items_sum
⚠️ DWD-DOC 规则 2: 费用使用 base_income (assistant_pd_money) + bonus_income (assistant_cx_money)
返回 {assistant_id: {effective_hours, gross_salary, base_income, bonus_income}}。
"""
if not assistant_ids:
return {}
result: dict[int, dict] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT assistant_id,
SUM(effective_hours) AS effective_hours,
SUM(gross_salary) AS gross_salary,
SUM(base_income) AS base_income,
SUM(bonus_income) AS bonus_income,
MAX(assistant_level_name) AS level_name
FROM app.v_dws_assistant_salary_calc
WHERE assistant_id = ANY(%s)
AND salary_month >= %s::date
AND salary_month <= %s::date
GROUP BY assistant_id
""",
(assistant_ids, start_date, end_date),
)
for row in cur.fetchall():
result[row[0]] = {
"effective_hours": float(row[1]) if row[1] is not None else 0.0,
"gross_salary": float(row[2]) if row[2] is not None else 0.0,
"base_income": float(row[3]) if row[3] is not None else 0.0,
"bonus_income": float(row[4]) if row[4] is not None else 0.0,
"level_name": row[5] or "",
}
return result
def get_top_customers_for_coaches(
conn: Any, site_id: int, assistant_ids: list[int]
) -> dict[int, list[str]]:
"""
批量查询助教 Top 3 客户按亲密度降序BOARD-1 用)。
来源: app.v_dws_member_assistant_relation_index + app.v_dim_member。
⚠️ DQ-6: 客户姓名通过 member_id JOIN v_dim_member取 scd2_is_current=1。
⚠️ 亲密度 emoji 四级映射: > 8.5 → 💖, > 7 → 🧡, > 5 → 💛, ≤ 5 → 💙。
返回 {assistant_id: ["💖 王先生", "💛 李女士", ...]},每个助教最多 3 个。
"""
if not assistant_ids:
return {}
result: dict[int, list[str]] = {aid: [] for aid in assistant_ids}
with _fdw_context(conn, site_id) as cur:
# 使用窗口函数取每个助教的 Top 3
cur.execute(
"""
WITH ranked AS (
SELECT ri.assistant_id,
ri.rs_display,
dm.nickname,
ROW_NUMBER() OVER (
PARTITION BY ri.assistant_id
ORDER BY ri.rs_display DESC
) AS rn
FROM app.v_dws_member_assistant_relation_index ri
LEFT JOIN app.v_dim_member dm
ON ri.member_id = dm.member_id
AND dm.scd2_is_current = 1
WHERE ri.assistant_id = ANY(%s)
)
SELECT assistant_id, rs_display, nickname
FROM ranked
WHERE rn <= 3
ORDER BY assistant_id, rn
""",
(assistant_ids,),
)
for row in cur.fetchall():
aid = row[0]
rs = float(row[1]) if row[1] is not None else 0.0
name = row[2] or "未知"
emoji = _rs_emoji(rs)
result[aid].append(f"{emoji} {name}")
return result
def _rs_emoji(rs_display: float) -> str:
"""亲密度 emoji 四级映射。"""
if rs_display > 8.5:
return "💖"
if rs_display > 7:
return "🧡"
if rs_display > 5:
return "💛"
return "💙"
def get_coach_sv_data(
conn: Any,
site_id: int,
assistant_ids: list[int],
start_date: str,
end_date: str,
) -> dict[int, dict]:
"""
批量查询助教储值维度数据BOARD-1 sv 维度用)。
CHANGE 2026-03-19 | P0 修复:原查询使用 v_dws_assistant_monthly_summary 的
total_ledger_amount总消费额冒充储值金额口径完全错误。
改为使用 v_dws_assistant_recharge_commission助教储值提成明细表
正确取 recharge_amount储值金额和 commission_amount提成金额
返回 {assistant_id: {sv_amount, sv_customer_count, sv_consume}}。
"""
if not assistant_ids:
return {}
result: dict[int, dict] = {}
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT assistant_id,
COALESCE(SUM(recharge_amount), 0) AS sv_amount,
COUNT(DISTINCT recharge_order_id) AS sv_customer_count,
COALESCE(SUM(commission_amount), 0) AS sv_consume
FROM app.v_dws_assistant_recharge_commission
WHERE assistant_id = ANY(%s)
AND commission_month >= %s::date
AND commission_month <= %s::date
GROUP BY assistant_id
""",
(assistant_ids, start_date, end_date),
)
for row in cur.fetchall():
result[row[0]] = {
"sv_amount": float(row[1]),
"sv_customer_count": int(row[2]),
"sv_consume": float(row[3]),
}
return result
# ---------------------------------------------------------------------------
# BOARD-2 客户看板 FDW 查询8 维度 + 批量助教查询)
# ---------------------------------------------------------------------------
def _project_filter_clause(project: str) -> tuple[str, tuple]:
"""
生成项目筛选 SQL 片段(用于 BOARD-2 会员维度查询)。
CHANGE 2026-03-20 | R3 修复project 参数直接接收 category_code
BILLIARD/SNOOKER/MAHJONG/KTV/ALL去掉 chinese→BILLIARD 映射层。
返回 (sql_fragment, params)sql_fragment 以 AND 开头,可直接拼入 WHERE 子句。
"""
_valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
if project == "ALL" or project not in _valid_categories:
return "", ()
clause = """
AND vd.member_id IN (
SELECT mpt.member_id
FROM app.v_dws_member_project_tag mpt
WHERE mpt.category_code = %s AND mpt.is_tagged = true
)
"""
return clause, (project,)
def get_customer_board_recall(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 recall 维度:召回指数排行。
来源: app.v_dws_member_winback_index + app.v_dim_member。
CHANGE 2026-03-20 | 修正列名映射:
ideal_days → ideal_interval_days, wbi_score → display_score,
elapsed_days → CURRENT_DATE - last_visit_time::date (计算列),
overdue_days → elapsed_days - ideal_interval_days (计算列),
visits_30d 不存在(有 visits_14d/visits_60d用 visits_14d 近似,
balance_amount → balance (v_dim_member_card_account)
⚠️ DQ-6: 客户姓名通过 member_id JOIN v_dim_member。
⚠️ DQ-7: 余额通过 JOIN v_dim_member_card_account。
按 display_score 降序LIMIT/OFFSET 分页。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
# 总数
cur.execute(
f"""
SELECT COUNT(*)
FROM app.v_dws_member_winback_index wi
WHERE 1=1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
# 分页数据
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT wi.member_id,
dm.nickname,
wi.ideal_interval_days,
CURRENT_DATE - wi.last_visit_time::date AS elapsed_days,
(CURRENT_DATE - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
wi.visits_14d,
wi.display_score,
COALESCE(ca.balance, 0) AS balance
FROM app.v_dws_member_winback_index wi
LEFT JOIN app.v_dim_member dm
ON wi.member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dim_member_card_account ca
ON wi.member_id = ca.tenant_member_id AND ca.scd2_is_current = 1
WHERE 1=1 {proj_clause}
ORDER BY wi.display_score DESC
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
items.append({
"member_id": row[0],
"name": row[1] or "",
"ideal_days": row[2] or 0,
"elapsed_days": row[3] or 0,
"overdue_days": row[4] or 0,
"visits_30d": row[5] or 0, # 实际为 visits_14d 近似
"recall_index": float(row[6]) if row[6] is not None else 0.0,
"balance": float(row[7]) if row[7] is not None else 0.0,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def _derive_potential_tags(
level_score: float | None,
speed_score: float | None,
stability_score: float | None,
) -> list[str]:
"""从三维分数派生潜力标签display_score 均为 0-100 区间)。"""
tags = []
threshold = 60.0
if level_score is not None and float(level_score) >= threshold:
tags.append("high_level")
if speed_score is not None and float(speed_score) >= threshold:
tags.append("fast_growth")
if stability_score is not None and float(stability_score) >= threshold:
tags.append("stable")
return tags
def get_customer_board_potential(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 potential 维度:消费潜力指数排行。
CHANGE 2026-03-19 | P0 修复v_dws_member_spending_power_index 已通过 FDW 可用,
替换空列表降级为实际查询。按 display_score 降序排列。
会员信息通过 member_id JOIN v_dim_memberDQ-6 规则)。
项目筛选通过 v_dws_member_project_tag 子查询。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
# 计数(带项目筛选)
cur.execute(
f"""
SELECT COUNT(*)
FROM app.v_dws_member_spending_power_index spi
WHERE 1=1
{f"AND spi.member_id IN (SELECT member_id FROM app.v_dws_member_project_tag WHERE category_code = %s AND is_tagged = true)" if proj_params else ""}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT spi.member_id,
dm.nickname,
spi.spend_30,
spi.visit_days_30,
spi.avg_ticket_90,
spi.display_score,
spi.score_level_display,
spi.score_speed_display,
spi.score_stability_display
FROM app.v_dws_member_spending_power_index spi
LEFT JOIN app.v_dim_member dm
ON spi.member_id = dm.member_id AND dm.scd2_is_current = 1
WHERE 1=1
{f"AND spi.member_id IN (SELECT member_id FROM app.v_dws_member_project_tag WHERE category_code = %s AND is_tagged = true)" if proj_params else ""}
ORDER BY spi.display_score DESC NULLS LAST
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
items.append({
"member_id": row[0],
"name": row[1] or "",
"spend_30d": float(row[2]) if row[2] is not None else 0.0,
"avg_visits": int(row[3]) if row[3] is not None else 0,
"avg_spend": float(row[4]) if row[4] is not None else 0.0,
"potential_score": float(row[5]) if row[5] is not None else 0.0,
"potential_tags": _derive_potential_tags(row[6], row[7], row[8]),
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def get_customer_board_balance(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 balance 维度:余额排行。
来源: app.v_dim_member_card_account + app.v_dim_member。
CHANGE 2026-03-20 | 修正列名balance_amount → balance,
last_visit_date/monthly_consume 从 v_dws_member_consumption_summary 获取
(实际列为 days_since_last, consume_amount_60d
⚠️ DQ-7: 余额通过 tenant_member_id JOIN取 scd2_is_current=1。
按 balance 降序。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
cur.execute(
f"""
SELECT COUNT(*)
FROM app.v_dim_member_card_account ca
WHERE ca.scd2_is_current = 1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT ca.tenant_member_id AS member_id,
dm.nickname,
ca.balance,
vd.days_since_last,
vd.consume_amount_60d
FROM app.v_dim_member_card_account ca
LEFT JOIN app.v_dim_member dm
ON ca.tenant_member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dws_member_consumption_summary vd
ON ca.tenant_member_id = vd.member_id
WHERE ca.scd2_is_current = 1 {proj_clause}
ORDER BY ca.balance DESC
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
items.append({
"member_id": row[0],
"name": row[1] or "",
"balance": float(row[2]) if row[2] is not None else 0.0,
"last_visit_date": row[3], # days_since_last (int)
"monthly_consume": float(row[4]) if row[4] is not None else 0.0,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def get_customer_board_recharge(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 recharge 维度:充值记录排行。
来源: app.v_dwd_recharge_order + app.v_dim_member_card_account。
CHANGE 2026-03-20 | 修正列名:
recharge_date → pay_time, recharge_amount → pay_amount,
balance_amount → balance (v_dim_member_card_account)
按 last_recharge_date (MAX(pay_time::date)) 降序。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
cur.execute(
f"""
SELECT COUNT(DISTINCT ro.member_id)
FROM app.v_dwd_recharge_order ro
WHERE 1=1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT ro.member_id,
dm.nickname,
MAX(ro.pay_time::date) AS last_recharge_date,
SUM(ro.pay_amount) AS recharge_amount,
COUNT(*) FILTER (
WHERE ro.pay_time >= CURRENT_DATE - INTERVAL '60 days'
) AS recharges_60d,
COALESCE(ca.balance, 0) AS current_balance
FROM app.v_dwd_recharge_order ro
LEFT JOIN app.v_dim_member dm
ON ro.member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dim_member_card_account ca
ON ro.member_id = ca.tenant_member_id AND ca.scd2_is_current = 1
WHERE 1=1 {proj_clause}
GROUP BY ro.member_id, dm.nickname, ca.balance
ORDER BY MAX(ro.pay_time::date) DESC
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
items.append({
"member_id": row[0],
"name": row[1] or "",
"last_recharge_date": row[2],
"recharge_amount": float(row[3]) if row[3] is not None else 0.0,
"recharges_60d": row[4] or 0,
"current_balance": float(row[5]) if row[5] is not None else 0.0,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def get_customer_board_recent(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 recent 维度:最近到店排行。
CHANGE 2026-03-19 | P2 修复ideal_days 从 v_dws_member_winback_index.ideal_interval_days 获取,
不再硬编码为 0。来源: v_dws_member_visit_detail + v_dim_member + v_dws_member_winback_index。
按 last_visit_date 降序。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
cur.execute(
f"""
SELECT COUNT(DISTINCT vd.member_id)
FROM app.v_dws_member_visit_detail vd
WHERE 1=1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
WITH member_agg AS (
SELECT vd.member_id,
MAX(vd.visit_date) AS last_visit_date,
COUNT(*) AS total_visits,
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '30 days') AS visits_30d,
AVG(vd.total_consume) AS avg_spend
FROM app.v_dws_member_visit_detail vd
WHERE 1=1 {proj_clause}
GROUP BY vd.member_id
)
SELECT ma.member_id,
dm.nickname,
ma.last_visit_date,
ma.total_visits,
COALESCE(wi.ideal_interval_days, 0) AS ideal_days,
ma.visits_30d,
ma.avg_spend
FROM member_agg ma
LEFT JOIN app.v_dim_member dm
ON ma.member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dws_member_winback_index wi
ON ma.member_id = wi.member_id
ORDER BY ma.last_visit_date DESC
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
items.append({
"member_id": row[0],
"name": row[1] or "",
"last_visit_date": row[2],
"visit_freq": float(row[3]) if row[3] is not None else 0.0,
"ideal_days": float(row[4]) if row[4] is not None else 0.0,
"visits_30d": row[5] or 0,
"avg_spend": float(row[6]) if row[6] is not None else 0.0,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def get_customer_board_spend60(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 spend60 维度60 天消费额排行。
来源: app.v_dws_member_consumption_summary。
CHANGE 2026-03-20 | 修正列名items_sum_60d → consume_amount_60d,
high_spend_tag/avg_spend 不存在,用 avg_ticket_amount 替代 avg_spend
high_spend_tag 通过阈值计算。
按 consume_amount_60d 降序。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
cur.execute(
f"""
SELECT COUNT(*)
FROM app.v_dws_member_consumption_summary cs
WHERE 1=1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT cs.member_id,
dm.nickname,
cs.consume_amount_60d,
cs.visit_count_60d,
cs.avg_ticket_amount
FROM app.v_dws_member_consumption_summary cs
LEFT JOIN app.v_dim_member dm
ON cs.member_id = dm.member_id AND dm.scd2_is_current = 1
WHERE 1=1 {proj_clause}
ORDER BY cs.consume_amount_60d DESC
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
spend = float(row[2]) if row[2] is not None else 0.0
items.append({
"member_id": row[0],
"name": row[1] or "",
"spend_60d": spend,
"visits_60d": row[3] or 0,
"high_spend_tag": spend > 5000, # 视图无此列,按阈值计算
"avg_spend": float(row[4]) if row[4] is not None else 0.0,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def get_customer_board_freq60(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 freq60 维度60 天到店频次排行。
来源: app.v_dws_member_consumption_summary汇总+ app.v_dwd_assistant_service_log周粒度
CHANGE 2026-03-20 | 修正列名items_sum_60d → consume_amount_60d,
avg_interval_days 不存在,用 60/visit_count_60d 近似计算。
按 visit_count_60d 降序。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
cur.execute(
f"""
SELECT COUNT(*)
FROM app.v_dws_member_consumption_summary cs
WHERE 1=1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT cs.member_id,
dm.nickname,
cs.visit_count_60d,
cs.consume_amount_60d
FROM app.v_dws_member_consumption_summary cs
LEFT JOIN app.v_dim_member dm
ON cs.member_id = dm.member_id AND dm.scd2_is_current = 1
WHERE 1=1 {proj_clause}
ORDER BY cs.visit_count_60d DESC
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
)
items = []
member_ids = []
for row in cur.fetchall():
mid = row[0]
member_ids.append(mid)
visits = row[2] or 0
# avg_interval_days 不存在于视图,近似计算
avg_interval = round(60.0 / visits, 1) if visits > 0 else 0.0
items.append({
"member_id": mid,
"name": row[1] or "",
"visits_60d": visits,
"avg_interval_days": avg_interval,
"spend_60d": float(row[3]) if row[3] is not None else 0.0,
"weekly_visits": [], # 后续填充
})
# 批量查询 8 周到店数据
if member_ids:
weekly_map = _get_weekly_visits_batch(cur, member_ids)
for item in items:
item["weekly_visits"] = weekly_map.get(item["member_id"], _empty_weekly())
return {"items": items, "total": total, "page": page, "page_size": page_size}
def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[dict]]:
"""
批量查询客户最近 8 周的到店次数(用于 freq60 维度柱状图)。
来源: app.v_dwd_assistant_service_log按 ISO 周分组。
返回 {member_id: [{val: int, pct: int}, ...]},固定 8 个元素。
"""
cur.execute(
"""
WITH weekly AS (
SELECT tenant_member_id AS member_id,
DATE_TRUNC('week', create_time::date) AS week_start,
COUNT(*) AS cnt
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = ANY(%s)
AND is_delete = 0
AND create_time >= CURRENT_DATE - INTERVAL '56 days'
GROUP BY tenant_member_id, DATE_TRUNC('week', create_time::date)
)
SELECT member_id, week_start, cnt
FROM weekly
ORDER BY member_id, week_start
""",
(member_ids,),
)
from collections import defaultdict
raw: dict[int, dict[str, int]] = defaultdict(dict)
for row in cur.fetchall():
raw[row[0]][str(row[1])] = row[2]
# 生成最近 8 周的周一日期
from datetime import date, timedelta
today = date.today()
this_monday = today - timedelta(days=today.weekday())
weeks = [this_monday - timedelta(weeks=i) for i in range(7, -1, -1)]
result: dict[int, list[dict]] = {}
for mid in member_ids:
vals = [raw.get(mid, {}).get(str(w), 0) for w in weeks]
max_val = max(vals) if vals else 0
weekly = []
for v in vals:
pct = round(v / max_val * 100) if max_val > 0 else 0
weekly.append({"val": v, "pct": pct})
result[mid] = weekly
return result
def _empty_weekly() -> list[dict]:
"""返回 8 个空周数据。"""
return [{"val": 0, "pct": 0}] * 8
def get_customer_board_loyal(
conn: Any, site_id: int, project: str, page: int, page_size: int
) -> dict:
"""
BOARD-2 loyal 维度:忠诚度排行。
来源: app.v_dws_member_assistant_relation_index。
按 max_rs最高亲密度降序。
"""
proj_clause, proj_params = _project_filter_clause(project)
with _fdw_context(conn, site_id) as cur:
cur.execute(
f"""
SELECT COUNT(DISTINCT ri.member_id)
FROM app.v_dws_member_assistant_relation_index ri
WHERE 1=1 {proj_clause}
""",
proj_params,
)
total = cur.fetchone()[0]
offset = (page - 1) * page_size
cur.execute(
f"""
WITH member_top AS (
SELECT ri.member_id,
MAX(ri.rs_display) AS max_rs,
(ARRAY_AGG(ri.assistant_id ORDER BY ri.rs_display DESC))[1] AS top_assistant_id,
(ARRAY_AGG(ri.rs_display ORDER BY ri.rs_display DESC))[1] AS top_rs
FROM app.v_dws_member_assistant_relation_index ri
WHERE 1=1 {proj_clause}
GROUP BY ri.member_id
ORDER BY MAX(ri.rs_display) DESC
LIMIT %s OFFSET %s
)
SELECT mt.member_id,
dm.nickname,
mt.max_rs,
mt.top_assistant_id,
mt.top_rs,
COALESCE(da.real_name, da.nickname, '') AS top_coach_name
FROM member_top mt
LEFT JOIN app.v_dim_member dm
ON mt.member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dim_assistant da
ON mt.top_assistant_id = da.assistant_id AND da.scd2_is_current = 1
ORDER BY mt.max_rs DESC
""",
(*proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
items.append({
"member_id": row[0],
"name": row[1] or "",
"intimacy": float(row[2]) if row[2] is not None else 0.0,
"top_assistant_id": row[3],
"top_coach_name": row[5] or "",
"top_coach_heart": float(row[4]) if row[4] is not None else 0.0,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
def get_customer_assistants(
conn: Any, site_id: int, member_ids: list[int]
) -> dict[int, list[dict]]:
"""
批量查询客户关联助教列表BOARD-2 所有维度共用)。
来源: app.v_dws_member_assistant_relation_index + app.v_dim_assistant。
含亲密度计算,按 rs_display 降序。
返回 {member_id: [{name, cls, heart_score, badge, badge_cls}, ...]}。
"""
if not member_ids:
return {}
result: dict[int, list[dict]] = {mid: [] for mid in member_ids}
with _fdw_context(conn, site_id) as cur:
# CHANGE 2026-03-20 | v_dim_assistant 无 assistant_name/skill_name
# 改用 COALESCE(real_name, nickname)cls 暂返回空
cur.execute(
"""
SELECT ri.member_id,
COALESCE(da.real_name, da.nickname, '') AS assistant_name,
ri.rs_display
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
WHERE ri.member_id = ANY(%s)
ORDER BY ri.member_id, ri.rs_display DESC
""",
(member_ids,),
)
for row in cur.fetchall():
mid = row[0]
if mid in result:
result[mid].append({
"name": row[1] or "",
"cls": "", # v_dim_assistant 无 skill_name 列
"heart_score": float(row[2]) if row[2] is not None else 0.0,
})
return result
# ---------------------------------------------------------------------------
# BOARD-3 财务看板 FDW 查询6 板块)
# ---------------------------------------------------------------------------
def get_finance_overview(
conn: Any, site_id: int, start_date: str, end_date: str
) -> dict:
"""
BOARD-3 经营一览8 项核心指标(从财务日报聚合)。
来源: app.v_dws_finance_daily_summary。
CHANGE 2026-03-20 | 修正列名映射:
occurrence → gross_amount, discount → discount_total,
confirmed_revenue → confirmed_income,
cash_in → cash_inflow_total, cash_out → cash_outflow_total,
cash_balance → cash_balance_change
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT SUM(gross_amount) AS occurrence,
SUM(discount_total) AS discount,
CASE WHEN SUM(gross_amount) > 0
THEN SUM(discount_total) / SUM(gross_amount)
ELSE 0 END AS discount_rate,
SUM(confirmed_income) AS confirmed_revenue,
SUM(cash_inflow_total) AS cash_in,
SUM(cash_outflow_total) AS cash_out,
SUM(cash_balance_change) AS cash_balance,
CASE WHEN SUM(cash_inflow_total) > 0
THEN SUM(cash_balance_change) / SUM(cash_inflow_total)
ELSE 0 END AS balance_rate
FROM app.v_dws_finance_daily_summary
WHERE stat_date >= %s::date AND stat_date <= %s::date
""",
(start_date, end_date),
)
row = cur.fetchone()
if not row or row[0] is None:
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,
}
return {
"occurrence": float(row[0]) if row[0] is not None else 0.0,
"discount": float(row[1]) if row[1] is not None else 0.0,
"discount_rate": float(row[2]) if row[2] is not None else 0.0,
"confirmed_revenue": float(row[3]) if row[3] is not None else 0.0,
"cash_in": float(row[4]) if row[4] is not None else 0.0,
"cash_out": float(row[5]) if row[5] is not None else 0.0,
"cash_balance": float(row[6]) if row[6] is not None else 0.0,
"balance_rate": float(row[7]) if row[7] is not None else 0.0,
}
def get_finance_recharge(
conn: Any, site_id: int, start_date: str, end_date: str
) -> dict:
"""
BOARD-3 预收资产:储值卡 5 指标 + 赠送卡 3×4 矩阵。
来源: app.v_dws_finance_recharge_summary。
CHANGE 2026-03-20 | 修正列名映射:
actual_income → recharge_cash, first_charge → first_recharge_cash,
renew_charge → renewal_cash, consumed → 不存在(暂返回 0,
card_balance → cash_card_balance, all_card_balance → total_card_balance。
CHANGE 2026-07-22 | Prompt: gift-card-breakdown Task 7.1 | 直接原因: SQL 新增 6 个赠送卡细分字段 SUM 聚合
新增: gift_liquor_balance, gift_table_fee_balance, gift_voucher_balance,
gift_liquor_recharge, gift_table_fee_recharge, gift_voucher_recharge
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT SUM(recharge_cash) AS actual_income,
SUM(first_recharge_cash) AS first_charge,
SUM(renewal_cash) AS renew_charge,
SUM(cash_card_balance) AS card_balance,
SUM(total_card_balance) AS all_card_balance,
SUM(gift_card_balance) AS gift_balance_total,
SUM(gift_liquor_balance) AS gift_liquor_balance,
SUM(gift_table_fee_balance) AS gift_table_fee_balance,
SUM(gift_voucher_balance) AS gift_voucher_balance,
SUM(gift_liquor_recharge) AS gift_liquor_recharge,
SUM(gift_table_fee_recharge) AS gift_table_fee_recharge,
SUM(gift_voucher_recharge) AS gift_voucher_recharge
FROM app.v_dws_finance_recharge_summary
WHERE stat_date >= %s::date AND stat_date <= %s::date
""",
(start_date, end_date),
)
row = cur.fetchone()
def _f(v):
return float(v) if v is not None else 0.0
if not row or row[0] is None:
return _empty_recharge_data()
gift_balance = _f(row[5])
# CHANGE 2026-07-22 | Prompt: gift-card-breakdown Task 7.2 | 直接原因: gift_rows 填充真实细分数据
gift_liquor_balance = _f(row[6])
gift_table_fee_balance = _f(row[7])
gift_voucher_balance = _f(row[8])
gift_liquor_recharge = _f(row[9])
gift_table_fee_recharge = _f(row[10])
gift_voucher_recharge = _f(row[11])
# CHANGE 2026-03-20 | gift_rows 每个 cell 必须是 GiftCell dict{"value": float}
# 不能是裸 float否则 Pydantic ResponseValidationError
_gc = lambda v: {"value": v}
return {
"actual_income": _f(row[0]),
"first_charge": _f(row[1]),
"renew_charge": _f(row[2]),
"consumed": 0.0, # 视图无消耗列,暂返回 0
"card_balance": _f(row[3]),
"gift_rows": [
# 新增行total = 三个细分之和(保证 total = liquor + table_fee + voucher 恒等)
{"label": "新增",
"total": _gc(gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge),
"liquor": _gc(gift_liquor_recharge),
"table_fee": _gc(gift_table_fee_recharge),
"voucher": _gc(gift_voucher_recharge)},
# 消费行:上游 API 仅提供消费总额,无法按卡类型拆分,细分列保持 0
{"label": "消费",
"total": _gc(0.0),
"liquor": _gc(0.0),
"table_fee": _gc(0.0),
"voucher": _gc(0.0)},
# 余额行:填充对应细分余额
{"label": "余额",
"total": _gc(gift_balance),
"liquor": _gc(gift_liquor_balance),
"table_fee": _gc(gift_table_fee_balance),
"voucher": _gc(gift_voucher_balance)},
],
"all_card_balance": _f(row[4]),
}
def _empty_recharge_data() -> dict:
"""预收资产空默认值。"""
_gc = lambda v: {"value": v}
empty_row = {"label": "", "total": _gc(0.0), "liquor": _gc(0.0), "table_fee": _gc(0.0), "voucher": _gc(0.0)}
return {
"actual_income": 0.0, "first_charge": 0.0, "renew_charge": 0.0,
"consumed": 0.0, "card_balance": 0.0,
"gift_rows": [
{**empty_row, "label": "新增"},
{**empty_row, "label": "消费"},
{**empty_row, "label": "余额"},
],
"all_card_balance": 0.0,
}
def get_finance_revenue(
conn: Any, site_id: int, start_date: str, end_date: str, area: str = "all"
) -> dict:
"""
BOARD-3 应计收入:收入结构 + 价格构成 + 优惠明细 + 渠道分布。
CHANGE 2026-03-19 | P0 修复:
1. structure_type='AREA' → is_sub=True区域子行'INCOME_TYPE' → is_sub=False主行
2. price_items 从 INCOME_TYPE 行提取
3. channel_items 从 v_dws_finance_daily_summary 聚合渠道支付数据
4. discount 按 discount_detail 总额均摊到 confirmed_total
⚠️ DWD-DOC 规则 2: 助教行使用 assistant_pd_money陪打+ assistant_cx_money超休
"""
with _fdw_context(conn, site_id) as cur:
# 收入结构主表(按 structure_type + category_code 分类)
cur.execute(
"""
SELECT structure_type, category_code, category_name,
SUM(income_amount) AS income_amount,
SUM(order_count) AS order_count,
SUM(duration_minutes) AS duration_minutes
FROM app.v_dws_finance_income_structure
WHERE stat_date >= %s::date AND stat_date <= %s::date
GROUP BY structure_type, category_code, category_name
ORDER BY structure_type, category_code
""",
(start_date, end_date),
)
structure_rows = []
price_items = []
total_income = 0.0
for row in cur.fetchall():
s_type = row[0] or ""
amt = float(row[3]) if row[3] is not None else 0.0
is_sub = (s_type == "AREA")
# 只有主行INCOME_TYPE计入总收入避免 AREA 子行重复计算
if not is_sub:
total_income += amt
price_items.append({
"label": row[2] or row[1] or "",
"amount": amt,
})
structure_rows.append({
"id": row[1] or "",
"name": row[2] or "",
"desc": None,
"is_sub": is_sub,
"amount": amt,
"discount": 0.0,
"booked": amt,
})
# 优惠明细
cur.execute(
"""
SELECT discount_type_code, discount_type_name,
SUM(discount_amount) AS amount
FROM app.v_dws_finance_discount_detail
WHERE stat_date >= %s::date AND stat_date <= %s::date
GROUP BY discount_type_code, discount_type_name
ORDER BY discount_type_code
""",
(start_date, end_date),
)
discount_items = []
total_discount = 0.0
for row in cur.fetchall():
amt = float(row[2]) if row[2] is not None else 0.0
total_discount += amt
discount_items.append({
"label": row[1] or row[0] or "",
"amount": amt,
})
# 渠道分布(从 v_dws_finance_daily_summary 聚合)
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,
COALESCE(SUM(platform_settlement_amount), 0) AS platform_pay
FROM app.v_dws_finance_daily_summary
WHERE stat_date >= %s::date AND stat_date <= %s::date
""",
(start_date, end_date),
)
ch = cur.fetchone()
channel_items = []
if ch:
for label, val in [
("现金/扫码", ch[0]),
("团购核销", ch[1]),
("储值卡消费", ch[2]),
("赠送卡消费", ch[3]),
("平台结算", ch[4]),
]:
channel_items.append({
"label": label,
"amount": float(val) if val is not None else 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,
"confirmed_total": confirmed_total,
"channel_items": channel_items,
}
def get_finance_cashflow(
conn: Any, site_id: int, start_date: str, end_date: str
) -> dict:
"""
BOARD-3 现金流入:消费收款 + 充值收款。
来源: app.v_dws_finance_daily_summary消费收款 + 充值收款字段均在财务日报中)。
CHANGE 2026-03-20 | 修正列名映射:
consume_cash_pay → cash_pay_amount,
consume_online_pay → groupbuy_pay_amount (团购核销),
consume_balance_pay → card_consume_total (储值卡消费),
recharge_income → recharge_cash_inflow (充值现金流入)
⚠️ DWD-DOC 规则 7: platform_settlement_amount 和 groupbuy_pay_amount 互斥。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT SUM(cash_pay_amount) AS consume_cash,
SUM(groupbuy_pay_amount) AS consume_groupbuy,
SUM(card_consume_total) AS consume_card,
SUM(recharge_cash_inflow) AS recharge_income,
SUM(cash_inflow_total) AS total
FROM app.v_dws_finance_daily_summary
WHERE stat_date >= %s::date AND stat_date <= %s::date
""",
(start_date, end_date),
)
row = cur.fetchone()
def _f(v):
return float(v) if v is not None else 0.0
if not row or row[0] is None:
return {
"consume_items": [
{"label": "现金收款", "amount": 0.0},
{"label": "团购核销", "amount": 0.0},
{"label": "储值卡消费", "amount": 0.0},
],
"recharge_items": [{"label": "充值收款", "amount": 0.0}],
"total": 0.0,
}
return {
"consume_items": [
{"label": "现金收款", "amount": _f(row[0])},
{"label": "团购核销", "amount": _f(row[1])},
{"label": "储值卡消费", "amount": _f(row[2])},
],
"recharge_items": [{"label": "充值收款", "amount": _f(row[3])}],
"total": _f(row[4]),
}
def get_finance_expense(
conn: Any, site_id: int, start_date: str, end_date: str
) -> dict:
"""
BOARD-3 现金流出:支出明细 4 子分组。
来源: app.v_dws_finance_expense_summary + app.v_dws_platform_settlement。
CHANGE 2026-03-20 | 修正列名映射:
expense_group → expense_category, expense_label → expense_type_name,
stat_date → expense_month (expense_summary),
stat_date → settlement_date (platform_settlement)
⚠️ DWD-DOC 规则 2: coachItems 中基础课使用 assistant_pd_money激励课使用 assistant_cx_money。
"""
with _fdw_context(conn, site_id) as cur:
# 支出汇总(日期列为 expense_month分组列为 expense_category
cur.execute(
"""
SELECT expense_category, expense_type_name,
SUM(expense_amount) AS amount
FROM app.v_dws_finance_expense_summary
WHERE expense_month >= %s::date AND expense_month <= %s::date
GROUP BY expense_category, expense_type_name
ORDER BY expense_category, expense_type_name
""",
(start_date, end_date),
)
groups: dict[str, list[dict]] = {
"operation": [], "fixed": [], "coach": [], "platform": [],
}
total = 0.0
for row in cur.fetchall():
group = row[0] or "operation"
label = row[1] or ""
amt = float(row[2]) if row[2] is not None else 0.0
if group in groups:
groups[group].append({"label": label, "amount": amt})
total += amt
# 平台服务费(独立视图,日期列为 settlement_date
cur.execute(
"""
SELECT platform_name, SUM(service_fee) AS fee
FROM app.v_dws_platform_settlement
WHERE settlement_date >= %s::date AND settlement_date <= %s::date
GROUP BY platform_name
ORDER BY platform_name
""",
(start_date, end_date),
)
for row in cur.fetchall():
amt = float(row[1]) if row[1] is not None else 0.0
groups["platform"].append({"label": row[0] or "", "amount": amt})
total += amt
return {
"operation_items": groups["operation"],
"fixed_items": groups["fixed"],
"coach_items": groups["coach"],
"platform_items": groups["platform"],
"total": total,
}
def get_finance_coach_analysis(
conn: Any, site_id: int, start_date: str, end_date: str
) -> dict:
"""
BOARD-3 助教分析:按 assistant_level_name 分组聚合。
来源: app.v_dws_assistant_salary_calc。
返回 basic基础课/陪打)+ incentive激励课/超休)两个子表。
"""
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT assistant_level_name AS level,
SUM(base_income) AS pd_pay,
SUM(bonus_income) AS cx_pay,
SUM(effective_hours) AS total_hours,
COUNT(DISTINCT assistant_id) AS coach_count
FROM app.v_dws_assistant_salary_calc
WHERE salary_month >= %s::date AND salary_month <= %s::date
GROUP BY assistant_level_name
ORDER BY assistant_level_name
""",
(start_date, end_date),
)
rows = cur.fetchall()
basic_rows = []
incentive_rows = []
total_pd = 0.0
total_cx = 0.0
total_hours = 0.0
for row in rows:
level = row[0] or ""
pd_pay = float(row[1]) if row[1] is not None else 0.0
cx_pay = float(row[2]) if row[2] is not None else 0.0
hours = float(row[3]) if row[3] is not None else 0.0
count = row[4] or 0
total_pd += pd_pay
total_cx += cx_pay
total_hours += hours
# 基础课(陪打)
pd_hourly = pd_pay / hours if hours > 0 else 0.0
basic_rows.append({
"level": level,
"pay": pd_pay,
"share": 0.0, # 服务层计算占比
"hourly": round(pd_hourly, 2),
})
# 激励课(超休)
cx_hourly = cx_pay / hours if hours > 0 else 0.0
incentive_rows.append({
"level": level,
"pay": cx_pay,
"share": 0.0,
"hourly": round(cx_hourly, 2),
})
# 计算占比
for r in basic_rows:
r["share"] = round(r["pay"] / total_pd * 100, 1) if total_pd > 0 else 0.0
for r in incentive_rows:
r["share"] = round(r["pay"] / total_cx * 100, 1) if total_cx > 0 else 0.0
avg_pd_hourly = round(total_pd / total_hours, 2) if total_hours > 0 else 0.0
avg_cx_hourly = round(total_cx / total_hours, 2) if total_hours > 0 else 0.0
return {
"basic": {
"total_pay": total_pd,
"total_share": 100.0 if total_pd > 0 else 0.0,
"avg_hourly": avg_pd_hourly,
"rows": basic_rows,
},
"incentive": {
"total_pay": total_cx,
"total_share": 100.0 if total_cx > 0 else 0.0,
"avg_hourly": avg_cx_hourly,
"rows": incentive_rows,
},
}
def get_skill_types(conn: Any, site_id: int) -> list[dict]:
"""
CONFIG-1: 查询项目类型筛选器配置。
来源: app.v_cfg_area_category基于 dws.cfg_area_category 去重,
排除 SPECIAL/OTHER。返回列表头部插入"不限"选项。
查询失败时由调用方降级返回空数组。
"""
# CHANGE 2026-03-20 | R3 修复:原查询虚构的 v_cfg_skill_type 视图不存在,
# 改为查询 v_cfg_area_category项目类型配置value 直接用 category_code
# BILLIARD/SNOOKER/MAHJONG/KTV前端枚举同步修改。
# 假设cfg_area_category 的 category_code 是稳定的业务标识,不会频繁变动。
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT category_code, display_name, short_name
FROM app.v_cfg_area_category
ORDER BY sort_order
"""
)
# 头部插入"不限"选项(后端生成,不存数据库)
items: list[dict] = [{"key": "ALL", "label": "不限", "emoji": "", "cls": ""}]
for row in cur.fetchall():
items.append({
"key": row[0] or "",
"label": row[1] or "",
"emoji": row[2] or "",
"cls": "",
})
return items