Files
Neo-ZQYY/apps/backend/app/ai/data_fetchers/member_data.py
Neo 6f8f12314f 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>
2026-04-06 00:03:48 +08:00

403 lines
14 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.
"""客户消费数据获取模块(应用 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和桌台IDsite_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()