3866 lines
154 KiB
Python
3866 lines
154 KiB
Python
# 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 暂不生效
|
||
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | batch_query_for_task_list 增加第 8 步
|
||
# 查询 app.v_cfg_performance_tier 获取当前有效档位配置,返回 performance_tiers 字段。
|
||
# - 2026-03-24 | Prompt: bonus_money 需要 bonus_deduction_ratio | batch_query_for_task_list 第 8 步
|
||
# 和 get_performance_tiers 均增加 bonus_deduction_ratio 列(打赏课抽成比例)。
|
||
# - 2026-03-25 | Prompt: 保底 relationship_building 任务 | 新增 get_all_service_pairs(),
|
||
# 查询 v_dws_member_assistant_relation_index WHERE session_count > 0 的全量关系对,
|
||
# 用于保底任务生成(不限 os_label)。
|
||
# - 2026-03-25 | Prompt: 任务详情服务记录6项改进 | get_service_records_for_task() 改造:
|
||
# (1) LEFT JOIN v_dim_table 获取台桌名称(同 get_service_records);
|
||
# (2) 收入改用 settlement_head.assistant_pd_money + assistant_cx_money(助教到手);
|
||
# (3) LATERAL 子查询 v_dwd_store_goods_sale 聚合商品明细(按 ledger_name GROUP BY SUM);
|
||
# (4) 返回 drinks 字段("商品名×数量" 格式)。
|
||
# - 2026-03-27 | Prompt: board-finance-integration T2.1-T2.3 |
|
||
# T2.1: get_finance_recharge() 卡余额快照值从 SUM 改为取最后一天(3 查询拆分),
|
||
# 新增 consumed 字段从 v_dws_finance_daily_summary.card_consume_total SUM 聚合。
|
||
# T2.2: get_finance_cashflow() 移除"储值卡消费"项,新增"纸币现金"/"扫码收款"拆分
|
||
# (依赖 T1.1 新增字段 cash_paper_amount/scan_pay_amount)。
|
||
# T2.3: get_finance_coach_analysis() 修复小时均价分母(base_hours/bonus_hours),
|
||
# pay 改为对客收费(hours×course_price),share 改为球房分成(hours×deduction)。
|
||
|
||
"""
|
||
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 = 0(RLS 视图基于 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
|
||
|
||
from app.trace.decorators import trace_service
|
||
|
||
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_conn: Any = None):
|
||
"""
|
||
上下文管理器:直连 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.* 表)。
|
||
|
||
CHANGE 2026-03-26 | ETL 连接复用:传入 etl_conn 时复用已有连接(不关闭),
|
||
不传时新建连接并在 yield 后自动关闭。避免同一请求内多次新建连接(每次 ~2.6s)。
|
||
"""
|
||
owned = etl_conn is None
|
||
if owned:
|
||
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:
|
||
if owned:
|
||
etl_conn.close()
|
||
|
||
|
||
@trace_service(description_zh="获取会员信息", description_en="Get member info")
|
||
def get_member_info(
|
||
conn: Any, site_id: int, member_ids: list[int],
|
||
*, etl_conn: Any = None,
|
||
) -> 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, etl_conn=etl_conn) 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
|
||
|
||
|
||
@trace_service(description_zh="获取会员余额", description_en="Get member balance")
|
||
def get_member_balance(
|
||
conn: Any, site_id: int, member_ids: list[int],
|
||
*, etl_conn: Any = None,
|
||
) -> 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, etl_conn=etl_conn) as cur:
|
||
# CHANGE 2026-03-29 | 修复多卡膨胀:同一客户多张卡需 SUM 聚合
|
||
cur.execute(
|
||
"""
|
||
SELECT tenant_member_id AS member_id, SUM(balance) AS balance
|
||
FROM app.v_dim_member_card_account
|
||
WHERE tenant_member_id = ANY(%s) AND scd2_is_current = 1
|
||
GROUP BY tenant_member_id
|
||
""",
|
||
(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
|
||
|
||
|
||
@trace_service(description_zh="获取最后到店天数", description_en="Get last visit days")
|
||
def get_last_visit_days(
|
||
conn: Any, site_id: int, member_ids: list[int],
|
||
*, etl_conn: Any = None,
|
||
) -> dict[int, int | None]:
|
||
"""
|
||
批量查询客户距上次到店天数。
|
||
|
||
来源: app.v_dwd_assistant_service_log。
|
||
废单排除: is_delete = 0(RLS 视图使用 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, etl_conn=etl_conn) 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
|
||
|
||
|
||
@trace_service(description_zh="获取工资计算数据", description_en="Get salary calculation")
|
||
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,
|
||
top_rank_bonus,
|
||
recharge_commission,
|
||
other_bonus,
|
||
base_deduction,
|
||
bonus_deduction_ratio
|
||
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,
|
||
# CHANGE 2026-03-24 | 奖金拆分字段 + 当前档位抽成参数,用于 performance 页收入明细和到手费率计算
|
||
"top_rank_bonus": float(row[16]) if row[16] is not None else 0.0,
|
||
"recharge_commission": float(row[17]) if row[17] is not None else 0.0,
|
||
"other_bonus": float(row[18]) if row[18] is not None else 0.0,
|
||
"base_deduction": float(row[19]) if row[19] is not None else 0.0,
|
||
"bonus_deduction_ratio": float(row[20]) if row[20] is not None else 0.0,
|
||
}
|
||
|
||
|
||
def get_monthly_summary(
|
||
conn: Any, site_id: int, assistant_id: int, year: int, month: int
|
||
) -> dict | None:
|
||
"""
|
||
查询助教当月实时业绩汇总(每日更新)。
|
||
|
||
来源: app.v_dws_assistant_monthly_summary。
|
||
用于 banner 展示当月课时/档位/客户数等实时数据,
|
||
不依赖月初结算的 salary_calc。
|
||
"""
|
||
stat_month = f"{year}-{month:02d}-01"
|
||
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT effective_hours,
|
||
base_hours,
|
||
bonus_hours,
|
||
room_hours,
|
||
tier_id,
|
||
tier_code,
|
||
tier_name,
|
||
unique_customers,
|
||
assistant_level_name,
|
||
rank_with_ties
|
||
FROM app.v_dws_assistant_monthly_summary
|
||
WHERE assistant_id = %s AND stat_month = %s::date
|
||
""",
|
||
(assistant_id, stat_month),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
return {
|
||
"effective_hours": float(row[0]) if row[0] is not None else 0.0,
|
||
"base_hours": float(row[1]) if row[1] is not None else 0.0,
|
||
"bonus_hours": float(row[2]) if row[2] is not None else 0.0,
|
||
"room_hours": float(row[3]) if row[3] is not None else 0.0,
|
||
"tier_id": row[4] or 0,
|
||
"tier_code": row[5] or "",
|
||
"tier_name": row[6] or "",
|
||
"unique_customers": row[7] or 0,
|
||
"coach_level": row[8] or "",
|
||
"rank_with_ties": row[9] or 0,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="批量查询任务列表ETL数据", description_en="Batch query for task list")
|
||
def batch_query_for_task_list(
|
||
conn: Any,
|
||
site_id: int,
|
||
assistant_id: int,
|
||
member_ids: list[int],
|
||
year: int,
|
||
month: int,
|
||
) -> dict:
|
||
"""
|
||
单连接批量查询任务列表所需的所有 ETL 数据。
|
||
|
||
CHANGE 2026-03-23: 合并 7 次独立 ETL 连接为 1 次,消除连接开销瓶颈。
|
||
原来每个查询各自 _fdw_context → 新建连接 → BEGIN → SET LOCAL → 查询 → 关闭,
|
||
7 次连接开销约 350-1400ms。合并后仅 1 次连接,节省 ~85% 连接时间。
|
||
|
||
返回 {member_info, balance, last_visit, rs, monthly_summary, salary_cur, salary_prev}。
|
||
"""
|
||
member_info_map: dict[int, dict] = {}
|
||
balance_map: dict[int, Decimal] = {}
|
||
last_visit_map: dict[int, int | None] = {}
|
||
rs_map: dict[int, Decimal] = {}
|
||
wbi_map: dict[int, dict] = {}
|
||
recent60d_map: dict[int, dict] = {}
|
||
monthly_summary: dict | None = None
|
||
salary_cur: dict | None = None
|
||
salary_prev: dict | None = None
|
||
|
||
stat_month = f"{year}-{month:02d}-01"
|
||
prev_year, prev_month = (year, month - 1) if month > 1 else (year - 1, 12)
|
||
prev_stat_month = f"{prev_year}-{prev_month:02d}-01"
|
||
|
||
with _fdw_context(conn, site_id) as cur:
|
||
# 1. 会员信息
|
||
if member_ids:
|
||
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():
|
||
member_info_map[row[0]] = {"nickname": row[1], "mobile": row[2]}
|
||
|
||
# 2. 余额
|
||
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():
|
||
balance_map[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal("0")
|
||
|
||
# 3. 最后到店天数
|
||
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():
|
||
last_visit_map[row[0]] = row[1]
|
||
|
||
# 4. RS 指数
|
||
cur.execute(
|
||
"""
|
||
SELECT member_id, COALESCE(rs_display, 0)
|
||
FROM app.v_dws_member_assistant_relation_index
|
||
WHERE assistant_id = %s AND member_id = ANY(%s)
|
||
""",
|
||
(assistant_id, member_ids),
|
||
)
|
||
for row in cur.fetchall():
|
||
rs_map[row[0]] = Decimal(str(row[1]))
|
||
|
||
# 4b. WBI 期望到店间隔 + 期望下次到店日期
|
||
# CHANGE 2026-03-24 | 新增:用于任务卡片"预期X天"标签
|
||
cur.execute(
|
||
"""
|
||
SELECT member_id, ideal_interval_days, ideal_next_visit_date
|
||
FROM app.v_dws_member_winback_index
|
||
WHERE member_id = ANY(%s)
|
||
""",
|
||
(member_ids,),
|
||
)
|
||
for row in cur.fetchall():
|
||
wbi_map[row[0]] = {
|
||
"ideal_interval_days": float(row[1]) if row[1] is not None else None,
|
||
"ideal_next_visit_date": row[2].isoformat() if row[2] is not None else None,
|
||
}
|
||
|
||
# 4c. 近60天服务汇总(口径同 task-detail serviceSummary)
|
||
# CHANGE 2026-03-27 | 新增:按 assistant_id + member_id 聚合近60天总时长和到手收入
|
||
# 到手 = hours × net_rate(基础课: base_price - deduction; 激励课: bonus_price × (1 - ratio))
|
||
cur.execute(
|
||
"""
|
||
SELECT sl.tenant_member_id,
|
||
SUM(sl.income_seconds / 3600.0) AS total_hours,
|
||
SUM(
|
||
CASE
|
||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||
END
|
||
) AS total_income
|
||
FROM app.v_dwd_assistant_service_log sl
|
||
LEFT JOIN app.v_dws_assistant_salary_calc sc
|
||
ON sl.site_assistant_id = sc.assistant_id
|
||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||
WHERE sl.site_assistant_id = %s
|
||
AND sl.tenant_member_id = ANY(%s)
|
||
AND sl.is_delete = 0
|
||
AND sl.create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
|
||
GROUP BY sl.tenant_member_id
|
||
""",
|
||
(assistant_id, member_ids),
|
||
)
|
||
for row in cur.fetchall():
|
||
recent60d_map[row[0]] = {
|
||
"hours": round(float(row[1]), 2) if row[1] is not None else 0.0,
|
||
"income": round(float(row[2]), 2) if row[2] is not None else 0.0,
|
||
}
|
||
|
||
# 5. 当月 monthly_summary
|
||
cur.execute(
|
||
"""
|
||
SELECT effective_hours, base_hours, bonus_hours, room_hours,
|
||
tier_id, tier_code, tier_name, unique_customers,
|
||
assistant_level_name, rank_with_ties
|
||
FROM app.v_dws_assistant_monthly_summary
|
||
WHERE assistant_id = %s AND stat_month = %s::date
|
||
""",
|
||
(assistant_id, stat_month),
|
||
)
|
||
row = cur.fetchone()
|
||
if row:
|
||
monthly_summary = {
|
||
"effective_hours": float(row[0]) if row[0] is not None else 0.0,
|
||
"base_hours": float(row[1]) if row[1] is not None else 0.0,
|
||
"bonus_hours": float(row[2]) if row[2] is not None else 0.0,
|
||
"room_hours": float(row[3]) if row[3] is not None else 0.0,
|
||
"tier_id": row[4] or 0,
|
||
"tier_code": row[5] or "",
|
||
"tier_name": row[6] or "",
|
||
"unique_customers": row[7] or 0,
|
||
"coach_level": row[8] or "",
|
||
"rank_with_ties": row[9] or 0,
|
||
}
|
||
|
||
# 6. 当月 salary_calc
|
||
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, stat_month),
|
||
)
|
||
row = cur.fetchone()
|
||
if row:
|
||
salary_cur = _parse_salary_row(row, stat_month)
|
||
|
||
# 7. 上月 salary_calc
|
||
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, prev_stat_month),
|
||
)
|
||
row = cur.fetchone()
|
||
if row:
|
||
salary_prev = _parse_salary_row(row, prev_stat_month)
|
||
|
||
# 8. 绩效档位配置(用于构建 tier_nodes + bonus_money 计算)
|
||
# CHANGE 2026-03-24 | 增加 bonus_deduction_ratio 用于打赏课抽成差额计算
|
||
cur.execute(
|
||
"""
|
||
SELECT tier_id, tier_code, tier_name, tier_level,
|
||
min_hours, max_hours, base_deduction, bonus_deduction_ratio
|
||
FROM app.v_cfg_performance_tier
|
||
WHERE effective_from <= CURRENT_DATE
|
||
AND effective_to >= CURRENT_DATE
|
||
ORDER BY tier_level
|
||
"""
|
||
)
|
||
tier_rows = cur.fetchall()
|
||
performance_tiers = [
|
||
{
|
||
"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,
|
||
"base_deduction": float(r[6]) if r[6] is not None else 0.0,
|
||
"bonus_deduction_ratio": float(r[7]) if r[7] is not None else 0.0,
|
||
}
|
||
for r in tier_rows
|
||
]
|
||
|
||
return {
|
||
"member_info": member_info_map,
|
||
"balance": balance_map,
|
||
"last_visit": last_visit_map,
|
||
"rs": rs_map,
|
||
"wbi": wbi_map,
|
||
"recent60d": recent60d_map,
|
||
"monthly_summary": monthly_summary,
|
||
"salary_cur": salary_cur,
|
||
"salary_prev": salary_prev,
|
||
"performance_tiers": performance_tiers,
|
||
}
|
||
|
||
|
||
def _parse_salary_row(row: tuple, fallback_month: str) -> dict:
|
||
"""解析 salary_calc 查询结果行为 dict。"""
|
||
return {
|
||
"calc_month": str(row[0]) if row[0] else fallback_month,
|
||
"coach_level": row[1] or "",
|
||
"tier_index": row[2] or 0,
|
||
"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": 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_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,
|
||
"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,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取绩效档位配置", description_en="Get performance tiers")
|
||
def get_performance_tiers(
|
||
conn: Any, site_id: int
|
||
) -> list[dict]:
|
||
"""
|
||
查询当前有效的绩效档位配置。
|
||
|
||
来源: app.v_cfg_performance_tier(RLS 视图)。
|
||
按 tier_level 升序返回,仅包含当前日期有效的档位。
|
||
|
||
⚠️ feiqiu-data-rules 规则 6: 绩效档位必须从配置表读取,禁止硬编码。
|
||
|
||
返回 [{tier_id, tier_code, tier_name, tier_level, min_hours, max_hours,
|
||
base_deduction, bonus_deduction_ratio}, ...]。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT tier_id, tier_code, tier_name, tier_level,
|
||
min_hours, max_hours, base_deduction, bonus_deduction_ratio
|
||
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,
|
||
"base_deduction": float(r[6]) if r[6] is not None else 0.0,
|
||
"bonus_deduction_ratio": float(r[7]) if r[7] is not None else 0.0,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
@trace_service(description_zh="获取等级映射", description_en="Get level map")
|
||
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 {}
|
||
|
||
|
||
@trace_service(description_zh="获取服务记录", description_en="Get service records")
|
||
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_id(WHERE 过滤用)
|
||
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(业绩时长)
|
||
ledger_amount → income(items_sum 口径)
|
||
skill_name → course_type(数据库原始值,不做二次映射)
|
||
dim_table.table_name → table_name(桌号名称,JOIN dim_table 获取)
|
||
|
||
CHANGE 2026-03-26 | 收入口径: JOIN salary_calc 取到手费率,hours × net_rate 算真正到手
|
||
|
||
⚠️ 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.skill_name,
|
||
COALESCE(dt.table_name, '') AS table_name,
|
||
-- CHANGE 2026-03-26 | 到手 = hours × net_rate
|
||
CASE
|
||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||
END 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
|
||
LEFT JOIN app.v_dim_table dt
|
||
ON sl.site_table_id = dt.table_id
|
||
AND dt.scd2_is_current = 1
|
||
LEFT JOIN app.v_dws_assistant_salary_calc sc
|
||
ON sl.site_assistant_id = sc.assistant_id
|
||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||
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,
|
||
"course_type": row[7],
|
||
"table_name": row[8] or "",
|
||
"income": float(row[9]) if row[9] is not None else 0.0,
|
||
})
|
||
|
||
return records
|
||
|
||
|
||
@trace_service(description_zh="获取近90天服务记录", description_en="Get service records for 90 days")
|
||
def get_service_records_90days(
|
||
conn: Any,
|
||
site_id: int,
|
||
assistant_id: int,
|
||
ref_date: str,
|
||
) -> list[dict]:
|
||
"""
|
||
查询助教近90天的服务记录(常客统计用)。
|
||
|
||
ref_date: 参考日期(如 "2026-03-01"),向前推90天。
|
||
CHANGE 2026-03-26 | 收入口径: JOIN salary_calc 取到手费率
|
||
返回 member_id 级聚合:{ member_id, customer_name, count, total_hours, total_income }。
|
||
"""
|
||
records: list[dict] = []
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT sl.tenant_member_id,
|
||
dm.nickname AS customer_name,
|
||
COUNT(*) AS cnt,
|
||
SUM(sl.income_seconds / 3600.0) AS total_hours,
|
||
SUM(
|
||
CASE
|
||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||
END
|
||
) AS total_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
|
||
LEFT JOIN app.v_dws_assistant_salary_calc sc
|
||
ON sl.site_assistant_id = sc.assistant_id
|
||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||
WHERE sl.site_assistant_id = %s AND sl.is_delete = 0
|
||
AND sl.create_time >= (%s::date - INTERVAL '90 days')::timestamptz
|
||
AND sl.create_time < %s::timestamptz
|
||
GROUP BY sl.tenant_member_id, dm.nickname
|
||
""",
|
||
(assistant_id, ref_date, ref_date),
|
||
)
|
||
for row in cur.fetchall():
|
||
records.append({
|
||
"member_id": row[0],
|
||
"customer_name": row[1] or "未知客户",
|
||
"count": row[2],
|
||
"total_hours": float(row[3]) if row[3] is not None else 0.0,
|
||
"total_income": float(row[4]) if row[4] is not None else 0.0,
|
||
})
|
||
return records
|
||
|
||
|
||
@trace_service(description_zh="获取任务关联服务记录", description_en="Get service records for task")
|
||
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)。
|
||
|
||
CHANGE 2026-03-25 | 台桌名称: LEFT JOIN v_dim_table 获取 table_name(同 get_service_records)
|
||
CHANGE 2026-03-26 | 收入口径: JOIN salary_calc 取到手费率,hours × net_rate 算真正到手
|
||
CHANGE 2026-03-25 | 酒水商品: 子查询 v_dwd_store_goods_sale 聚合 "商品名×数量" 明细
|
||
|
||
返回按 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,
|
||
-- CHANGE 2026-03-27 | real_use_seconds 不用于业绩,惩罚在 DWS 层计算
|
||
-- DWD 层服务记录卡片不显示折前时长,统一返回 NULL
|
||
NULL AS service_hours_raw,
|
||
sl.skill_name,
|
||
COALESCE(dt.table_name, '') AS table_name,
|
||
-- CHANGE 2026-03-26 | 到手 = hours × net_rate(基础课: base_price - deduction; 激励课: bonus_price × (1 - ratio))
|
||
CASE
|
||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||
END AS income,
|
||
gs_agg.drinks
|
||
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_table dt
|
||
ON sl.site_table_id = dt.table_id
|
||
AND dt.scd2_is_current = 1
|
||
LEFT JOIN app.v_dws_assistant_salary_calc sc
|
||
ON sl.site_assistant_id = sc.assistant_id
|
||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||
LEFT JOIN LATERAL (
|
||
SELECT string_agg(gs.ledger_name || '×' || gs.total_count, '、') AS drinks
|
||
FROM (
|
||
SELECT ledger_name, SUM(ledger_count) AS total_count
|
||
FROM app.v_dwd_store_goods_sale
|
||
WHERE order_settle_id = sl.order_settle_id
|
||
AND is_delete = 0
|
||
GROUP BY ledger_name
|
||
) gs
|
||
) gs_agg ON true
|
||
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():
|
||
svc_hours = float(row[6]) if row[6] is not None else 0.0
|
||
# CHANGE 2026-03-27 | 折前时长:SQL 层已判断,无惩罚时 row[7] 为 NULL
|
||
svc_hours_raw = float(row[7]) if row[7] is not None else None
|
||
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": round(svc_hours, 2),
|
||
"service_hours_raw": round(svc_hours_raw, 2) if svc_hours_raw is not None else None,
|
||
"course_type": row[8],
|
||
"table_name": row[9] or "",
|
||
"income": float(row[10]) if row[10] is not None else 0.0,
|
||
"is_estimate": False,
|
||
"drinks": row[11],
|
||
})
|
||
|
||
return records
|
||
|
||
|
||
@trace_service(description_zh="获取近60天消费金额", description_en="Get 60-day consumption")
|
||
def get_consumption_60d(
|
||
conn: Any, site_id: int, member_id: int,
|
||
*, etl_conn: Any = None,
|
||
) -> Decimal | None:
|
||
"""
|
||
查询客户近 60 天消费金额。
|
||
|
||
来源: app.v_dws_member_consumption_summary(DWS 预聚合表)。
|
||
与 board-customer spend60 维度统一口径:items_sum,60天窗口,日粒度。
|
||
取最新 stat_date 的快照行。
|
||
"""
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT consume_amount_60d
|
||
FROM app.v_dws_member_consumption_summary
|
||
WHERE member_id = %s
|
||
ORDER BY stat_date DESC
|
||
LIMIT 1
|
||
""",
|
||
(member_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
return Decimal(str(row[0])) if row and row[0] is not None else None
|
||
|
||
|
||
@trace_service(description_zh="获取关系指数", description_en="Get relation index")
|
||
def get_relation_index(
|
||
conn: Any, site_id: int, member_id: int,
|
||
*, etl_conn: Any = None,
|
||
) -> 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, etl_conn=etl_conn) 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_head,WHERE settle_type 引用也改为 sh。
|
||
# 原因:这些字段属于结算单头表,不在助教服务日志视图中。
|
||
# 验证:MCP 端到端查询通过。
|
||
@trace_service(description_zh="获取消费记录", description_en="Get consumption records")
|
||
def get_consumption_records(
|
||
conn: Any, site_id: int, member_id: int, limit: int, offset: int,
|
||
*, etl_conn: Any = None,
|
||
start_date: str | None = None, end_date: str | None = None,
|
||
) -> 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] = []
|
||
# CHANGE 2026-03-29 | CUST-3: 支持按月份过滤消费记录
|
||
date_clause = ""
|
||
date_params: list = []
|
||
if start_date:
|
||
date_clause += " AND sl.create_time >= %s::timestamptz"
|
||
date_params.append(start_date)
|
||
if end_date:
|
||
date_clause += " AND sl.create_time < %s::timestamptz"
|
||
date_params.append(end_date)
|
||
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||
cur.execute(
|
||
f"""
|
||
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.nickname, da.real_name, '') 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,
|
||
sh.consume_money,
|
||
sh.adjust_amount
|
||
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
|
||
WHERE sl.tenant_member_id = %s
|
||
AND sl.is_delete = 0
|
||
AND sh.settle_type IN (1, 3)
|
||
{date_clause}
|
||
ORDER BY sl.create_time DESC
|
||
LIMIT %s OFFSET %s
|
||
""",
|
||
(member_id,) + tuple(date_params) + (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 "",
|
||
"assistant_level": row[10], # int level code
|
||
"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],
|
||
"consume_money": float(row[16]) if row[16] is not None else 0.0,
|
||
"adjust_amount": float(row[17]) if row[17] is not None else 0.0,
|
||
})
|
||
return records
|
||
|
||
|
||
@trace_service(description_zh="获取累计服务次数", description_en="Get total service count")
|
||
def get_total_service_count(
|
||
conn: Any, site_id: int, member_id: int,
|
||
*, etl_conn: Any = None,
|
||
assistant_id: int | None = None,
|
||
) -> int:
|
||
"""
|
||
查询客户累计服务总次数(跨所有月份)。
|
||
|
||
来源: app.v_dwd_assistant_service_log。
|
||
⚠️ 废单排除: is_delete = 0。
|
||
CHANGE 2026-03-27 | 新增 assistant_id 过滤,只统计指定助教的服务次数。
|
||
"""
|
||
where = "tenant_member_id = %s AND is_delete = 0"
|
||
params: list = [member_id]
|
||
if assistant_id:
|
||
where += " AND site_assistant_id = %s"
|
||
params.append(assistant_id)
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||
cur.execute(
|
||
f"SELECT COUNT(*) FROM app.v_dwd_assistant_service_log WHERE {where}",
|
||
params,
|
||
)
|
||
row = cur.fetchone()
|
||
return row[0] if row else 0
|
||
|
||
|
||
@trace_service(description_zh="获取助教近60天统计", description_en="Get coach 60-day stats")
|
||
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,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取客户服务记录", description_en="Get customer service records")
|
||
def get_customer_service_records(
|
||
conn: Any,
|
||
site_id: int,
|
||
member_id: int,
|
||
year: int,
|
||
month: int,
|
||
table: str | None,
|
||
limit: int,
|
||
offset: int,
|
||
*,
|
||
etl_conn: Any = None,
|
||
assistant_id: int | None = None,
|
||
) -> tuple[list[dict], int]:
|
||
"""
|
||
查询客户按月服务记录(CUST-2 用)。
|
||
|
||
来源: app.v_dwd_assistant_service_log + v_dim_table + v_dws_assistant_salary_calc + v_dwd_store_goods_sale。
|
||
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
|
||
⚠️ 废单排除: is_delete = 0。
|
||
CHANGE 2026-03-27 | 新增 assistant_id 过滤,只返回指定助教的服务记录。
|
||
CHANGE 2026-03-27 | 统一卡片数据:复用 get_service_records_for_task 的 SQL 模式
|
||
— 台桌名称: LEFT JOIN v_dim_table
|
||
— 到手金额: LEFT JOIN salary_calc,hours × net_rate
|
||
— 酒水商品: LEFT JOIN LATERAL v_dwd_store_goods_sale 聚合
|
||
返回 (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 assistant_id:
|
||
base_where += " AND sl.site_assistant_id = %s"
|
||
params.append(assistant_id)
|
||
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, etl_conn=etl_conn) 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
|
||
|
||
# CHANGE 2026-03-27 | 统一卡片数据:与 get_service_records_for_task 对齐
|
||
# — 台桌名称、到手金额(salary_calc)、酒水商品(LATERAL)
|
||
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,
|
||
-- CHANGE 2026-03-27 | real_use_seconds 不用于业绩,惩罚在 DWS 层计算
|
||
-- DWD 层服务记录卡片不显示折前时长,统一返回 NULL
|
||
NULL AS service_hours_raw,
|
||
sl.skill_name AS course_type,
|
||
COALESCE(dt.table_name, '') AS table_name,
|
||
COALESCE(da.nickname, da.real_name, '') AS assistant_name,
|
||
-- 到手 = hours × net_rate(与 get_service_records_for_task 同口径)
|
||
CASE
|
||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||
END AS income,
|
||
gs_agg.drinks
|
||
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_dim_table dt
|
||
ON sl.site_table_id = dt.table_id
|
||
AND dt.scd2_is_current = 1
|
||
LEFT JOIN app.v_dws_assistant_salary_calc sc
|
||
ON sl.site_assistant_id = sc.assistant_id
|
||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||
LEFT JOIN LATERAL (
|
||
SELECT string_agg(gs.ledger_name || '×' || gs.total_count, '、') AS drinks
|
||
FROM (
|
||
SELECT ledger_name, SUM(ledger_count) AS total_count
|
||
FROM app.v_dwd_store_goods_sale
|
||
WHERE order_settle_id = sl.order_settle_id
|
||
AND is_delete = 0
|
||
GROUP BY ledger_name
|
||
) gs
|
||
) gs_agg ON true
|
||
WHERE {base_where}
|
||
ORDER BY sl.create_time DESC
|
||
LIMIT %s OFFSET %s
|
||
""",
|
||
params + [limit, offset],
|
||
)
|
||
for row in cur.fetchall():
|
||
svc_hours = float(row[4]) if row[4] is not None else 0.0
|
||
# CHANGE 2026-03-27 | 折前时长:SQL 层已判断,无惩罚时 row[5] 为 NULL
|
||
svc_hours_raw = float(row[5]) if row[5] is not None else None
|
||
records.append({
|
||
"id": str(row[0]) if row[0] else "",
|
||
"create_time": row[1],
|
||
"start_time": row[2],
|
||
"end_time": row[3],
|
||
"service_hours": round(svc_hours, 2),
|
||
"service_hours_raw": round(svc_hours_raw, 2) if svc_hours_raw is not None else None,
|
||
"course_type": row[6] or "",
|
||
"table_name": row[7] or "",
|
||
"assistant_name": row[8] or "",
|
||
"income": float(row[9]) if row[9] is not None else 0.0,
|
||
"drinks": row[10],
|
||
})
|
||
return records, total_count
|
||
|
||
|
||
@trace_service(description_zh="获取助教信息", description_en="Get assistant info")
|
||
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(nickname, real_name, '') 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": row[3].strftime("%Y-%m-%d") if row[3] else None,
|
||
# 视图中无 avatar/skills/work_years,使用默认值
|
||
"avatar": "",
|
||
"skills": [],
|
||
"work_years": 0.0,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="批量获取多月工资数据", description_en="Get salary calculation for multiple months")
|
||
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
|
||
|
||
|
||
@trace_service(description_zh="获取月度客户数", description_en="Get monthly customer count")
|
||
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
|
||
|
||
|
||
@trace_service(description_zh="获取助教TOP客户", description_en="Get coach top customers")
|
||
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_amount(items_sum 口径)。
|
||
⚠️ 废单排除: is_delete = 0。
|
||
"""
|
||
records: list[dict] = []
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
WITH card_balance AS (
|
||
-- CHANGE 2026-03-29 | 多卡膨胀修复:先按 tenant_member_id 聚合卡余额
|
||
SELECT tenant_member_id, SUM(COALESCE(balance, 0)) AS total_balance
|
||
FROM app.v_dim_member_card_account
|
||
WHERE scd2_is_current = 1
|
||
GROUP BY tenant_member_id
|
||
)
|
||
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,
|
||
COALESCE(cb.total_balance, 0) 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 card_balance cb
|
||
ON sl.tenant_member_id = cb.tenant_member_id
|
||
WHERE sl.site_assistant_id = %s
|
||
AND sl.is_delete = 0
|
||
AND sl.tenant_member_id > 0
|
||
GROUP BY sl.tenant_member_id, dm.nickname, cb.total_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
|
||
|
||
|
||
@trace_service(description_zh="获取助教服务记录", description_en="Get coach service 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。
|
||
CHANGE 2026-03-26 | 收入口径: JOIN salary_calc 取到手费率,hours × net_rate 算真正到手
|
||
⚠️ 废单排除: 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,
|
||
-- CHANGE 2026-03-26 | 到手 = hours × net_rate
|
||
CASE
|
||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||
END AS income,
|
||
sl.skill_name AS course_type,
|
||
sl.site_table_id AS table_id,
|
||
dt.table_name AS table_name
|
||
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_dws_assistant_salary_calc sc
|
||
ON sl.site_assistant_id = sc.assistant_id
|
||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||
LEFT JOIN app.v_dim_table dt
|
||
ON sl.site_table_id = dt.table_id
|
||
AND dt.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],
|
||
"table_name": row[8] or "",
|
||
})
|
||
return records
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# BOARD-1 助教看板 FDW 查询
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@trace_service(description_zh="获取所有助教列表", description_en="Get all assistants")
|
||
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.nickname, da.real_name, '') 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
|
||
AND da.leave_status = 0
|
||
{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
|
||
|
||
|
||
@trace_service(description_zh="批量获取工资数据", description_en="Get salary calculation batch")
|
||
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,
|
||
SUM(base_hours + bonus_hours + room_hours) AS raw_hours,
|
||
BOOL_OR(is_new_hire) AS is_new_hire
|
||
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 "",
|
||
"raw_hours": float(row[6]) if row[6] is not None else 0.0,
|
||
"is_new_hire": bool(row[7]) if row[7] is not None else False,
|
||
}
|
||
return result
|
||
|
||
|
||
@trace_service(description_zh="批量获取助教TOP客户", description_en="Get top customers for coaches")
|
||
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 "💙"
|
||
|
||
|
||
@trace_service(description_zh="获取助教储值数据", description_en="Get coach stored value data")
|
||
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-29 | 修正数据源:客源储值 = 该助教关联客户的卡余额合计。
|
||
从 relation_index 获取助教-客户关联,JOIN card_account 获取余额。
|
||
sv_amount = 关联客户余额合计
|
||
sv_customer_count = 关联客户数(有余额的)
|
||
sv_consume = 关联客户 60 天消费合计
|
||
|
||
返回 {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:
|
||
# CHANGE 2026-04-07 | Fix-6:sv_consume 改为从结算表按 start_date/end_date 过滤,
|
||
# 使其随时间筛选联动,而非固定 60 天窗口。
|
||
cur.execute(
|
||
"""
|
||
WITH coach_members AS (
|
||
SELECT ri.assistant_id,
|
||
ri.member_id
|
||
FROM app.v_dws_member_assistant_relation_index ri
|
||
WHERE ri.assistant_id = ANY(%s)
|
||
AND ri.session_count > 0
|
||
),
|
||
period_consume AS (
|
||
SELECT sh.member_id,
|
||
COALESCE(SUM(sh.items_sum), 0) AS consume_amount
|
||
FROM app.v_dwd_settlement_head sh
|
||
WHERE sh.member_id = ANY(SELECT member_id FROM coach_members)
|
||
AND sh.settle_type IN (1, 3)
|
||
AND sh.pay_time::date >= %s::date
|
||
AND sh.pay_time::date <= %s::date
|
||
GROUP BY sh.member_id
|
||
)
|
||
SELECT cm.assistant_id,
|
||
COALESCE(SUM(ca_agg.balance), 0) AS sv_amount,
|
||
COUNT(DISTINCT CASE WHEN ca_agg.balance > 0 THEN cm.member_id END) AS sv_customer_count,
|
||
COALESCE(SUM(pc.consume_amount), 0) AS sv_consume
|
||
FROM coach_members cm
|
||
LEFT JOIN (
|
||
SELECT tenant_member_id, SUM(balance) AS balance
|
||
FROM app.v_dim_member_card_account
|
||
WHERE scd2_is_current = 1
|
||
GROUP BY tenant_member_id
|
||
) ca_agg ON cm.member_id = ca_agg.tenant_member_id
|
||
LEFT JOIN period_consume pc
|
||
ON cm.member_id = pc.member_id
|
||
GROUP BY cm.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, member_col: str = "member_id") -> tuple[str, tuple]:
|
||
"""
|
||
生成项目筛选 SQL 片段(用于 BOARD-2 会员维度查询)。
|
||
|
||
CHANGE 2026-03-20 | R3 修复:project 参数直接接收 category_code
|
||
(BILLIARD/SNOOKER/MAHJONG/KTV/ALL),去掉 chinese→BILLIARD 映射层。
|
||
CHANGE 2026-04-07 | Fix-1:member_col 参数化,修复 6 个维度别名不匹配导致 SQL 500。
|
||
返回 (sql_fragment, params),sql_fragment 以 AND 开头,可直接拼入 WHERE 子句。
|
||
"""
|
||
_valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
|
||
if project == "ALL" or project not in _valid_categories:
|
||
return "", ()
|
||
clause = f"""
|
||
AND {member_col} 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,)
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-召回维度", description_en="Get customer board recall")
|
||
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 (计算列),
|
||
CHANGE 2026-04-07 | Fix-3:visits_30d 新增字段,替代 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, "wi.member_id")
|
||
|
||
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_30d,
|
||
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 (
|
||
SELECT tenant_member_id, SUM(balance) AS balance
|
||
FROM app.v_dim_member_card_account
|
||
WHERE scd2_is_current = 1
|
||
GROUP BY tenant_member_id
|
||
) ca ON wi.member_id = ca.tenant_member_id
|
||
WHERE 1=1 {proj_clause}
|
||
ORDER BY wi.display_score DESC, wi.member_id
|
||
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 "",
|
||
# CHANGE 2026-03-29 | 天数字段转整数,前端显示不带小数
|
||
"ideal_days": int(row[2]) if row[2] is not None else 0,
|
||
"elapsed_days": int(row[3]) if row[3] is not None else 0,
|
||
"overdue_days": int(row[4]) if row[4] is not None else 0,
|
||
"visits_30d": int(row[5]) if row[5] is not None else 0,
|
||
"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[dict]:
|
||
"""从三维分数派生潜力标签(display_score 为 0-10 区间)。
|
||
返回 [{text, theme}] 格式,与前端 potentialTags 类型一致。
|
||
"""
|
||
tags: list[dict] = []
|
||
threshold = 6.0
|
||
if level_score is not None and float(level_score) >= threshold:
|
||
tags.append({"text": "高消费力", "theme": "success"})
|
||
if speed_score is not None and float(speed_score) >= threshold:
|
||
tags.append({"text": "快增长", "theme": "warning"})
|
||
if stability_score is not None and float(stability_score) >= threshold:
|
||
tags.append({"text": "稳定", "theme": "primary"})
|
||
return tags
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-潜力维度", description_en="Get customer board potential")
|
||
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_member(DQ-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,
|
||
COALESCE(ca_agg.balance, 0) AS balance
|
||
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
|
||
LEFT JOIN (
|
||
SELECT tenant_member_id, SUM(balance) AS balance
|
||
FROM app.v_dim_member_card_account
|
||
WHERE scd2_is_current = 1
|
||
GROUP BY tenant_member_id
|
||
) ca_agg ON spi.member_id = ca_agg.tenant_member_id
|
||
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, COALESCE(ca_agg.balance, 0) DESC, spi.member_id
|
||
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]),
|
||
"balance": float(row[9]) if row[9] is not None else 0.0,
|
||
})
|
||
|
||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-余额维度", description_en="Get customer board balance")
|
||
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, "ca.tenant_member_id")
|
||
|
||
with _fdw_context(conn, site_id) as cur:
|
||
# CHANGE 2026-03-28 | 修复客户重复:dim_member_card_account 同一 member 有多条记录,
|
||
# 先聚合余额再 JOIN,避免行膨胀。排除散客(member_id <= 0)。
|
||
cur.execute(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT ca.tenant_member_id
|
||
FROM app.v_dim_member_card_account ca
|
||
WHERE ca.scd2_is_current = 1
|
||
AND ca.tenant_member_id > 0
|
||
{proj_clause}
|
||
GROUP BY ca.tenant_member_id
|
||
) sub
|
||
""",
|
||
proj_params,
|
||
)
|
||
total = cur.fetchone()[0]
|
||
|
||
offset = (page - 1) * page_size
|
||
cur.execute(
|
||
f"""
|
||
SELECT ca_agg.member_id,
|
||
dm.nickname,
|
||
ca_agg.balance,
|
||
vd.days_since_last,
|
||
vd.consume_amount_60d
|
||
FROM (
|
||
SELECT ca.tenant_member_id AS member_id,
|
||
SUM(ca.balance) AS balance
|
||
FROM app.v_dim_member_card_account ca
|
||
WHERE ca.scd2_is_current = 1
|
||
AND ca.tenant_member_id > 0
|
||
{proj_clause}
|
||
GROUP BY ca.tenant_member_id
|
||
) ca_agg
|
||
LEFT JOIN app.v_dim_member dm
|
||
ON ca_agg.member_id = dm.member_id AND dm.scd2_is_current = 1
|
||
LEFT JOIN LATERAL (
|
||
SELECT cs.days_since_last, cs.consume_amount_60d
|
||
FROM app.v_dws_member_consumption_summary cs
|
||
WHERE cs.member_id = ca_agg.member_id
|
||
ORDER BY cs.stat_date DESC
|
||
LIMIT 1
|
||
) vd ON true
|
||
ORDER BY ca_agg.balance DESC, ca_agg.member_id
|
||
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,
|
||
# CHANGE 2026-03-29 | last_visit 格式化为"X天前",ideal_days 从 winback_index 获取
|
||
"last_visit": f"{row[3]}天前" if row[3] is not None else "--",
|
||
"last_visit_date": row[3],
|
||
"ideal_days": None, # balance 维度无 ideal_days,由 board_service 补充
|
||
# CHANGE 2026-04-07 | Fix-4:consume_amount_60d 是 60 天总额,月均 = /2
|
||
"monthly_consume": float(row[4]) / 2 if row[4] is not None else 0.0,
|
||
"available_months": (
|
||
f"{2 * float(row[2]) / float(row[4]):.1f}个月"
|
||
if row[2] and row[4] and float(row[4]) > 0
|
||
else "--"
|
||
),
|
||
})
|
||
|
||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-充值维度", description_en="Get customer board recharge")
|
||
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, "ro.member_id")
|
||
|
||
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_agg.balance, 0) AS current_balance,
|
||
cs.days_since_last
|
||
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 (
|
||
SELECT tenant_member_id, SUM(balance) AS balance
|
||
FROM app.v_dim_member_card_account
|
||
WHERE scd2_is_current = 1
|
||
GROUP BY tenant_member_id
|
||
) ca_agg ON ro.member_id = ca_agg.tenant_member_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT cs2.days_since_last
|
||
FROM app.v_dws_member_consumption_summary cs2
|
||
WHERE cs2.member_id = ro.member_id
|
||
ORDER BY cs2.stat_date DESC
|
||
LIMIT 1
|
||
) cs ON true
|
||
WHERE 1=1 {proj_clause}
|
||
GROUP BY ro.member_id, dm.nickname, ca_agg.balance, cs.days_since_last
|
||
ORDER BY MAX(ro.pay_time::date) DESC, ro.member_id
|
||
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": str(row[2]) if row[2] else "",
|
||
"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,
|
||
# CHANGE 2026-03-29 | 补充 last_visit 和 ideal_days(头部展示用)
|
||
"last_visit": f"{row[6]}天前" if row[6] is not None else "--",
|
||
"ideal_days": None, # 由 board_service 补充
|
||
})
|
||
|
||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-近期维度", description_en="Get customer board recent")
|
||
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, "vd.member_id")
|
||
|
||
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,
|
||
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '60 days') AS visits_60d,
|
||
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.visits_60d,
|
||
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, ma.member_id
|
||
LIMIT %s OFFSET %s
|
||
""",
|
||
(*proj_params, page_size, offset),
|
||
)
|
||
items = []
|
||
for row in cur.fetchall():
|
||
last_visit = row[2]
|
||
# CHANGE 2026-03-29 | 补充 days_ago(距今天数)和 visits_60d
|
||
from datetime import date as _date
|
||
days_ago = (_date.today() - last_visit).days if last_visit else None
|
||
items.append({
|
||
"member_id": row[0],
|
||
"name": row[1] or "",
|
||
"last_visit_date": str(last_visit) if last_visit else "",
|
||
"days_ago": days_ago,
|
||
"visit_freq": float(row[3]) if row[3] is not None else 0.0,
|
||
"ideal_days": int(row[4]) if row[4] is not None else 0,
|
||
"visits_30d": row[5] or 0,
|
||
"visits_60d": row[6] or 0,
|
||
"avg_spend": float(row[7]) if row[7] is not None else 0.0,
|
||
})
|
||
|
||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-60天消费维度", description_en="Get customer board spend 60 days")
|
||
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 降序。
|
||
CHANGE 2026-04-08 | Fix:consumption_summary 按 stat_date 有多行快照,
|
||
用 DISTINCT ON 取最新快照避免同一客户出现多次。
|
||
"""
|
||
proj_clause, proj_params = _project_filter_clause(project, "cs.member_id")
|
||
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT DISTINCT ON (cs.member_id) cs.member_id
|
||
FROM app.v_dws_member_consumption_summary cs
|
||
WHERE 1=1 {proj_clause}
|
||
ORDER BY cs.member_id, cs.stat_date DESC
|
||
) sub
|
||
""",
|
||
proj_params,
|
||
)
|
||
total = cur.fetchone()[0]
|
||
|
||
offset = (page - 1) * page_size
|
||
cur.execute(
|
||
f"""
|
||
WITH latest_cs AS (
|
||
SELECT DISTINCT ON (cs.member_id)
|
||
cs.member_id, cs.consume_amount_60d,
|
||
cs.visit_count_60d, cs.avg_ticket_amount
|
||
FROM app.v_dws_member_consumption_summary cs
|
||
WHERE 1=1 {proj_clause}
|
||
ORDER BY cs.member_id, cs.stat_date DESC
|
||
)
|
||
SELECT cs.member_id,
|
||
dm.nickname,
|
||
cs.consume_amount_60d,
|
||
cs.visit_count_60d,
|
||
cs.avg_ticket_amount
|
||
FROM latest_cs cs
|
||
LEFT JOIN app.v_dim_member dm
|
||
ON cs.member_id = dm.member_id AND dm.scd2_is_current = 1
|
||
ORDER BY cs.consume_amount_60d DESC, cs.member_id
|
||
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}
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-60天频次维度", description_en="Get customer board frequency 60 days")
|
||
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 降序。
|
||
CHANGE 2026-04-08 | Fix:同 spend60,DISTINCT ON 取最新快照。
|
||
"""
|
||
proj_clause, proj_params = _project_filter_clause(project, "cs.member_id")
|
||
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT DISTINCT ON (cs.member_id) cs.member_id
|
||
FROM app.v_dws_member_consumption_summary cs
|
||
WHERE 1=1 {proj_clause}
|
||
ORDER BY cs.member_id, cs.stat_date DESC
|
||
) sub
|
||
""",
|
||
proj_params,
|
||
)
|
||
total = cur.fetchone()[0]
|
||
|
||
offset = (page - 1) * page_size
|
||
cur.execute(
|
||
f"""
|
||
WITH latest_cs AS (
|
||
SELECT DISTINCT ON (cs.member_id)
|
||
cs.member_id, cs.visit_count_60d, cs.consume_amount_60d
|
||
FROM app.v_dws_member_consumption_summary cs
|
||
WHERE 1=1 {proj_clause}
|
||
ORDER BY cs.member_id, cs.stat_date DESC
|
||
)
|
||
SELECT cs.member_id,
|
||
dm.nickname,
|
||
cs.visit_count_60d,
|
||
cs.consume_amount_60d
|
||
FROM latest_cs cs
|
||
LEFT JOIN app.v_dim_member dm
|
||
ON cs.member_id = dm.member_id AND dm.scd2_is_current = 1
|
||
ORDER BY cs.visit_count_60d DESC, cs.member_id
|
||
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": 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 维度柱状图)。
|
||
|
||
CHANGE 2026-04-07 | Fix-5:数据源从 v_dwd_assistant_service_log 改为
|
||
v_dwd_settlement_head(settle_type IN (1,3)),与汇总维度口径一致。
|
||
返回 {member_id: [{val: int, pct: int}, ...]},固定 8 个元素。
|
||
"""
|
||
cur.execute(
|
||
"""
|
||
WITH weekly AS (
|
||
SELECT member_id,
|
||
DATE_TRUNC('week', pay_time::date) AS week_start,
|
||
COUNT(DISTINCT pay_time::date) AS cnt
|
||
FROM app.v_dwd_settlement_head
|
||
WHERE member_id = ANY(%s)
|
||
AND settle_type IN (1, 3)
|
||
AND pay_time >= CURRENT_DATE - INTERVAL '56 days'
|
||
GROUP BY member_id, DATE_TRUNC('week', pay_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():
|
||
# CHANGE 2026-03-29 | DATE_TRUNC 返回 timestamp,转为 date 字符串匹配
|
||
week_key = row[1].date() if hasattr(row[1], 'date') else row[1]
|
||
raw[row[0]][str(week_key)] = 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
|
||
|
||
|
||
@trace_service(description_zh="获取客户看板-忠诚维度", description_en="Get customer board loyal")
|
||
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, "ri.member_id")
|
||
|
||
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, ri.member_id
|
||
LIMIT %s OFFSET %s
|
||
)
|
||
SELECT mt.member_id,
|
||
dm.nickname,
|
||
mt.max_rs,
|
||
mt.top_assistant_id,
|
||
mt.top_rs,
|
||
COALESCE(da.nickname, da.real_name, '') 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, mt.member_id
|
||
""",
|
||
(*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_score": f"{float(row[4]):.1f}" if row[4] is not None else "0",
|
||
"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}
|
||
|
||
|
||
@trace_service(description_zh="获取客户关联助教", description_en="Get customer assistants")
|
||
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-28 | 助教姓名优先使用昵称/花名,不显示真实姓名
|
||
# 改用 COALESCE(nickname, real_name)
|
||
cur.execute(
|
||
"""
|
||
SELECT ri.member_id,
|
||
COALESCE(da.nickname, da.real_name, '') 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)
|
||
AND (da.leave_status IS NULL OR da.leave_status = 0)
|
||
ORDER BY ri.member_id, ri.rs_display DESC
|
||
""",
|
||
(member_ids,),
|
||
)
|
||
for row in cur.fetchall():
|
||
mid = row[0]
|
||
if mid in result:
|
||
# CHANGE 2026-03-29 | 每个客户最多显示 3 个关系最好的助教
|
||
if len(result[mid]) < 3:
|
||
result[mid].append({
|
||
"name": row[1] or "",
|
||
"cls": "",
|
||
"heart_score": float(row[2]) if row[2] is not None else 0.0,
|
||
})
|
||
|
||
return result
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# BOARD-3 财务看板 FDW 查询(6 板块)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@trace_service(description_zh="获取财务概览", description_en="Get finance overview")
|
||
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,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取财务充值数据", description_en="Get finance recharge")
|
||
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 + app.v_dws_finance_daily_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
|
||
CHANGE 2026-03-27 | board-finance-integration T2.1 | 卡余额快照值从 SUM 改为取最后一天;
|
||
新增 consumed 从 v_dws_finance_daily_summary.card_consume_total SUM 聚合
|
||
"""
|
||
def _f(v):
|
||
return float(v) if v is not None else 0.0
|
||
|
||
with _fdw_context(conn, site_id) as cur:
|
||
# CHANGE 2026-03-27 | board-finance-integration T2.1 | 查询 1:流量值 SUM 聚合(充值相关)
|
||
cur.execute(
|
||
"""
|
||
SELECT SUM(recharge_cash) AS actual_income,
|
||
SUM(first_recharge_cash) AS first_charge,
|
||
SUM(renewal_cash) AS renew_charge,
|
||
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),
|
||
)
|
||
flow_row = cur.fetchone()
|
||
|
||
# CHANGE 2026-03-27 | board-finance-integration T2.1 | 查询 2:快照值取时间范围内最后一天
|
||
cur.execute(
|
||
"""
|
||
SELECT cash_card_balance,
|
||
total_card_balance,
|
||
gift_card_balance,
|
||
gift_liquor_balance,
|
||
gift_table_fee_balance,
|
||
gift_voucher_balance
|
||
FROM app.v_dws_finance_recharge_summary
|
||
WHERE stat_date = (
|
||
SELECT MAX(stat_date)
|
||
FROM app.v_dws_finance_recharge_summary
|
||
WHERE stat_date >= %s::date AND stat_date <= %s::date
|
||
)
|
||
""",
|
||
(start_date, end_date),
|
||
)
|
||
snap_row = cur.fetchone()
|
||
|
||
# CHANGE 2026-03-27 | board-finance-integration T2.1 | 查询 3:consumed 从财务日报 SUM
|
||
cur.execute(
|
||
"""
|
||
SELECT COALESCE(SUM(card_consume_total), 0) AS consumed
|
||
FROM app.v_dws_finance_daily_summary
|
||
WHERE stat_date >= %s::date AND stat_date <= %s::date
|
||
""",
|
||
(start_date, end_date),
|
||
)
|
||
consume_row = cur.fetchone()
|
||
|
||
if not flow_row or flow_row[0] is None:
|
||
return _empty_recharge_data()
|
||
|
||
# 流量值
|
||
actual_income = _f(flow_row[0])
|
||
first_charge = _f(flow_row[1])
|
||
renew_charge = _f(flow_row[2])
|
||
gift_liquor_recharge = _f(flow_row[3])
|
||
gift_table_fee_recharge = _f(flow_row[4])
|
||
gift_voucher_recharge = _f(flow_row[5])
|
||
|
||
# 快照值(最后一天)
|
||
card_balance = _f(snap_row[0]) if snap_row else 0.0
|
||
all_card_balance = _f(snap_row[1]) if snap_row else 0.0
|
||
gift_balance = _f(snap_row[2]) if snap_row else 0.0
|
||
gift_liquor_balance = _f(snap_row[3]) if snap_row else 0.0
|
||
gift_table_fee_balance = _f(snap_row[4]) if snap_row else 0.0
|
||
gift_voucher_balance = _f(snap_row[5]) if snap_row else 0.0
|
||
|
||
# consumed
|
||
consumed = _f(consume_row[0]) if consume_row else 0.0
|
||
|
||
# CHANGE 2026-03-20 | gift_rows 每个 cell 必须是 GiftCell dict({"value": float}),
|
||
# 不能是裸 float,否则 Pydantic ResponseValidationError
|
||
_gc = lambda v: {"value": v}
|
||
return {
|
||
"actual_income": actual_income,
|
||
"first_charge": first_charge,
|
||
"renew_charge": renew_charge,
|
||
"consumed": consumed,
|
||
"card_balance": card_balance,
|
||
"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": all_card_balance,
|
||
}
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取财务收入数据", description_en="Get finance revenue")
|
||
def get_finance_revenue(
|
||
conn: Any, site_id: int, start_date: str, end_date: str, area: str = "all"
|
||
) -> dict:
|
||
"""
|
||
BOARD-3 应计收入:收入结构 + 价格构成 + 优惠明细 + 渠道分布。
|
||
|
||
CHANGE 2026-03-27 | board-finance-phase2 T1+T2 | 收入结构改为从 dwd_settlement_head
|
||
按物理区域聚合;优惠减扣按 Demo 4 项重组(从 v_dws_finance_daily_summary discount_* 字段)。
|
||
原因:原 v_dws_finance_income_structure 分类与 Demo 不一致,需按物理区域展示子行。
|
||
⚠️ DWD-DOC 规则 2: 助教行使用 assistant_pd_money(陪打)+ assistant_cx_money(超休)。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
# AreaFilterEnum value -> physical area label mapping
|
||
# CHANGE 2026-03-28 | Bug3 fix: add area filter to revenue SQL
|
||
_AREA_LABEL_MAP = {
|
||
"hall": ["A区", "B区", "C区", "台球包厢", "斯诺克", "麻将区", "团建区"],
|
||
"hallA": ["A区"],
|
||
"hallB": ["B区"],
|
||
"hallC": ["C区"],
|
||
"vip": ["台球包厢"],
|
||
"snooker": ["斯诺克"],
|
||
"mahjong": ["麻将区"],
|
||
"ktv": ["团建区"],
|
||
}
|
||
area_filter_labels = _AREA_LABEL_MAP.get(area, None) # None means "all"
|
||
|
||
# T1: revenue structure from dwd_settlement_head aggregated by physical area
|
||
area_where = ""
|
||
area_params: list = [start_date, end_date]
|
||
if area_filter_labels:
|
||
area_where = "AND area_label = ANY(%s)"
|
||
area_params.append(area_filter_labels)
|
||
|
||
cur.execute(
|
||
f"""
|
||
SELECT area_label,
|
||
SUM(table_charge_money) AS table_charge,
|
||
SUM(goods_money) AS goods,
|
||
SUM(assistant_pd_money) AS pd,
|
||
SUM(assistant_cx_money) AS cx
|
||
FROM (
|
||
SELECT CASE t.site_table_area_name
|
||
WHEN 'A区' THEN 'A区'
|
||
WHEN 'B区' THEN 'B区'
|
||
WHEN 'C区' THEN 'C区' WHEN 'TV台' THEN 'C区' WHEN '美洲豹赛台' THEN 'C区'
|
||
WHEN 'VIP包厢' THEN '台球包厢'
|
||
WHEN '斯诺克区' THEN '斯诺克'
|
||
WHEN '麻将房' THEN '麻将区' WHEN 'M7' THEN '麻将区' WHEN 'M8' THEN '麻将区'
|
||
WHEN '666' THEN '麻将区' WHEN '发财' THEN '麻将区'
|
||
WHEN 'K包' THEN '团建区' WHEN 'k包活动区' THEN '团建区' WHEN '幸会158' THEN '团建区'
|
||
ELSE NULL
|
||
END AS area_label,
|
||
h.table_charge_money, h.goods_money,
|
||
h.assistant_pd_money, h.assistant_cx_money
|
||
FROM app.v_dwd_settlement_head h
|
||
LEFT JOIN app.v_dim_table t ON h.table_id = t.table_id AND t.scd2_is_current = 1
|
||
WHERE h.settle_type IN (1, 3)
|
||
AND (h.create_time AT TIME ZONE 'Asia/Shanghai')::date >= %s
|
||
AND (h.create_time AT TIME ZONE 'Asia/Shanghai')::date <= %s
|
||
) sub
|
||
WHERE area_label IS NOT NULL
|
||
{area_where}
|
||
GROUP BY area_label
|
||
ORDER BY CASE area_label
|
||
WHEN 'A区' THEN 1 WHEN 'B区' THEN 2 WHEN 'C区' THEN 3
|
||
WHEN '台球包厢' THEN 4 WHEN '斯诺克' THEN 5 WHEN '麻将区' THEN 6 WHEN '团建区' THEN 7
|
||
ELSE 8
|
||
END
|
||
""",
|
||
area_params,
|
||
)
|
||
area_rows = cur.fetchall()
|
||
|
||
# 聚合各区域数据
|
||
total_table_charge = 0.0
|
||
total_goods = 0.0
|
||
total_pd = 0.0
|
||
total_cx = 0.0
|
||
area_sub_rows = []
|
||
for row in area_rows:
|
||
tc = float(row[1]) if row[1] is not None else 0.0
|
||
gd = float(row[2]) if row[2] is not None else 0.0
|
||
pd = float(row[3]) if row[3] is not None else 0.0
|
||
cx = float(row[4]) if row[4] is not None else 0.0
|
||
total_table_charge += tc
|
||
total_goods += gd
|
||
total_pd += pd
|
||
total_cx += cx
|
||
area_sub_rows.append({"name": row[0], "amount": tc})
|
||
|
||
# 构建 structure_rows(与前端 _loadData 映射一致)
|
||
structure_rows = []
|
||
# "开台与包厢" 主行
|
||
structure_rows.append({
|
||
"id": "table_charge", "name": "开台与包厢", "desc": None,
|
||
"is_sub": False, "amount": total_table_charge,
|
||
"discount": 0.0, "booked": total_table_charge,
|
||
})
|
||
# 各区域子行
|
||
for sub in area_sub_rows:
|
||
structure_rows.append({
|
||
"id": f"area_{sub['name']}", "name": sub["name"], "desc": None,
|
||
"is_sub": True, "amount": sub["amount"],
|
||
"discount": 0.0, "booked": sub["amount"],
|
||
})
|
||
# "助教 基础课" 行
|
||
structure_rows.append({
|
||
"id": "assistant_pd", "name": "助教 基础课", "desc": None,
|
||
"is_sub": False, "amount": total_pd,
|
||
"discount": 0.0, "booked": total_pd,
|
||
})
|
||
# "助教 激励课" 行
|
||
structure_rows.append({
|
||
"id": "assistant_cx", "name": "助教 激励课", "desc": None,
|
||
"is_sub": False, "amount": total_cx,
|
||
"discount": 0.0, "booked": total_cx,
|
||
})
|
||
# "食品酒水" 行
|
||
structure_rows.append({
|
||
"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},
|
||
]
|
||
total_income = total_table_charge + total_goods + total_pd + total_cx
|
||
|
||
# ── T2: 优惠减扣(从 v_dws_finance_daily_summary discount_* 字段聚合)──
|
||
cur.execute(
|
||
"""
|
||
SELECT COALESCE(SUM(discount_groupbuy), 0) AS groupbuy,
|
||
COALESCE(SUM(discount_vip), 0) AS vip,
|
||
COALESCE(SUM(discount_manual), 0) + COALESCE(SUM(discount_other), 0) AS manual_adjust,
|
||
COALESCE(SUM(discount_gift_card), 0) AS gift_card,
|
||
COALESCE(SUM(discount_rounding), 0) AS rounding
|
||
FROM app.v_dws_finance_daily_summary
|
||
WHERE stat_date >= %s AND stat_date <= %s
|
||
""",
|
||
(start_date, end_date),
|
||
)
|
||
dr = cur.fetchone()
|
||
groupbuy_d = float(dr[0]) if dr and dr[0] is not None else 0.0
|
||
vip_d = float(dr[1]) if dr and dr[1] is not None else 0.0
|
||
manual_d = float(dr[2]) if dr and dr[2] is not None else 0.0
|
||
gift_card_d = float(dr[3]) if dr and dr[3] is not None else 0.0
|
||
rounding_d = float(dr[4]) if dr and dr[4] is not None else 0.0
|
||
|
||
discount_items = [
|
||
{"label": "团购优惠", "amount": groupbuy_d},
|
||
{"label": "会员折扣", "amount": vip_d},
|
||
{"label": "手动调整", "amount": manual_d},
|
||
{"label": "赠送卡抵扣", "desc": "台桌卡+酒水卡+抵用券", "amount": gift_card_d},
|
||
{"label": "其他优惠", "desc": "免单+抹零", "amount": rounding_d},
|
||
]
|
||
total_discount = groupbuy_d + vip_d + manual_d + gift_card_d + rounding_d
|
||
|
||
# 回填收入结构表的优惠分摊
|
||
# CHANGE 2026-03-28 | 按区域 table_charge_money 占比分摊 DWS discount_total
|
||
if total_table_charge > 0 and total_discount > 0:
|
||
for row in structure_rows:
|
||
if row["is_sub"]:
|
||
# 区域子行:按占比分摊
|
||
ratio = row["amount"] / total_table_charge
|
||
row["discount"] = round(total_discount * ratio, 2)
|
||
row["booked"] = row["amount"] - row["discount"]
|
||
elif row["id"] == "table_charge":
|
||
# "开台与包厢"主行
|
||
row["discount"] = total_discount
|
||
row["booked"] = total_table_charge - total_discount
|
||
|
||
# ── 渠道分布(从 v_dws_finance_daily_summary 聚合,label 对齐 Demo)──
|
||
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_date, end_date),
|
||
)
|
||
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
|
||
|
||
channel_items = [
|
||
{"label": "储值卡结算冲销", "amount": cash_card + gift_card_consume},
|
||
{"label": "现金/线上支付", "amount": cash_pay},
|
||
{"label": "团购核销确认收入", "desc": "团购成交价", "amount": groupbuy_pay},
|
||
]
|
||
|
||
confirmed_total = total_income - abs(total_discount)
|
||
return {
|
||
"structure_rows": structure_rows,
|
||
"price_items": price_items,
|
||
"total_occurrence": total_income,
|
||
"discount_items": discount_items,
|
||
# CHANGE 2026-03-27 | board-finance-phase2 T3 | 优惠总计供前端展示
|
||
"discount_total": total_discount,
|
||
"confirmed_total": confirmed_total,
|
||
"channel_items": channel_items,
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取财务现金流", description_en="Get finance cashflow")
|
||
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 (充值现金流入)
|
||
CHANGE 2026-03-27 | board-finance-integration T2.2 | 移除"储值卡消费"(非现金流入),
|
||
新增"纸币现金"和"扫码收款"拆分(依赖 T1.1 新增字段 cash_paper_amount/scan_pay_amount)
|
||
⚠️ DWD-DOC 规则 7: platform_settlement_amount 和 groupbuy_pay_amount 互斥。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
# CHANGE 2026-03-27 | board-finance-integration T2.2 | SQL 拆分现金收款为纸币+扫码
|
||
# CHANGE 2026-07-22 | Prompt: 财务看板多问题修复 | 直接原因: ETL 未重跑时 cash_paper/scan_pay 为 0,需回退用 cash_pay_amount 合并
|
||
cur.execute(
|
||
"""
|
||
SELECT SUM(cash_pay_amount) AS cash_pay,
|
||
COALESCE(SUM(cash_paper_amount), 0) AS cash_paper,
|
||
COALESCE(SUM(scan_pay_amount), 0) AS scan_pay,
|
||
SUM(groupbuy_pay_amount) AS consume_groupbuy,
|
||
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[5] is None:
|
||
return {
|
||
"consume_items": [
|
||
{"label": "现金/线上收款", "amount": 0.0},
|
||
{"label": "团购平台回款", "amount": 0.0},
|
||
],
|
||
"recharge_items": [{"label": "会员充值到账", "amount": 0.0}],
|
||
"total": 0.0,
|
||
}
|
||
|
||
# CHANGE 2026-07-22 | Prompt: 财务看板多问题修复 | 直接原因: ETL 未重跑时 cash_paper+scan_pay=0,回退为合并项
|
||
cash_pay = _f(row[0])
|
||
cash_paper = _f(row[1])
|
||
scan_pay = _f(row[2])
|
||
groupbuy = _f(row[3])
|
||
|
||
# CHANGE 2026-03-27 | board-finance-phase2 T4 | 现金流入项名和描述对齐 Demo
|
||
if cash_paper + scan_pay > 0:
|
||
# ETL 已重跑,拆分为"纸币现金"和"线上收款"
|
||
consume_items = [
|
||
{"label": "纸币现金", "desc": "柜台现金收款", "amount": cash_paper},
|
||
{"label": "线上收款", "desc": "微信/支付宝/刷卡", "amount": scan_pay},
|
||
{"label": "团购平台", "desc": "美团/抖音回款", "amount": groupbuy},
|
||
]
|
||
else:
|
||
# ETL 未重跑,合并为一项"现金/线上收款"(降级)
|
||
consume_items = [
|
||
{"label": "现金/线上收款", "amount": cash_pay},
|
||
{"label": "团购平台", "desc": "美团/抖音回款", "amount": groupbuy},
|
||
]
|
||
|
||
return {
|
||
"consume_items": consume_items,
|
||
"recharge_items": [{"label": "会员充值到账", "desc": "首充/续费实收", "amount": _f(row[4])}],
|
||
"total": _f(row[5]),
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取财务支出数据", description_en="Get finance expense")
|
||
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)
|
||
CHANGE 2026-03-27 | board-finance-phase2 T5 | 现金流出固定项名对齐 Demo,空数据时显示固定结构
|
||
⚠️ 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
|
||
|
||
# CHANGE 2026-03-27 | board-finance-phase2 T5 | 空数据时填充固定项名对齐 Demo
|
||
if not groups["coach"]:
|
||
groups["coach"] = [
|
||
{"label": "基础课分成", "amount": 0.0},
|
||
{"label": "激励课分成", "amount": 0.0},
|
||
{"label": "充值提成", "amount": 0.0},
|
||
{"label": "额外奖金", "amount": 0.0},
|
||
]
|
||
if not groups["platform"]:
|
||
groups["platform"] = [
|
||
{"label": "汇来米", "amount": 0.0},
|
||
{"label": "美团", "amount": 0.0},
|
||
{"label": "抖音", "amount": 0.0},
|
||
]
|
||
if not groups["operation"]:
|
||
groups["operation"] = [
|
||
{"label": "食品饮料", "amount": 0.0},
|
||
{"label": "耗材", "amount": 0.0},
|
||
{"label": "报销", "amount": 0.0},
|
||
]
|
||
if not groups["fixed"]:
|
||
groups["fixed"] = [
|
||
{"label": "房租", "amount": 0.0},
|
||
{"label": "水电", "amount": 0.0},
|
||
{"label": "物业", "amount": 0.0},
|
||
{"label": "人员工资", "amount": 0.0},
|
||
]
|
||
|
||
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 分组聚合。
|
||
|
||
三列全部从 DWS dws_assistant_salary_calc 计算:
|
||
- pay = SUM(hours × course_price)
|
||
- share = SUM(hours × deduction)
|
||
- hourly = share / hours(加权平均抽成单价)
|
||
|
||
CHANGE 2026-03-28 | board-finance-phase2 bugfix | 改回纯 DWS 方案。
|
||
禁止从 DWD ledger_amount JOIN DWS 等级——DWS 同一助教同月可有多等级记录
|
||
(月中升级),level_map JOIN service_log 会导致行膨胀。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT assistant_level_name AS level,
|
||
SUM(base_hours * base_course_price) AS base_pay,
|
||
SUM(base_hours * base_deduction) AS base_share,
|
||
SUM(base_hours) AS base_hours,
|
||
SUM(bonus_hours * bonus_course_price) AS bonus_pay,
|
||
SUM(bonus_hours * bonus_course_price * bonus_deduction_ratio) AS bonus_share,
|
||
SUM(bonus_hours) AS bonus_hours
|
||
FROM app.v_dws_assistant_salary_calc
|
||
WHERE salary_month >= %s::date AND salary_month <= %s::date
|
||
GROUP BY assistant_level_name
|
||
ORDER BY CASE assistant_level_name
|
||
WHEN '初级' THEN 1 WHEN '中级' THEN 2
|
||
WHEN '高级' THEN 3 WHEN '星级' THEN 4 ELSE 5
|
||
END
|
||
""",
|
||
(start_date, end_date),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
basic_rows = []
|
||
incentive_rows = []
|
||
total_base_pay = 0.0
|
||
total_base_share = 0.0
|
||
total_base_hours = 0.0
|
||
total_bonus_pay = 0.0
|
||
total_bonus_share = 0.0
|
||
total_bonus_hours = 0.0
|
||
|
||
for row in rows:
|
||
level = row[0] or ""
|
||
base_pay = float(row[1]) if row[1] is not None else 0.0
|
||
base_share = float(row[2]) if row[2] is not None else 0.0
|
||
base_h = float(row[3]) if row[3] is not None else 0.0
|
||
bonus_pay = float(row[4]) if row[4] is not None else 0.0
|
||
bonus_share = float(row[5]) if row[5] is not None else 0.0
|
||
bonus_h = float(row[6]) if row[6] is not None else 0.0
|
||
|
||
total_base_pay += base_pay
|
||
total_base_share += base_share
|
||
total_base_hours += base_h
|
||
total_bonus_pay += bonus_pay
|
||
total_bonus_share += bonus_share
|
||
total_bonus_hours += bonus_h
|
||
|
||
base_hourly = base_share / base_h if base_h > 0 else 0.0
|
||
basic_rows.append({
|
||
"level": level, "pay": base_pay,
|
||
"share": base_share, "hourly": round(base_hourly, 2),
|
||
})
|
||
bonus_hourly = bonus_share / bonus_h if bonus_h > 0 else 0.0
|
||
incentive_rows.append({
|
||
"level": level, "pay": bonus_pay,
|
||
"share": bonus_share, "hourly": round(bonus_hourly, 2),
|
||
})
|
||
|
||
avg_base_hourly = round(total_base_share / total_base_hours, 2) if total_base_hours > 0 else 0.0
|
||
avg_bonus_hourly = round(total_bonus_share / total_bonus_hours, 2) if total_bonus_hours > 0 else 0.0
|
||
|
||
return {
|
||
"basic": {
|
||
"total_pay": total_base_pay,
|
||
"total_share": total_base_share,
|
||
"avg_hourly": avg_base_hourly,
|
||
"rows": basic_rows,
|
||
},
|
||
"incentive": {
|
||
"total_pay": total_bonus_pay,
|
||
"total_share": total_bonus_share,
|
||
"avg_hourly": avg_bonus_hourly,
|
||
"rows": incentive_rows,
|
||
},
|
||
}
|
||
|
||
|
||
|
||
def get_finance_coach_analysis_area(
|
||
conn: Any, site_id: int, start_date: str, end_date: str, area_code: str
|
||
) -> dict:
|
||
"""
|
||
BOARD-3 助教分析(区域过滤版):area≠all 时从 dws_coach_area_hours
|
||
JOIN dws_assistant_salary_calc 按区域聚合。
|
||
|
||
返回结构与 get_finance_coach_analysis 完全一致(basic/incentive 两表)。
|
||
|
||
CHANGE 2026-03-29 | Prompt: 助教分析按区域细化 | 新增区域过滤查询
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
sc.assistant_level_name AS level,
|
||
SUM(cah.base_hours * sc.base_course_price) AS base_pay,
|
||
SUM(cah.base_hours * sc.base_deduction) AS base_share,
|
||
SUM(cah.base_hours) AS base_hours,
|
||
SUM(cah.bonus_hours * sc.bonus_course_price) AS bonus_pay,
|
||
SUM(cah.bonus_hours * sc.bonus_course_price * sc.bonus_deduction_ratio) AS bonus_share,
|
||
SUM(cah.bonus_hours) AS bonus_hours
|
||
FROM app.v_dws_coach_area_hours cah
|
||
JOIN app.v_dws_assistant_salary_calc sc
|
||
ON cah.assistant_id = sc.assistant_id
|
||
AND cah.stat_month = sc.salary_month
|
||
WHERE cah.area_code = %s
|
||
AND cah.stat_month >= %s::date
|
||
AND cah.stat_month <= %s::date
|
||
GROUP BY sc.assistant_level_name
|
||
ORDER BY CASE sc.assistant_level_name
|
||
WHEN '初级' THEN 1 WHEN '中级' THEN 2
|
||
WHEN '高级' THEN 3 WHEN '星级' THEN 4 ELSE 5
|
||
END
|
||
""",
|
||
(area_code, start_date, end_date),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
# 与 get_finance_coach_analysis 完全相同的后处理逻辑
|
||
basic_rows = []
|
||
incentive_rows = []
|
||
total_base_pay = 0.0
|
||
total_base_share = 0.0
|
||
total_base_hours = 0.0
|
||
total_bonus_pay = 0.0
|
||
total_bonus_share = 0.0
|
||
total_bonus_hours = 0.0
|
||
|
||
for row in rows:
|
||
level = row[0] or ""
|
||
base_pay = float(row[1]) if row[1] is not None else 0.0
|
||
base_share = float(row[2]) if row[2] is not None else 0.0
|
||
base_h = float(row[3]) if row[3] is not None else 0.0
|
||
bonus_pay = float(row[4]) if row[4] is not None else 0.0
|
||
bonus_share = float(row[5]) if row[5] is not None else 0.0
|
||
bonus_h = float(row[6]) if row[6] is not None else 0.0
|
||
|
||
total_base_pay += base_pay
|
||
total_base_share += base_share
|
||
total_base_hours += base_h
|
||
total_bonus_pay += bonus_pay
|
||
total_bonus_share += bonus_share
|
||
total_bonus_hours += bonus_h
|
||
|
||
base_hourly = base_share / base_h if base_h > 0 else 0.0
|
||
basic_rows.append({
|
||
"level": level, "pay": base_pay,
|
||
"share": base_share, "hourly": round(base_hourly, 2),
|
||
})
|
||
bonus_hourly = bonus_share / bonus_h if bonus_h > 0 else 0.0
|
||
incentive_rows.append({
|
||
"level": level, "pay": bonus_pay,
|
||
"share": bonus_share, "hourly": round(bonus_hourly, 2),
|
||
})
|
||
|
||
avg_base_hourly = round(total_base_share / total_base_hours, 2) if total_base_hours > 0 else 0.0
|
||
avg_bonus_hourly = round(total_bonus_share / total_bonus_hours, 2) if total_bonus_hours > 0 else 0.0
|
||
|
||
return {
|
||
"basic": {
|
||
"total_pay": total_base_pay,
|
||
"total_share": total_base_share,
|
||
"avg_hourly": avg_base_hourly,
|
||
"rows": basic_rows,
|
||
},
|
||
"incentive": {
|
||
"total_pay": total_bonus_pay,
|
||
"total_share": total_bonus_share,
|
||
"avg_hourly": avg_bonus_hourly,
|
||
"rows": incentive_rows,
|
||
},
|
||
}
|
||
|
||
|
||
@trace_service(description_zh="获取项目类型配置", description_en="Get skill types")
|
||
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
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# P17:助教客户归属与任务生成引擎 — ETL 查询方法
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
@trace_service(
|
||
description_zh="获取有归属数据的门店列表",
|
||
description_en="Get active site IDs from relation index",
|
||
)
|
||
def get_active_site_ids(conn: Any) -> list[int]:
|
||
"""
|
||
从 ETL 关系指数表获取所有有 session_count > 0 的 distinct site_id。
|
||
|
||
CHANGE 2026-03-29 | 替代 auth.user_assistant_binding 作为 task_generator 的门店入口,
|
||
确保未绑定小程序的门店也能生成任务数据。
|
||
|
||
直接查询 dws schema(非 RLS 视图),不需要 site_id 参数。
|
||
"""
|
||
from app.database import get_etl_readonly_connection
|
||
|
||
# site_id=0 仅用于获取连接,不设置 RLS(查询 dws schema 不受 RLS 限制)
|
||
etl_conn = get_etl_readonly_connection(0)
|
||
try:
|
||
with etl_conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT DISTINCT site_id
|
||
FROM dws.dws_member_assistant_relation_index
|
||
WHERE session_count > 0
|
||
"""
|
||
)
|
||
return [row[0] for row in cur.fetchall()]
|
||
finally:
|
||
etl_conn.close()
|
||
|
||
|
||
@trace_service(
|
||
description_zh="获取归属对(OS)",
|
||
description_en="Get ownership pairs",
|
||
)
|
||
def get_ownership_pairs(
|
||
conn: Any, site_id: int
|
||
) -> list[dict]:
|
||
"""
|
||
P17 Step 1: 查询 os_label ∈ {MAIN, COMANAGE} 的所有 (assistant_id, member_id) 对。
|
||
|
||
来源: app.v_dws_member_assistant_relation_index(RLS 视图,按 site_id 隔离)
|
||
返回: [{"assistant_id", "member_id", "os_label", "rs", "ms", "ml"}]
|
||
|
||
⚠️ 这是 P17 的核心入口查询,取代旧的 auth.user_assistant_binding 入口。
|
||
只返回 MAIN/COMANAGE 对,POOL/UNASSIGNED 不在常规任务生成范围内。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT assistant_id,
|
||
member_id,
|
||
os_label,
|
||
COALESCE(rs_display, 0) AS rs,
|
||
COALESCE(ms_display, 0) AS ms,
|
||
COALESCE(ml_display, 0) AS ml
|
||
FROM app.v_dws_member_assistant_relation_index
|
||
WHERE os_label IN ('MAIN', 'COMANAGE')
|
||
"""
|
||
)
|
||
return [
|
||
{
|
||
"assistant_id": row[0],
|
||
"member_id": row[1],
|
||
"os_label": row[2],
|
||
"rs": Decimal(str(row[3])),
|
||
"ms": Decimal(str(row[4])),
|
||
"ml": Decimal(str(row[5])),
|
||
}
|
||
for row in cur.fetchall()
|
||
]
|
||
|
||
|
||
@trace_service(
|
||
description_zh="获取 POOL 助教候选",
|
||
description_en="Get pool assistants for transfer",
|
||
)
|
||
def get_pool_assistants(
|
||
conn: Any, site_id: int, member_id: int
|
||
) -> list[dict]:
|
||
"""
|
||
P17 转移子流程: 查询某客户的 POOL 助教候选池。
|
||
|
||
来源: app.v_dws_member_assistant_relation_index
|
||
返回: [{"assistant_id", "rs", "ms", "ml"}]
|
||
|
||
⚠️ 保护 3(服务关系门槛)在此处实施:只返回 os_label='POOL' 的助教,
|
||
UNASSIGNED 永远不参与转移候选。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT assistant_id,
|
||
COALESCE(rs_display, 0) AS rs,
|
||
COALESCE(ms_display, 0) AS ms,
|
||
COALESCE(ml_display, 0) AS ml
|
||
FROM app.v_dws_member_assistant_relation_index
|
||
WHERE member_id = %s
|
||
AND os_label = 'POOL'
|
||
""",
|
||
(member_id,),
|
||
)
|
||
return [
|
||
{
|
||
"assistant_id": row[0],
|
||
"rs": Decimal(str(row[1])),
|
||
"ms": Decimal(str(row[2])),
|
||
"ml": Decimal(str(row[3])),
|
||
}
|
||
for row in cur.fetchall()
|
||
]
|
||
|
||
|
||
@trace_service(
|
||
description_zh="批量获取 WBI",
|
||
description_en="Batch fetch WBI scores",
|
||
)
|
||
def get_wbi_batch(
|
||
conn: Any, site_id: int
|
||
) -> dict[int, Decimal]:
|
||
"""
|
||
P17 Step 2: 批量读取全店 WBI(流失回赢指数)。
|
||
|
||
来源: app.v_dws_member_winback_index(RLS 视图)
|
||
返回: {member_id: display_score}
|
||
|
||
⚠️ 全表扫描无 WHERE(除 RLS 隔离),与旧 _process_assistant() 行为一致。
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT member_id, COALESCE(display_score, 0)
|
||
FROM app.v_dws_member_winback_index
|
||
"""
|
||
)
|
||
return {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||
|
||
|
||
@trace_service(
|
||
description_zh="批量获取 NCI",
|
||
description_en="Batch fetch NCI scores",
|
||
)
|
||
def get_nci_batch(
|
||
conn: Any, site_id: int
|
||
) -> dict[int, Decimal]:
|
||
"""
|
||
P17 Step 2: 批量读取全店 NCI(新客转化指数)。
|
||
|
||
来源: app.v_dws_member_newconv_index(RLS 视图)
|
||
返回: {member_id: display_score}
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT member_id, COALESCE(display_score, 0)
|
||
FROM app.v_dws_member_newconv_index
|
||
"""
|
||
)
|
||
return {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||
|
||
|
||
@trace_service(
|
||
description_zh="批量获取理想到店周期",
|
||
description_en="Batch fetch ideal interval days",
|
||
)
|
||
def get_ideal_interval_batch(
|
||
conn: Any, site_id: int
|
||
) -> dict[int, float]:
|
||
"""
|
||
批量读取全店客户的理想到店周期(天)。
|
||
|
||
来源: app.v_dws_member_winback_index(RLS 视图)
|
||
返回: {member_id: ideal_interval_days}(仅包含非 NULL 的记录)
|
||
|
||
CHANGE 2026-03-29 | OS 分级分配:用于计算升级倍数
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT member_id, ideal_interval_days
|
||
FROM app.v_dws_member_winback_index
|
||
WHERE ideal_interval_days IS NOT NULL
|
||
AND ideal_interval_days > 0
|
||
"""
|
||
)
|
||
return {row[0]: float(row[1]) for row in cur.fetchall()}
|
||
|
||
|
||
@trace_service(
|
||
description_zh="获取全量服务关系对",
|
||
description_en="Get all service relation pairs",
|
||
)
|
||
def get_all_service_pairs(
|
||
conn: Any, site_id: int
|
||
) -> list[dict]:
|
||
"""
|
||
查询 session_count > 0 的全量 (assistant_id, member_id) 对。
|
||
|
||
用于 relationship_building 保底任务生成:只要助教与客户确切发生过服务关系,
|
||
就生成一条 relationship_building 任务(不限 os_label)。
|
||
|
||
来源: app.v_dws_member_assistant_relation_index(RLS 视图,按 site_id 隔离)
|
||
返回: [{"assistant_id", "member_id", "rs"}]
|
||
"""
|
||
with _fdw_context(conn, site_id) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT assistant_id,
|
||
member_id,
|
||
COALESCE(rs_display, 0) AS rs
|
||
FROM app.v_dws_member_assistant_relation_index
|
||
WHERE session_count > 0
|
||
"""
|
||
)
|
||
return [
|
||
{
|
||
"assistant_id": row[0],
|
||
"member_id": row[1],
|
||
"rs": Decimal(str(row[2])),
|
||
}
|
||
for row in cur.fetchall()
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 区域财务看板查询(board-finance-dws-area-refactor)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@trace_service(
|
||
description_zh="获取区域财务概览", description_en="Get finance overview by area"
|
||
)
|
||
def get_finance_overview_area(
|
||
conn: Any,
|
||
site_id: int,
|
||
start_date: str,
|
||
end_date: str,
|
||
area_code: str = "all",
|
||
*,
|
||
etl_conn: Any = None,
|
||
) -> dict:
|
||
"""
|
||
从 v_dws_finance_area_daily 按 area_code 聚合 overview 8 项指标。
|
||
|
||
与 get_finance_overview 返回结构完全一致,但数据来源改为区域日粒度表,
|
||
支持按 area_code 过滤。
|
||
|
||
Requirements: 6.1, 6.2, 6.5
|
||
"""
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) 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_area_daily
|
||
WHERE area_code = %s
|
||
AND stat_date >= %s::date
|
||
AND stat_date <= %s::date
|
||
""",
|
||
(area_code, start_date, end_date),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
_zero = {
|
||
"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,
|
||
}
|
||
if not row or row[0] is None:
|
||
return _zero
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
@trace_service(
|
||
description_zh="获取区域收入数据", description_en="Get finance revenue by area"
|
||
)
|
||
def get_finance_revenue_area(
|
||
conn: Any,
|
||
site_id: int,
|
||
start_date: str,
|
||
end_date: str,
|
||
area_code: str = "all",
|
||
*,
|
||
etl_conn: Any = None,
|
||
) -> dict:
|
||
"""
|
||
从 v_dws_finance_area_daily 按 area_code 聚合 revenue 板块数据。
|
||
|
||
返回收入结构 4 项、优惠拆分 6 项、confirmed_total、order_count。
|
||
优惠拆分恒等式:discount_total = 6 项之和。
|
||
|
||
Requirements: 6.5, 6.6
|
||
"""
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT SUM(gross_amount) AS total_occurrence,
|
||
SUM(table_fee_amount) AS table_fee_amount,
|
||
SUM(goods_amount) AS goods_amount,
|
||
SUM(assistant_pd_amount) AS assistant_pd_amount,
|
||
SUM(assistant_cx_amount) AS assistant_cx_amount,
|
||
SUM(discount_total) AS discount_total,
|
||
SUM(discount_groupbuy) AS discount_groupbuy,
|
||
SUM(discount_vip) AS discount_vip,
|
||
SUM(discount_manual) AS discount_manual,
|
||
SUM(discount_gift_card) AS discount_gift_card,
|
||
SUM(discount_rounding) AS discount_rounding,
|
||
SUM(discount_other) AS discount_other,
|
||
SUM(confirmed_income) AS confirmed_total,
|
||
SUM(order_count) AS order_count
|
||
FROM app.v_dws_finance_area_daily
|
||
WHERE area_code = %s
|
||
AND stat_date >= %s::date
|
||
AND stat_date <= %s::date
|
||
""",
|
||
(area_code, start_date, end_date),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
_zero = {
|
||
"total_occurrence": 0.0,
|
||
"table_fee_amount": 0.0,
|
||
"goods_amount": 0.0,
|
||
"assistant_pd_amount": 0.0,
|
||
"assistant_cx_amount": 0.0,
|
||
"discount_total": 0.0,
|
||
"discount_groupbuy": 0.0,
|
||
"discount_vip": 0.0,
|
||
"discount_manual": 0.0,
|
||
"discount_gift_card": 0.0,
|
||
"discount_rounding": 0.0,
|
||
"discount_other": 0.0,
|
||
"confirmed_total": 0.0,
|
||
"order_count": 0,
|
||
}
|
||
if not row or row[0] is None:
|
||
return _zero
|
||
|
||
def _f(v):
|
||
return float(v) if v is not None else 0.0
|
||
|
||
return {
|
||
"total_occurrence": _f(row[0]),
|
||
"table_fee_amount": _f(row[1]),
|
||
"goods_amount": _f(row[2]),
|
||
"assistant_pd_amount": _f(row[3]),
|
||
"assistant_cx_amount": _f(row[4]),
|
||
"discount_total": _f(row[5]),
|
||
"discount_groupbuy": _f(row[6]),
|
||
"discount_vip": _f(row[7]),
|
||
"discount_manual": _f(row[8]),
|
||
"discount_gift_card": _f(row[9]),
|
||
"discount_rounding": _f(row[10]),
|
||
"discount_other": _f(row[11]),
|
||
"confirmed_total": _f(row[12]),
|
||
"order_count": int(row[13]) if row[13] is not None else 0,
|
||
}
|
||
|
||
|
||
@trace_service(
|
||
description_zh="查询财务看板缓存", description_en="Get finance board cache"
|
||
)
|
||
def get_finance_board_cache(
|
||
conn: Any,
|
||
site_id: int,
|
||
time_range: str,
|
||
area_code: str,
|
||
*,
|
||
etl_conn: Any = None,
|
||
) -> dict | None:
|
||
"""
|
||
从 v_dws_finance_board_cache 查询缓存。
|
||
|
||
返回 overview 8 项 + start_date/end_date + data_fingerprint,
|
||
或 None(缓存未命中)。
|
||
|
||
Requirements: 6.1, 6.3
|
||
"""
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT occurrence,
|
||
discount,
|
||
discount_rate,
|
||
confirmed_revenue,
|
||
cash_in,
|
||
cash_out,
|
||
cash_balance,
|
||
balance_rate,
|
||
start_date,
|
||
end_date,
|
||
data_fingerprint
|
||
FROM app.v_dws_finance_board_cache
|
||
WHERE time_range = %s
|
||
AND area_code = %s
|
||
""",
|
||
(time_range, area_code),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
def _f(v):
|
||
return float(v) if v is not None else 0.0
|
||
|
||
return {
|
||
"occurrence": _f(row[0]),
|
||
"discount": _f(row[1]),
|
||
"discount_rate": _f(row[2]),
|
||
"confirmed_revenue": _f(row[3]),
|
||
"cash_in": _f(row[4]),
|
||
"cash_out": _f(row[5]),
|
||
"cash_balance": _f(row[6]),
|
||
"balance_rate": _f(row[7]),
|
||
"start_date": str(row[8]) if row[8] is not None else None,
|
||
"end_date": str(row[9]) if row[9] is not None else None,
|
||
"data_fingerprint": row[10],
|
||
}
|
||
|
||
|
||
@trace_service(
|
||
description_zh="写入财务看板缓存", description_en="Set finance board cache"
|
||
)
|
||
def set_finance_board_cache(
|
||
conn: Any,
|
||
site_id: int,
|
||
time_range: str,
|
||
area_code: str,
|
||
data: dict,
|
||
*,
|
||
etl_conn: Any = None,
|
||
) -> None:
|
||
"""
|
||
写入/更新 dws.dws_finance_board_cache(实际表,不用 RLS 视图)。
|
||
|
||
使用 ON CONFLICT (site_id, time_range, area_code) DO UPDATE 保证幂等。
|
||
data dict 应包含 overview 8 项 + start_date/end_date + data_fingerprint。
|
||
|
||
注意:写入用实际表而非 RLS 视图,因为 RLS 视图可能不支持 INSERT。
|
||
site_id 通过参数显式传入 INSERT 语句。
|
||
|
||
Requirements: 6.2
|
||
"""
|
||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO dws.dws_finance_board_cache (
|
||
site_id, time_range, area_code,
|
||
start_date, end_date,
|
||
occurrence, discount, discount_rate,
|
||
confirmed_revenue, cash_in, cash_out,
|
||
cash_balance, balance_rate,
|
||
data_fingerprint, computed_at,
|
||
created_at, updated_at
|
||
) VALUES (
|
||
%s, %s, %s,
|
||
%s, %s,
|
||
%s, %s, %s,
|
||
%s, %s, %s,
|
||
%s, %s,
|
||
%s, NOW(),
|
||
NOW(), NOW()
|
||
)
|
||
ON CONFLICT (site_id, time_range, area_code) DO UPDATE SET
|
||
start_date = EXCLUDED.start_date,
|
||
end_date = EXCLUDED.end_date,
|
||
occurrence = EXCLUDED.occurrence,
|
||
discount = EXCLUDED.discount,
|
||
discount_rate = EXCLUDED.discount_rate,
|
||
confirmed_revenue = EXCLUDED.confirmed_revenue,
|
||
cash_in = EXCLUDED.cash_in,
|
||
cash_out = EXCLUDED.cash_out,
|
||
cash_balance = EXCLUDED.cash_balance,
|
||
balance_rate = EXCLUDED.balance_rate,
|
||
data_fingerprint = EXCLUDED.data_fingerprint,
|
||
computed_at = NOW(),
|
||
updated_at = NOW()
|
||
""",
|
||
(
|
||
site_id,
|
||
time_range,
|
||
area_code,
|
||
data.get("start_date"),
|
||
data.get("end_date"),
|
||
data.get("occurrence", 0),
|
||
data.get("discount", 0),
|
||
data.get("discount_rate", 0),
|
||
data.get("confirmed_revenue", 0),
|
||
data.get("cash_in", 0),
|
||
data.get("cash_out", 0),
|
||
data.get("cash_balance", 0),
|
||
data.get("balance_rate", 0),
|
||
data.get("data_fingerprint"),
|
||
),
|
||
)
|