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