涵盖(每条对应已存的审计记录): - 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 逐一处理
271 lines
9.5 KiB
Python
271 lines
9.5 KiB
Python
"""助教数据获取模块(应用 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()
|