"""助教数据获取模块(应用 4/5 共用)。 从 ETL 库 app.v_* RLS 视图获取助教基本信息和助教-客户服务历史。 使用 is_delete 字段排除废单(is_delete=0 为正常),禁止使用已废弃的 dwd_assistant_trash_event 表。 """ # CHANGE 2026-03-23 | Prompt: FDW 迁移——fdw_etl.* → app.* 直连 ETL 库 # intent: 将所有 fdw_etl.* 外部表引用改为 app.v_* RLS 视图(直连 ETL 库),列名同步修正 # 连接方式不变(get_etl_readonly_connection),仅改 SQL 表名和列名 from __future__ import annotations import asyncio import logging from datetime import date, datetime from decimal import Decimal from functools import partial from typing import Any from app.database import get_etl_readonly_connection logger = logging.getLogger(__name__) FDW_QUERY_TIMEOUT_SEC = 5 async def fetch_assistant_info( site_id: int, assistant_id: int, ) -> dict[str, Any]: """获取助教基本信息。 返回: { "nickname": str, "level": str, "hire_date": str, "tenure_months": int, "monthly_customers": int, "performance_tier": str, } Raises: ValueError: 助教不存在 TimeoutError: FDW 查询超时 ConnectionError: FDW 连接失败 """ loop = asyncio.get_event_loop() return await loop.run_in_executor( None, partial(_fetch_assistant_info_sync, site_id, assistant_id), ) def _fetch_assistant_info_sync(site_id: int, assistant_id: int) -> dict[str, Any]: """同步实现。""" conn = None try: conn = get_etl_readonly_connection(site_id) # RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效, # 需在查询事务中重新设置) with conn.cursor() as cur: cur.execute( "SET LOCAL app.current_site_id = %s", (str(site_id),) ) cur.execute( "SET LOCAL statement_timeout = %s", (f"{FDW_QUERY_TIMEOUT_SEC * 1000}",), ) # 基本信息 # ⚠️ v_dim_assistant 列名: hire_date→entry_time cur.execute( """ SELECT nickname, level, entry_time AS hire_date FROM app.v_dim_assistant WHERE assistant_id = %s AND scd2_is_current = 1 LIMIT 1 """, (assistant_id,), ) row = cur.fetchone() if not row: raise ValueError(f"assistant not found: assistant_id={assistant_id}") nickname = row[0] or "" level = row[1] or "" hire_date = row[2] # 计算工龄 tenure_months = 0 if hire_date and isinstance(hire_date, date): today = date.today() tenure_months = (today.year - hire_date.year) * 12 + (today.month - hire_date.month) # 绩效数据 # ⚠️ 列名映射: monthly_customers 不存在(用 0 占位),performance_tier→tier_name # ⚠️ salary_month 是 date 类型(YYYY-MM-01),按月降序取最新 cur.execute( """ SELECT 0 AS monthly_customers, COALESCE(tier_name, '') AS performance_tier FROM app.v_dws_assistant_salary_calc WHERE assistant_id = %s ORDER BY salary_month DESC LIMIT 1 """, (assistant_id,), ) perf_row = cur.fetchone() monthly_customers = perf_row[0] if perf_row else 0 performance_tier = perf_row[1] if perf_row else "" conn.commit() return { "nickname": nickname, "level": level, "hire_date": hire_date.isoformat() if isinstance(hire_date, date) else "", "tenure_months": tenure_months, "monthly_customers": monthly_customers, "performance_tier": performance_tier, } except (ValueError, TimeoutError, ConnectionError): raise except Exception as e: err_msg = str(e).lower() if "statement timeout" in err_msg or "timeout" in err_msg: raise TimeoutError( f"FDW 查询超时: assistant_id={assistant_id}" ) from e if "connection" in err_msg or "connect" in err_msg: raise ConnectionError( f"FDW 连接失败: assistant_id={assistant_id}" ) from e raise finally: if conn: conn.close() async def fetch_service_history( site_id: int, assistant_id: int, member_id: int, months: int = 3, ) -> list[dict[str, Any]]: """获取助教服务该客户的历史记录。 使用 is_delete 排除废单(WHERE is_delete = 0)。 返回: [ { "service_date": str, "duration_minutes": int, "items_sum": float, "room_name": str, "is_pd": bool, }, ... ] Raises: TimeoutError: FDW 查询超时 ConnectionError: FDW 连接失败 """ loop = asyncio.get_event_loop() return await loop.run_in_executor( None, partial(_fetch_service_history_sync, site_id, assistant_id, member_id, months), ) def _fetch_service_history_sync( site_id: int, assistant_id: int, member_id: int, months: int, ) -> list[dict[str, Any]]: """同步实现。""" conn = None try: conn = get_etl_readonly_connection(site_id) # RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效, # 需在查询事务中重新设置) with conn.cursor() as cur: cur.execute( "SET LOCAL app.current_site_id = %s", (str(site_id),) ) cur.execute( "SET LOCAL statement_timeout = %s", (f"{FDW_QUERY_TIMEOUT_SEC * 1000}",), ) # ⚠️ 列名映射: assistant_id→site_assistant_id, member_id→tenant_member_id, # is_trash=false→is_delete=0, service_date→create_time, # duration_minutes→real_use_seconds/60, items_sum→ledger_amount, # room_name→site_table_id, is_pd→(order_assistant_type=1) cur.execute( """ SELECT create_time AS service_date, COALESCE(real_use_seconds / 60, 0) AS duration_minutes, ledger_amount AS items_sum, site_table_id AS room_name, (order_assistant_type = 1) AS is_pd 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 '%s months') ORDER BY create_time DESC """, (assistant_id, member_id, months), ) columns = [desc[0] for desc in cur.description] rows = cur.fetchall() conn.commit() records = [] for row in rows: record = {} for col, val in zip(columns, row): if isinstance(val, (date, datetime)): record[col] = val.isoformat() elif isinstance(val, Decimal): record[col] = float(val) elif isinstance(val, bool): record[col] = val else: record[col] = val records.append(record) return records except (TimeoutError, ConnectionError): raise except Exception as e: err_msg = str(e).lower() if "statement timeout" in err_msg or "timeout" in err_msg: raise TimeoutError( f"FDW 查询超时: assistant_id={assistant_id}, member_id={member_id}" ) from e if "connection" in err_msg or "connect" in err_msg: raise ConnectionError( f"FDW 连接失败: assistant_id={assistant_id}, member_id={member_id}" ) from e raise finally: if conn: conn.close()