Files
Neo-ZQYY/apps/backend/app/ai/data_fetchers/assistant_data.py
Neo caf179a5da feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
2026-05-04 02:30:19 +08:00

271 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""助教数据获取模块(应用 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 后失效,
# 需在查询事务中重新设置)
# CHANGE 2026-05-02 | 同时下发 app.current_business_date供 RLS 视图业务日上界裁剪
from app.services.runtime_context import as_runtime_today_param as _rt_today
_ref_date = _rt_today(site_id)
with conn.cursor() as cur:
cur.execute(
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
cur.execute(
"SET LOCAL app.current_business_date = %s", (_ref_date.isoformat(),)
)
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]
# 计算工龄CHANGE 2026-05-02 | 用 business_date 替代 today沙箱按当时工龄
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id)
tenure_months = 0
if hire_date and isinstance(hire_date, date):
tenure_months = (ref_date.year - hire_date.year) * 12 + (ref_date.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 后失效,
# 需在查询事务中重新设置)
# CHANGE 2026-05-02 | 同时下发 app.current_business_date供 RLS 视图业务日上界裁剪
from app.services.runtime_context import as_runtime_today_param as _rt_today2
_ref_date_outer = _rt_today2(site_id)
with conn.cursor() as cur:
cur.execute(
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
cur.execute(
"SET LOCAL app.current_business_date = %s", (_ref_date_outer.isoformat(),)
)
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)
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」服务记录
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id)
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 >= (%s::date - (INTERVAL '1 month' * %s))
AND create_time < (%s::date + INTERVAL '1 day')
ORDER BY create_time DESC
""",
(assistant_id, member_id, ref_date, months, ref_date),
)
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()