feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
29
apps/backend/app/ai/data_fetchers/__init__.py
Normal file
29
apps/backend/app/ai/data_fetchers/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""AI 数据获取层。
|
||||
|
||||
为 AI 应用提供共享的数据获取函数,封装 FDW 查询和业务库查询逻辑。
|
||||
所有 FDW 查询通过 get_etl_readonly_connection(site_id) 获取只读连接,
|
||||
自动设置 RLS 门店隔离。
|
||||
|
||||
模块:
|
||||
- member_data: 客户消费数据获取(应用 3/6/7 共用)
|
||||
- assistant_data: 助教数据获取(应用 4/5 共用)
|
||||
- page_context: 页面上下文文本化(应用 1 专用)
|
||||
"""
|
||||
|
||||
from app.ai.data_fetchers.member_data import (
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
)
|
||||
from app.ai.data_fetchers.assistant_data import (
|
||||
fetch_assistant_info,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.data_fetchers.page_context import build_page_text
|
||||
|
||||
__all__ = [
|
||||
"fetch_member_consumption_data",
|
||||
"fetch_member_notes",
|
||||
"fetch_assistant_info",
|
||||
"fetch_service_history",
|
||||
"build_page_text",
|
||||
]
|
||||
253
apps/backend/app/ai/data_fetchers/assistant_data.py
Normal file
253
apps/backend/app/ai/data_fetchers/assistant_data.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""助教数据获取模块(应用 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()
|
||||
402
apps/backend/app/ai/data_fetchers/member_data.py
Normal file
402
apps/backend/app/ai/data_fetchers/member_data.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""客户消费数据获取模块(应用 3/6/7 共用)。
|
||||
|
||||
从 ETL 库 app.v_* RLS 视图获取客户近 N 个月消费数据,从业务库获取备注。
|
||||
金额口径统一使用拆分字段(table_charge_money + assistant_pd/cx_money + goods_money),禁止 consume_money。
|
||||
会员信息通过 member_id JOIN v_dim_member (scd2_is_current=1) 获取。
|
||||
"""
|
||||
# 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_connection, get_etl_readonly_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 消费记录最大返回数
|
||||
MAX_CONSUMPTION_RECORDS = 100
|
||||
# 备注最大返回数
|
||||
MAX_NOTES = 50
|
||||
# 备注单条最大字符数
|
||||
MAX_NOTE_LENGTH = 500
|
||||
# FDW 查询超时(秒)
|
||||
FDW_QUERY_TIMEOUT_SEC = 5
|
||||
|
||||
|
||||
async def fetch_member_consumption_data(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
months: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""获取客户近 N 个月消费数据。
|
||||
|
||||
返回结构对应 NS2 设计文档中 main_data:
|
||||
- consumption_records: 消费记录列表(最多 100 条,settle_date DESC)
|
||||
- member_cards: 会员卡明细列表
|
||||
- card_balance_total: 储值卡余额合计
|
||||
- stored_value_balance_total: 储值余额合计
|
||||
- expected_visit_date: 预计到店日期
|
||||
- days_since_last_visit: 距上次到店天数
|
||||
- member_nickname: 会员昵称
|
||||
|
||||
Raises:
|
||||
TimeoutError: FDW 查询超时(>5s)
|
||||
ConnectionError: FDW 连接失败
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(_fetch_member_consumption_data_sync, site_id, member_id, months),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_member_consumption_data_sync(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
months: int,
|
||||
) -> dict[str, Any]:
|
||||
"""同步实现:在单个 FDW 连接上串行执行多个查询。"""
|
||||
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}",), # 毫秒
|
||||
)
|
||||
|
||||
# 1. 会员昵称
|
||||
nickname = _query_member_nickname(conn, member_id)
|
||||
|
||||
# 2. 消费记录(台桌结账 + 商城订单)
|
||||
records, total_count = _query_consumption_records(conn, member_id, months)
|
||||
|
||||
# 3. 会员卡明细
|
||||
cards = _query_member_cards(conn, member_id)
|
||||
|
||||
# 4. 余额汇总
|
||||
balance_info = _query_balance_summary(conn, member_id)
|
||||
|
||||
# 5. 到店数据
|
||||
visit_info = _query_visit_info(conn, member_id)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"member_nickname": nickname,
|
||||
"consumption_records": records,
|
||||
"member_cards": cards,
|
||||
"card_balance_total": balance_info.get("card_balance_total", Decimal("0")),
|
||||
"stored_value_balance_total": balance_info.get(
|
||||
"stored_value_balance_total", Decimal("0")
|
||||
),
|
||||
"expected_visit_date": visit_info.get("expected_visit_date"),
|
||||
"days_since_last_visit": visit_info.get("days_since_last_visit"),
|
||||
}
|
||||
if total_count > MAX_CONSUMPTION_RECORDS:
|
||||
result["truncated"] = True
|
||||
result["total_count"] = total_count
|
||||
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# psycopg2 超时异常包含 "statement timeout"
|
||||
err_msg = str(e).lower()
|
||||
if "statement timeout" in err_msg or "timeout" in err_msg:
|
||||
raise TimeoutError(
|
||||
f"FDW 查询超时(>{FDW_QUERY_TIMEOUT_SEC}s): member_id={member_id}"
|
||||
) from e
|
||||
if "connection" in err_msg or "connect" in err_msg:
|
||||
raise ConnectionError(
|
||||
f"FDW 连接失败: member_id={member_id}, error={e}"
|
||||
) from e
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _query_member_nickname(conn: Any, member_id: int) -> str:
|
||||
"""从 app.v_dim_member 获取会员昵称(scd2_is_current=1)。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname
|
||||
FROM app.v_dim_member
|
||||
WHERE member_id = %s AND scd2_is_current = 1
|
||||
LIMIT 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else ""
|
||||
|
||||
|
||||
def _query_consumption_records(
|
||||
conn: Any, member_id: int, months: int
|
||||
) -> tuple[list[dict], int]:
|
||||
"""从 app.v_dwd_settlement_head + app.v_dwd_table_fee_log 获取消费记录。
|
||||
|
||||
仅包含正向交易(settle_type IN (1, 3))。
|
||||
⚠️ 费用拆分字段(table_charge_money, assistant_pd/cx_money)在 settlement_head 上。
|
||||
⚠️ table_fee_log 提供台桌时长(real_table_use_seconds)和桌台ID(site_table_id)。
|
||||
⚠️ 列名映射: settle_date→create_time, settle_id→order_settle_id, sale_amount→ledger_amount。
|
||||
返回 (records, total_count)。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
# 先查总数
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
WHERE sh.member_id = %s
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND sh.create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
""",
|
||||
(member_id, months),
|
||||
)
|
||||
total_count = cur.fetchone()[0]
|
||||
|
||||
# 查询消费记录(限制 100 条)
|
||||
# table_charge_money/assistant_pd_money/assistant_cx_money 直接从 settlement_head 取
|
||||
# 台桌信息从 table_fee_log 取(site_table_id, real_table_use_seconds)
|
||||
# 商品金额从 store_goods_sale 聚合
|
||||
# 助教姓名从 service_log JOIN dim_assistant 获取
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
sh.create_time AS settle_date,
|
||||
sh.settle_type,
|
||||
sh.table_charge_money + sh.assistant_pd_money + sh.assistant_cx_money
|
||||
+ COALESCE(sg.goods_money, 0) AS items_sum,
|
||||
COALESCE(sh.table_charge_money, 0) AS table_charge_money,
|
||||
COALESCE(sh.assistant_pd_money, 0) AS assistant_pd_money,
|
||||
COALESCE(sh.assistant_cx_money, 0) AS assistant_cx_money,
|
||||
COALESCE(sg.goods_money, 0) AS goods_money,
|
||||
tfl.site_table_id AS room_name,
|
||||
COALESCE(tfl.real_table_use_seconds / 60, 0) AS duration_minutes,
|
||||
coaches.assistant_names
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
LEFT JOIN app.v_dwd_table_fee_log tfl
|
||||
ON sh.order_settle_id = tfl.order_settle_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(sgs.ledger_amount) AS goods_money
|
||||
FROM app.v_dwd_store_goods_sale sgs
|
||||
WHERE sgs.order_settle_id = sh.order_settle_id
|
||||
) sg ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(DISTINCT COALESCE(da.nickname, da.real_name, ''), ', ')
|
||||
AS assistant_names
|
||||
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.order_settle_id = sh.order_settle_id
|
||||
AND sl.is_delete = 0
|
||||
) coaches ON true
|
||||
WHERE sh.member_id = %s
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND sh.create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
ORDER BY sh.create_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(member_id, months, MAX_CONSUMPTION_RECORDS),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
|
||||
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)
|
||||
else:
|
||||
record[col] = val
|
||||
# assistant_names: 确保是列表
|
||||
names = record.get("assistant_names")
|
||||
if names and isinstance(names, str):
|
||||
record["assistant_names"] = [n.strip() for n in names.split(",") if n.strip()]
|
||||
elif not names:
|
||||
record["assistant_names"] = []
|
||||
records.append(record)
|
||||
|
||||
return records, total_count
|
||||
|
||||
|
||||
def _query_member_cards(conn: Any, member_id: int) -> list[dict]:
|
||||
"""从 app.v_dim_member_card_account 获取会员卡明细。
|
||||
⚠️ 列名映射: member_id→tenant_member_id, gift_balance 不存在(用 balance - principal_balance 近似)。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_card_type_name AS card_type,
|
||||
COALESCE(balance, 0) AS balance,
|
||||
COALESCE(balance, 0) - COALESCE(principal_balance, 0) AS gift_balance
|
||||
FROM app.v_dim_member_card_account
|
||||
WHERE tenant_member_id = %s AND scd2_is_current = 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"card_type": row[0] or "",
|
||||
"balance": float(row[1]) if row[1] else 0.0,
|
||||
"gift_balance": float(row[2]) if row[2] else 0.0,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _query_balance_summary(conn: Any, member_id: int) -> dict:
|
||||
"""从 app.v_dws_member_consumption_summary 获取余额汇总。
|
||||
⚠️ 列名映射: recharge_card_amount→cash_card_balance, balance_amount→total_card_balance。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(cash_card_balance, 0) AS card_balance_total,
|
||||
COALESCE(total_card_balance, 0) AS stored_value_balance_total
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"card_balance_total": Decimal("0"),
|
||||
"stored_value_balance_total": Decimal("0"),
|
||||
}
|
||||
return {
|
||||
"card_balance_total": row[0],
|
||||
"stored_value_balance_total": row[1],
|
||||
}
|
||||
|
||||
|
||||
def _query_visit_info(conn: Any, member_id: int) -> dict:
|
||||
"""从 app.v_dws_member_visit_detail 获取到店数据,推算预计到店日期。
|
||||
⚠️ 列名映射: last_visit_date→MAX(visit_date), avg_visit_interval_days 需从明细计算。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
# 获取最近到店日期和平均到店间隔
|
||||
cur.execute(
|
||||
"""
|
||||
WITH visits AS (
|
||||
SELECT visit_date,
|
||||
LAG(visit_date) OVER (ORDER BY visit_date) AS prev_visit
|
||||
FROM app.v_dws_member_visit_detail
|
||||
WHERE member_id = %s
|
||||
)
|
||||
SELECT
|
||||
MAX(visit_date) AS last_visit_date,
|
||||
AVG(visit_date - prev_visit) AS avg_visit_interval_days
|
||||
FROM visits
|
||||
WHERE prev_visit IS NOT NULL
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not row[0]:
|
||||
return {"expected_visit_date": None, "days_since_last_visit": None}
|
||||
|
||||
last_visit = row[0]
|
||||
avg_interval = row[1]
|
||||
today = date.today()
|
||||
days_since = (today - last_visit).days if isinstance(last_visit, date) else None
|
||||
|
||||
expected = None
|
||||
if avg_interval and last_visit:
|
||||
from datetime import timedelta
|
||||
expected_date = last_visit + timedelta(days=int(avg_interval))
|
||||
expected = expected_date.isoformat()
|
||||
|
||||
return {
|
||||
"expected_visit_date": expected,
|
||||
"days_since_last_visit": days_since,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_member_notes(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
limit: int = MAX_NOTES,
|
||||
) -> list[dict]:
|
||||
"""获取客户的全部备注(按 created_at DESC,最多 limit 条)。
|
||||
|
||||
从业务库 biz.notes 查询。
|
||||
单条备注内容截断 500 字符,超出附加"…(已截断)"。
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(_fetch_member_notes_sync, site_id, member_id, limit),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_member_notes_sync(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""同步实现:从业务库查询备注。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
n.content,
|
||||
u.nickname AS recorded_by,
|
||||
n.created_at
|
||||
FROM biz.notes n
|
||||
LEFT JOIN biz.coach_tasks ct ON ct.id = n.task_id
|
||||
LEFT JOIN public.users u ON u.id = n.user_id
|
||||
WHERE n.target_id = %s AND n.site_id = %s
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(member_id, site_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
notes = []
|
||||
for row in rows:
|
||||
content = row[0] or ""
|
||||
recorded_by = row[1] or ""
|
||||
created_at = row[2]
|
||||
|
||||
# 截断处理
|
||||
if len(content) > MAX_NOTE_LENGTH:
|
||||
content = content[:MAX_NOTE_LENGTH] + "…(已截断)"
|
||||
|
||||
notes.append({
|
||||
"recorded_by": recorded_by,
|
||||
"content": content,
|
||||
"created_at": created_at.isoformat() if isinstance(created_at, (date, datetime)) else str(created_at) if created_at else "",
|
||||
})
|
||||
|
||||
return notes
|
||||
finally:
|
||||
conn.close()
|
||||
645
apps/backend/app/ai/data_fetchers/page_context.py
Normal file
645
apps/backend/app/ai/data_fetchers/page_context.py
Normal file
@@ -0,0 +1,645 @@
|
||||
"""页面上下文文本化模块(应用 1 专用)。
|
||||
|
||||
根据 contextType 从数据库获取对应页面数据,
|
||||
格式化为结构化中文文本(≤ 2000 字符),供 AI 理解当前场景。
|
||||
不传入 member_phone 等断档敏感字段。
|
||||
"""
|
||||
|
||||
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_connection, get_etl_readonly_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PAGE_CONTEXT_LENGTH = 2000
|
||||
FDW_QUERY_TIMEOUT_SEC = 5
|
||||
|
||||
# 支持的 10 种页面类型
|
||||
SUPPORTED_PAGE_TYPES = {
|
||||
"task-detail",
|
||||
"customer-detail",
|
||||
"coach-detail",
|
||||
"board-finance",
|
||||
"board-customer",
|
||||
"board-coach",
|
||||
"performance",
|
||||
"my-profile",
|
||||
"task-list",
|
||||
"customer-service-records",
|
||||
}
|
||||
|
||||
|
||||
async def build_page_text(
|
||||
source_page: str,
|
||||
context_id: int | str | None,
|
||||
site_id: int,
|
||||
filters: dict | None = None,
|
||||
) -> str:
|
||||
"""将页面数据转换为 AI 可读的结构化中文文本。
|
||||
|
||||
Args:
|
||||
source_page: 页面类型(contextType)
|
||||
context_id: 实体 ID(contextId)
|
||||
site_id: 门店 ID
|
||||
filters: 看板类页面的筛选参数
|
||||
|
||||
Returns:
|
||||
结构化中文文本(≤ 2000 字符),失败时返回降级文本
|
||||
"""
|
||||
if not source_page or source_page not in SUPPORTED_PAGE_TYPES:
|
||||
return ""
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
text = await loop.run_in_executor(
|
||||
None,
|
||||
partial(_build_page_text_sync, source_page, context_id, site_id, filters or {}),
|
||||
)
|
||||
# 截断保护
|
||||
if len(text) > MAX_PAGE_CONTEXT_LENGTH:
|
||||
text = text[:MAX_PAGE_CONTEXT_LENGTH - 20] + "\n…(上下文已截断)"
|
||||
return text
|
||||
except Exception:
|
||||
logger.exception("页面上下文获取失败: source_page=%s", source_page)
|
||||
return "页面上下文获取失败,请直接描述您的问题"
|
||||
|
||||
|
||||
def _build_page_text_sync(
|
||||
source_page: str,
|
||||
context_id: int | str | None,
|
||||
site_id: int,
|
||||
filters: dict,
|
||||
) -> str:
|
||||
"""同步路由到对应页面文本化函数。"""
|
||||
handlers = {
|
||||
"task-detail": _text_task_detail,
|
||||
"customer-detail": _text_customer_detail,
|
||||
"coach-detail": _text_coach_detail,
|
||||
"board-finance": _text_board_finance,
|
||||
"board-customer": _text_board_customer,
|
||||
"board-coach": _text_board_coach,
|
||||
"performance": _text_performance,
|
||||
"my-profile": _text_my_profile,
|
||||
"task-list": _text_task_list,
|
||||
"customer-service-records": _text_customer_service_records,
|
||||
}
|
||||
handler = handlers.get(source_page)
|
||||
if not handler:
|
||||
return ""
|
||||
return handler(context_id, site_id, filters)
|
||||
|
||||
|
||||
# ── 详情类页面 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _text_task_detail(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""任务详情页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
task_id = int(context_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 任务信息
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ct.task_type, ct.status, ct.deadline,
|
||||
ct.member_id, ct.assistant_id,
|
||||
dm.nickname AS member_nickname,
|
||||
da.nickname AS assistant_nickname
|
||||
FROM biz.coach_tasks ct
|
||||
LEFT JOIN biz.coach_tasks_member_view dm
|
||||
ON dm.member_id = ct.member_id AND dm.site_id = ct.site_id
|
||||
LEFT JOIN biz.coach_tasks_assistant_view da
|
||||
ON da.assistant_id = ct.assistant_id AND da.site_id = ct.site_id
|
||||
WHERE ct.id = %s AND ct.site_id = %s
|
||||
""",
|
||||
(task_id, site_id),
|
||||
)
|
||||
task = cur.fetchone()
|
||||
if not task:
|
||||
return f"任务 {task_id} 不存在"
|
||||
|
||||
task_type, status, deadline, member_id, assistant_id, member_nick, asst_nick = task
|
||||
|
||||
# 最近备注(最多 3 条)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT content, created_at
|
||||
FROM biz.notes
|
||||
WHERE task_id = %s AND site_id = %s
|
||||
ORDER BY created_at DESC LIMIT 3
|
||||
""",
|
||||
(task_id, site_id),
|
||||
)
|
||||
notes = cur.fetchall()
|
||||
|
||||
# AI 缓存(最新分析)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT result_json, created_at
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = 'app4_analysis'
|
||||
AND site_id = %s
|
||||
AND target_id = %s
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(site_id, f"{assistant_id}_{member_id}"),
|
||||
)
|
||||
ai_row = cur.fetchone()
|
||||
|
||||
lines = [
|
||||
"【任务详情】",
|
||||
f" 任务类型:{task_type or '未知'}",
|
||||
f" 状态:{status or '未知'}",
|
||||
f" 截止日期:{_fmt_date(deadline)}",
|
||||
f" 客户:{member_nick or f'ID:{member_id}'}",
|
||||
f" 助教:{asst_nick or f'ID:{assistant_id}'}",
|
||||
]
|
||||
if notes:
|
||||
lines.append("【最近备注】")
|
||||
for content, created_at in notes:
|
||||
short = (content or "")[:100]
|
||||
lines.append(f" {_fmt_date(created_at)} {short}")
|
||||
if ai_row:
|
||||
lines.append(f"【AI 分析】最近更新于 {_fmt_date(ai_row[1])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _text_customer_detail(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""客户详情页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
member_id = int(context_id)
|
||||
|
||||
# 复用 member_data 的同步查询(避免循环导入,直接查询)
|
||||
etl_conn = None
|
||||
biz_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# CHANGE 2026-03-23 | Prompt: FDW 迁移——fdw_etl.* → app.* 直连 ETL 库
|
||||
# 会员信息
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname
|
||||
FROM app.v_dim_member
|
||||
WHERE member_id = %s AND scd2_is_current = 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
m_row = cur.fetchone()
|
||||
nickname = m_row[0] if m_row else f"ID:{member_id}"
|
||||
|
||||
# 最近 5 条消费
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT settle_date, items_sum, room_name
|
||||
FROM app.v_dwd_settlement_head
|
||||
WHERE member_id = %s AND settle_type IN (1, 3)
|
||||
ORDER BY settle_date DESC LIMIT 5
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
recent = cur.fetchall()
|
||||
|
||||
# 余额
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT balance_amount
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = %s LIMIT 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
bal_row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
|
||||
# 维客线索
|
||||
biz_conn = get_connection()
|
||||
with biz_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT summary FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
""",
|
||||
(member_id, site_id),
|
||||
)
|
||||
clues = cur.fetchall()
|
||||
|
||||
lines = [
|
||||
"【客户详情】",
|
||||
f" 昵称:{nickname}",
|
||||
f" 储值余额:{_fmt_decimal(bal_row[0]) if bal_row else '未知'}",
|
||||
]
|
||||
if recent:
|
||||
lines.append("【近期消费】")
|
||||
for sd, amt, room in recent:
|
||||
lines.append(f" {_fmt_date(sd)} ¥{_fmt_decimal(amt)} {room or ''}")
|
||||
if clues:
|
||||
lines.append("【维客线索】")
|
||||
for (summary,) in clues:
|
||||
lines.append(f" {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
if biz_conn:
|
||||
biz_conn.close()
|
||||
|
||||
|
||||
def _text_coach_detail(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""助教详情页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
assistant_id = int(context_id)
|
||||
|
||||
etl_conn = None
|
||||
biz_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname, level, hire_date
|
||||
FROM app.v_dim_assistant
|
||||
WHERE assistant_id = %s LIMIT 1
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
|
||||
if not row:
|
||||
return f"助教 {assistant_id} 不存在"
|
||||
|
||||
nickname, level, hire_date = row
|
||||
|
||||
biz_conn = get_connection()
|
||||
with biz_conn.cursor() as cur:
|
||||
# 任务统计
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s AND site_id = %s
|
||||
GROUP BY status
|
||||
""",
|
||||
(assistant_id, site_id),
|
||||
)
|
||||
task_stats = cur.fetchall()
|
||||
|
||||
lines = [
|
||||
"【助教详情】",
|
||||
f" 花名:{nickname or ''}",
|
||||
f" 级别:{level or ''}",
|
||||
f" 入职日期:{_fmt_date(hire_date)}",
|
||||
]
|
||||
if task_stats:
|
||||
lines.append("【任务统计】")
|
||||
for status, cnt in task_stats:
|
||||
lines.append(f" {status}: {cnt} 个")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
if biz_conn:
|
||||
biz_conn.close()
|
||||
|
||||
|
||||
# ── 看板类页面 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _text_board_finance(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""财务看板文本化。"""
|
||||
time_dim = filters.get("timeDimension", "this_month")
|
||||
area = filters.get("areaFilter", "")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# 简化查询:获取汇总数据
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS settle_count,
|
||||
COALESCE(SUM(items_sum), 0) AS total_revenue,
|
||||
COALESCE(AVG(items_sum), 0) AS avg_revenue
|
||||
FROM app.v_dwd_settlement_head
|
||||
WHERE settle_type IN (1, 3)
|
||||
AND settle_date >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
""",
|
||||
)
|
||||
row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【财务看板】",
|
||||
f" 时间维度:{time_dim}",
|
||||
]
|
||||
if area:
|
||||
lines.append(f" 区域筛选:{area}")
|
||||
if row:
|
||||
lines.append(f" 结算笔数:{row[0]}")
|
||||
lines.append(f" 总营收:¥{_fmt_decimal(row[1])}")
|
||||
lines.append(f" 笔均:¥{_fmt_decimal(row[2])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
def _text_board_customer(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""客户看板文本化。"""
|
||||
dimension = filters.get("dimension", "consumption")
|
||||
type_filter = filters.get("typeFilter", "")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# Top 10 客户
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
dm.nickname,
|
||||
COALESCE(SUM(sh.items_sum), 0) AS total_consumption
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
JOIN app.v_dim_member dm
|
||||
ON dm.member_id = sh.member_id AND dm.scd2_is_current = 1
|
||||
WHERE sh.settle_type IN (1, 3)
|
||||
AND sh.member_id > 0
|
||||
AND sh.settle_date >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
GROUP BY dm.nickname
|
||||
ORDER BY total_consumption DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【客户看板】",
|
||||
f" 排序维度:{dimension}",
|
||||
]
|
||||
if type_filter:
|
||||
lines.append(f" 类型筛选:{type_filter}")
|
||||
if rows:
|
||||
lines.append(" Top 10 客户:")
|
||||
for i, (nick, amt) in enumerate(rows, 1):
|
||||
lines.append(f" {i}. {nick or '未知'} ¥{_fmt_decimal(amt)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
def _text_board_coach(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""助教看板文本化。"""
|
||||
dimension = filters.get("dimension", "service_count")
|
||||
project = filters.get("projectFilter", "")
|
||||
time_dim = filters.get("timeDimension", "this_month")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
da.nickname,
|
||||
COUNT(*) AS service_count,
|
||||
COALESCE(SUM(sl.ledger_amount), 0) AS total_revenue
|
||||
FROM app.v_dwd_assistant_service_log sl
|
||||
JOIN app.v_dim_assistant da
|
||||
ON da.assistant_id = sl.site_assistant_id
|
||||
WHERE sl.is_delete = 0
|
||||
AND sl.create_time >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
GROUP BY da.nickname
|
||||
ORDER BY service_count DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【助教看板】",
|
||||
f" 排序维度:{dimension}",
|
||||
f" 时间维度:{time_dim}",
|
||||
]
|
||||
if project:
|
||||
lines.append(f" 技能筛选:{project}")
|
||||
if rows:
|
||||
lines.append(" Top 10 助教:")
|
||||
for i, (nick, cnt, amt) in enumerate(rows, 1):
|
||||
lines.append(f" {i}. {nick or '未知'} 服务{cnt}次 ¥{_fmt_decimal(amt)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
# ── 其他页面 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _text_performance(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""绩效页面文本化。"""
|
||||
time_dim = filters.get("timeDimension", "this_month")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
da.nickname,
|
||||
sc.performance_tier,
|
||||
sc.monthly_customers
|
||||
FROM app.v_dws_assistant_salary_calc sc
|
||||
JOIN app.v_dim_assistant da
|
||||
ON da.assistant_id = sc.assistant_id
|
||||
ORDER BY sc.calc_month DESC, sc.monthly_customers DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【绩效数据】",
|
||||
f" 时间维度:{time_dim}",
|
||||
]
|
||||
if rows:
|
||||
for nick, tier, customers in rows:
|
||||
lines.append(f" {nick or '未知'} {tier or ''} 服务{customers or 0}人")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
def _text_my_profile(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""个人信息页文本化。"""
|
||||
return "【个人信息】\n 当前为个人信息页面,可查询个人绩效和任务情况。"
|
||||
|
||||
|
||||
def _text_task_list(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""任务列表页文本化。"""
|
||||
if not context_id:
|
||||
# 无特定任务,返回概要
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
GROUP BY status
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
stats = cur.fetchall()
|
||||
|
||||
lines = ["【任务列表】"]
|
||||
for status, cnt in stats:
|
||||
lines.append(f" {status}: {cnt} 个")
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 有特定任务 ID,复用 task-detail
|
||||
return _text_task_detail(context_id, site_id, filters)
|
||||
|
||||
|
||||
def _text_customer_service_records(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""客户服务记录页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
member_id = int(context_id)
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
create_time,
|
||||
real_use_seconds / 60 AS duration_minutes,
|
||||
ledger_amount,
|
||||
site_table_id
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s AND is_delete = 0
|
||||
ORDER BY create_time DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = ["【服务记录】"]
|
||||
if not rows:
|
||||
lines.append(" 暂无服务记录")
|
||||
else:
|
||||
for sd, dur, amt, room in rows:
|
||||
lines.append(
|
||||
f" {_fmt_date(sd)} {dur or 0}分钟 ¥{_fmt_decimal(amt)} {room or ''}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fmt_date(val: Any) -> str:
|
||||
"""格式化日期值。"""
|
||||
if isinstance(val, datetime):
|
||||
return val.strftime("%Y-%m-%d %H:%M")
|
||||
if isinstance(val, date):
|
||||
return val.isoformat()
|
||||
return str(val) if val else "未知"
|
||||
|
||||
|
||||
def _fmt_decimal(val: Any) -> str:
|
||||
"""格式化金额值。"""
|
||||
if val is None:
|
||||
return "0.00"
|
||||
if isinstance(val, Decimal):
|
||||
return f"{val:.2f}"
|
||||
if isinstance(val, float):
|
||||
return f"{val:.2f}"
|
||||
return str(val)
|
||||
Reference in New Issue
Block a user