Files
Neo-ZQYY/apps/backend/app/ai/data_fetchers/member_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

418 lines
15 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 连接上串行执行多个查询。
CHANGE 2026-05-02 | 所有窗口查询都按业务日上界裁剪,
sandbox 模式下不再读取 sandbox_date 之后的真实消费 / 到店。
"""
from app.services.runtime_context import as_runtime_today_param
conn = None
try:
conn = get_etl_readonly_connection(site_id)
ref_date = as_runtime_today_param(site_id)
# RLS 隔离 + 语句超时get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
# 需在查询事务中重新设置)
# CHANGE 2026-05-02 | 同时下发 app.current_business_date供 RLS 视图业务日上界裁剪
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}",), # 毫秒
)
# 1. 会员昵称
nickname = _query_member_nickname(conn, member_id)
# 2. 消费记录(台桌结账 + 商城订单)
records, total_count = _query_consumption_records(conn, member_id, months, ref_date)
# 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, ref_date)
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, ref_date: date
) -> 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。
CHANGE 2026-05-02 | 用 ref_date业务日替代 CURRENT_DATE沙箱不读「未来」消费。
返回 (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 >= (%s::date - (INTERVAL '1 month' * %s))
AND sh.create_time < (%s::date + INTERVAL '1 day')
""",
(member_id, ref_date, months, ref_date),
)
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 >= (%s::date - (INTERVAL '1 month' * %s))
AND sh.create_time < (%s::date + INTERVAL '1 day')
ORDER BY sh.create_time DESC
LIMIT %s
""",
(member_id, ref_date, months, ref_date, 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, ref_date: date) -> dict:
"""从 app.v_dws_member_visit_detail 获取到店数据,推算预计到店日期。
⚠️ 列名映射: last_visit_date→MAX(visit_date), avg_visit_interval_days 需从明细计算。
CHANGE 2026-05-02 | 仅取 ref_date 及之前的到店明细days_since 按 ref_date 计算。
"""
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
AND visit_date <= %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, ref_date),
)
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]
days_since = (ref_date - 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()