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 逐一处理
This commit is contained in:
@@ -35,9 +35,21 @@ class AdminAIService:
|
||||
|
||||
# ── Dashboard ─────────────────────────────────────────
|
||||
|
||||
async def get_dashboard(self, site_id: int | None = None) -> dict:
|
||||
"""聚合所有 Dashboard 数据。"""
|
||||
today_stats = await self._get_today_stats(site_id)
|
||||
async def get_dashboard(
|
||||
self,
|
||||
site_id: int | None = None,
|
||||
range_days: int | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> dict:
|
||||
"""聚合所有 Dashboard 数据。
|
||||
|
||||
时间范围优先级:
|
||||
1. 若 date_from / date_to 同时给出(指定日期)→ 闭区间 [from, to]
|
||||
2. 若 range_days=N → [CURRENT_DATE - (N-1) days, 现在]
|
||||
3. 默认 range_days=1(今日)
|
||||
"""
|
||||
today_stats = await self._get_range_stats(site_id, range_days, date_from, date_to)
|
||||
trend_7d = await self._get_7d_trend(site_id)
|
||||
app_dist = await self._get_app_distribution(site_id)
|
||||
app_health = await self._get_app_health(site_id)
|
||||
@@ -52,9 +64,32 @@ class AdminAIService:
|
||||
"app_health": app_health,
|
||||
}
|
||||
|
||||
async def _get_today_stats(self, site_id: int | None) -> dict:
|
||||
"""今日调用次数、成功率、Token 消耗、平均延迟。"""
|
||||
site_clause, params = _site_filter(site_id)
|
||||
async def _get_range_stats(
|
||||
self,
|
||||
site_id: int | None,
|
||||
range_days: int | None,
|
||||
date_from: str | None,
|
||||
date_to: str | None,
|
||||
) -> dict:
|
||||
"""指定时间段内的调用次数、成功率、Token 消耗、平均延迟。
|
||||
|
||||
字段名沿用 today_* 前缀以兼容前端 DashboardResponse schema。
|
||||
"""
|
||||
site_clause, site_params = _site_filter(site_id)
|
||||
|
||||
if date_from and date_to:
|
||||
time_clause = "created_at >= %s::date AND created_at < (%s::date + INTERVAL '1 day')"
|
||||
time_params: tuple = (date_from, date_to)
|
||||
else:
|
||||
days = range_days if range_days and range_days > 0 else 1
|
||||
time_clause = (
|
||||
"created_at >= CURRENT_DATE - (%s::int - 1) * INTERVAL '1 day' "
|
||||
"AND created_at < CURRENT_DATE + INTERVAL '1 day'"
|
||||
)
|
||||
time_params = (days,)
|
||||
|
||||
params = time_params + site_params
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -67,8 +102,7 @@ class AdminAIService:
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0)
|
||||
AS avg_latency
|
||||
FROM biz.ai_run_logs
|
||||
WHERE created_at >= CURRENT_DATE
|
||||
AND created_at < CURRENT_DATE + INTERVAL '1 day'
|
||||
WHERE {time_clause}
|
||||
{site_clause}
|
||||
""",
|
||||
params,
|
||||
@@ -466,6 +500,22 @@ class AdminAIService:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Phase 1.4:广播 cache_invalidated 事件,admin-web / 小程序可实时刷新
|
||||
if affected > 0:
|
||||
try:
|
||||
from app.ai.event_bus import AIEvent, get_event_bus
|
||||
get_event_bus().publish(AIEvent(
|
||||
type="cache_invalidated",
|
||||
site_id=site_id,
|
||||
payload={
|
||||
"cache_type": app_type,
|
||||
"member_id": member_id,
|
||||
"affected": affected,
|
||||
},
|
||||
))
|
||||
except Exception:
|
||||
logger.debug("cache_invalidated 事件广播失败", exc_info=True)
|
||||
|
||||
return affected
|
||||
|
||||
# ── Token 预算 ────────────────────────────────────────
|
||||
@@ -699,6 +749,140 @@ class AdminAIService:
|
||||
|
||||
return "ignored"
|
||||
|
||||
# ── 触发器管理(biz.trigger_jobs)───────────────────────
|
||||
|
||||
async def list_triggers(self) -> list[dict]:
|
||||
"""列出所有 AI 相关触发器(job_type 以 ai_ 开头 + task_generator)。
|
||||
|
||||
返回字段:id / job_name / job_type / trigger_condition / trigger_config /
|
||||
status / description / last_run_at / next_run_at / last_error
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_name, job_type, trigger_condition,
|
||||
trigger_config, status, description,
|
||||
last_run_at, next_run_at, last_error
|
||||
FROM biz.trigger_jobs
|
||||
WHERE job_type LIKE 'ai_%' OR job_name = 'task_generator'
|
||||
ORDER BY trigger_condition DESC, job_name
|
||||
"""
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return [_row_to_dict(cols, r) for r in rows]
|
||||
|
||||
async def update_trigger(
|
||||
self, trigger_id: int,
|
||||
status_new: str | None = None,
|
||||
cron_expression: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict:
|
||||
"""更新触发器:启用/禁用、修改 cron、改描述。
|
||||
|
||||
仅允许修改 ai_ 前缀或 task_generator 的触发器。
|
||||
"""
|
||||
if status_new is not None and status_new not in ("enabled", "disabled"):
|
||||
raise ValueError(f"非法 status: {status_new}")
|
||||
|
||||
sets: list[str] = []
|
||||
params: list = []
|
||||
if status_new is not None:
|
||||
sets.append("status = %s")
|
||||
params.append(status_new)
|
||||
if cron_expression is not None:
|
||||
sets.append("trigger_config = jsonb_set(trigger_config, '{cron_expression}', to_jsonb(%s::text))")
|
||||
params.append(cron_expression)
|
||||
if description is not None:
|
||||
sets.append("description = %s")
|
||||
params.append(description)
|
||||
|
||||
if not sets:
|
||||
raise ValueError("至少修改一个字段")
|
||||
|
||||
params.append(trigger_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET {", ".join(sets)}
|
||||
WHERE id = %s
|
||||
AND (job_type LIKE 'ai_%%' OR job_name = 'task_generator')
|
||||
RETURNING id, job_name, job_type, trigger_condition,
|
||||
trigger_config, status, description,
|
||||
last_run_at, next_run_at, last_error
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise ValueError("触发器不存在或不可修改")
|
||||
cols = [d[0] for d in cur.description]
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
return _row_to_dict(cols, row)
|
||||
|
||||
# ── 预热进度(app2_finance 72 组合)──────────────────────
|
||||
|
||||
async def get_prewarm_progress(self, site_id: int) -> dict:
|
||||
"""查询 app2_finance 72 组合缓存进度。
|
||||
|
||||
返回:total=72, done=N, missing=[{time_dimension, area}], last_updated
|
||||
"""
|
||||
time_dims = (
|
||||
"this_month", "last_month", "this_week", "last_week",
|
||||
"this_quarter", "last_quarter", "last_3_months", "last_6_months",
|
||||
)
|
||||
areas = (
|
||||
"all", "hall", "hallA", "hallB", "hallC",
|
||||
"vip", "snooker", "mahjong", "ktv",
|
||||
)
|
||||
expected = {f"{t}__{a}" for t in time_dims for a in areas}
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT target_id, max(created_at) AS last_updated
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = 'app2_finance'
|
||||
AND site_id = %s
|
||||
AND target_id LIKE %s ESCAPE '\\'
|
||||
GROUP BY target_id
|
||||
""",
|
||||
(site_id, r'%\_\_%'),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
done_map = {r[0]: r[1] for r in rows}
|
||||
missing = sorted(expected - set(done_map.keys()))
|
||||
last = max(done_map.values()) if done_map else None
|
||||
return {
|
||||
"total": len(expected),
|
||||
"done": len(expected & set(done_map.keys())),
|
||||
"missing": [
|
||||
{"target_id": m, "time_dimension": m.split("__")[0], "area": m.split("__")[1]}
|
||||
for m in missing
|
||||
],
|
||||
"last_updated": last.isoformat() if last else None,
|
||||
}
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ from typing import Any
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services import fdw_queries
|
||||
from app.services.runtime_context import get_runtime_context, task_runtime_filter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -260,7 +261,8 @@ async def get_coach_board(
|
||||
detail="最近6个月不支持客源储值排序",
|
||||
)
|
||||
|
||||
start_date, end_date = _calc_date_range(time)
|
||||
runtime_ctx = get_runtime_context(site_id)
|
||||
start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
|
||||
start_str = str(start_date)
|
||||
end_str = str(end_date)
|
||||
|
||||
@@ -415,20 +417,22 @@ def _query_coach_tasks(
|
||||
|
||||
result: dict[int, dict] = {}
|
||||
try:
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
# 狭义召回+回访完成数:均从 coach_tasks 统计,status='completed' 表示助教亲自完成
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT assistant_id, task_type, COUNT(*) AS cnt
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = ANY(%s)
|
||||
AND site_id = %s
|
||||
{runtime_clause}
|
||||
AND completed_at >= %s::date
|
||||
AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
AND status = 'completed'
|
||||
GROUP BY assistant_id, task_type
|
||||
""",
|
||||
(assistant_ids, site_id, start_date, end_date),
|
||||
[assistant_ids, site_id, *runtime_params, start_date, end_date],
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
aid, task_type, cnt = row[0], row[1], row[2] or 0
|
||||
@@ -470,13 +474,27 @@ def _batch_ideal_days(conn: Any, site_id: int, member_ids: list[int]) -> dict[in
|
||||
return result
|
||||
|
||||
|
||||
def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[dict]]:
|
||||
"""批量查询客户-助教服务明细(loyal 维度 coachDetails 用)。每个客户前 5 个。"""
|
||||
def _batch_coach_details(
|
||||
conn: Any,
|
||||
site_id: int,
|
||||
member_ids: list[int],
|
||||
*,
|
||||
ref_date: date | None = None,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""批量查询客户-助教服务明细(loyal 维度 coachDetails 用)。每个客户前 5 个。
|
||||
|
||||
ref_date 默认从 RuntimeContext 取业务日,用于把 60 天消费窗口的上界落到 ``ref_date`` 上,
|
||||
避免 sandbox 模式下读到 sandbox_date 之后的真实消费。
|
||||
"""
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
|
||||
ref = ref_date or as_runtime_today_param(site_id, conn=conn)
|
||||
result: dict[int, list[dict]] = {mid: [] for mid in member_ids}
|
||||
try:
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# CHANGE 2026-03-29 | coach_spend 改为从 dwd_assistant_service_log 聚合 60 天消费
|
||||
# CHANGE 2026-05-02 | 用 ref_date(业务日)替代 CURRENT_DATE,沙箱不读「未来」
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ri.member_id,
|
||||
@@ -493,7 +511,8 @@ def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict
|
||||
SUM(ledger_amount) AS spend_60d
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE is_delete = 0
|
||||
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
|
||||
AND create_time >= (%s::date - INTERVAL '60 days')
|
||||
AND create_time < (%s::date + INTERVAL '1 day')
|
||||
AND tenant_member_id = ANY(%s)
|
||||
GROUP BY tenant_member_id, site_assistant_id
|
||||
) s60 ON ri.member_id = s60.tenant_member_id
|
||||
@@ -502,7 +521,7 @@ def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict
|
||||
AND (da.leave_status IS NULL OR da.leave_status = 0)
|
||||
ORDER BY ri.member_id, ri.rs_display DESC
|
||||
""",
|
||||
(member_ids, member_ids),
|
||||
(ref, ref, member_ids, member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
mid = row[0]
|
||||
@@ -690,7 +709,8 @@ async def get_finance_board(
|
||||
- area≠all 时 overview 覆盖逻辑保留
|
||||
- compare=1 时对上期执行同样缓存/日粒度逻辑
|
||||
"""
|
||||
start_date, end_date = _calc_date_range(time)
|
||||
runtime_ctx = get_runtime_context(site_id)
|
||||
start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
|
||||
start_str = str(start_date)
|
||||
end_str = str(end_date)
|
||||
|
||||
|
||||
@@ -234,23 +234,14 @@ class ChatService:
|
||||
INSERT INTO biz.ai_conversations
|
||||
(user_id, nickname, app_id, site_id, context_type, context_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, EXTRACT(EPOCH FROM created_at)::bigint
|
||||
RETURNING id
|
||||
""",
|
||||
(str(user_id), nickname, APP_ID, site_id, context_type, context_id),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
new_id = result[0]
|
||||
created_ts = result[1]
|
||||
|
||||
# 生成 session_id 并回写(格式:conv_{id}_{timestamp})
|
||||
session_id = f"conv_{new_id}_{created_ts}"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_conversations SET session_id = %s WHERE id = %s
|
||||
""",
|
||||
(session_id, new_id),
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
|
||||
# session_id 初始保持 NULL,首次对话由百炼返回后再回写。
|
||||
# 参见 P14 spec §2.3:后端不再自生 session_id,交由百炼云端管理。
|
||||
conn.commit()
|
||||
return new_id
|
||||
except Exception:
|
||||
@@ -274,6 +265,34 @@ class ChatService:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@trace_service("保存百炼 session_id", "Save bailian session ID")
|
||||
def save_session_id(self, chat_id: int, session_id: str) -> None:
|
||||
"""流式回复完成后,将百炼返回的 session_id 回写 ai_conversations。
|
||||
|
||||
multi-turn 启用:
|
||||
- 首次对话 session_id=NULL → 百炼分配新 session → 这里回写
|
||||
- 下次对话 get_session_id 返回该值 → 传给百炼关联历史上下文
|
||||
|
||||
幂等:同一对话多次调用覆盖最新 session_id(通常保持稳定)。
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.ai_conversations SET session_id = %s WHERE id = %s",
|
||||
(session_id, chat_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.warning(
|
||||
"保存 session_id 失败: chat_id=%s", chat_id, exc_info=True,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CHAT-2: 消息列表
|
||||
# ------------------------------------------------------------------
|
||||
@@ -662,7 +681,10 @@ class ChatService:
|
||||
"""查询客户近 30 天消费金额(items_sum 口径)。
|
||||
|
||||
⚠️ DWD-DOC 规则 1: 使用 ledger_amount(items_sum 口径),禁用 consume_money。
|
||||
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE,沙箱不读「未来」消费。
|
||||
"""
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref = as_runtime_today_param(site_id, conn=conn)
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -670,16 +692,22 @@ class ChatService:
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
|
||||
AND create_time >= (%s::date - INTERVAL '30 days')::timestamptz
|
||||
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
""",
|
||||
(member_id,),
|
||||
(member_id, ref, ref),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return Decimal(str(row[0])) if row and row[0] is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _get_visit_count_30d(conn: Any, site_id: int, member_id: int) -> int | None:
|
||||
"""查询客户近 30 天到店次数。"""
|
||||
"""查询客户近 30 天到店次数。
|
||||
|
||||
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE,沙箱不读「未来」到店。
|
||||
"""
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref = as_runtime_today_param(site_id, conn=conn)
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -687,9 +715,10 @@ class ChatService:
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
|
||||
AND create_time >= (%s::date - INTERVAL '30 days')::timestamptz
|
||||
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
""",
|
||||
(member_id,),
|
||||
(member_id, ref, ref),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else None
|
||||
|
||||
@@ -147,7 +147,9 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
|
||||
if not assistant_info:
|
||||
raise HTTPException(status_code=404, detail="助教不存在")
|
||||
|
||||
now = datetime.date.today()
|
||||
# 业务时间锚:sandbox 模式下用 business_date,避免读到 sandbox_date 之后真实绩效
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
now = as_runtime_today_param(site_id, conn=conn)
|
||||
|
||||
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店)
|
||||
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
|
||||
@@ -713,7 +715,9 @@ def _build_history_months(
|
||||
4. 本月 estimated=True,历史月份 estimated=False
|
||||
5. 格式化:customers→"22人",hours→"87.5h",salary→"¥6,950"
|
||||
"""
|
||||
now = datetime.date.today()
|
||||
# 业务时间锚:sandbox 模式下用 business_date 计算最近 6 个月
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
now = as_runtime_today_param(site_id, conn=conn)
|
||||
|
||||
# 生成最近 6 个月的月份列表(含本月)
|
||||
months: list[str] = []
|
||||
|
||||
@@ -501,6 +501,9 @@ def _build_coach_tasks(
|
||||
logger.warning("批量查询助教信息失败", exc_info=True)
|
||||
|
||||
# 批量查询 60 天统计(一次 FDW 查询)
|
||||
# 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, conn=conn)
|
||||
stats_map: dict = {}
|
||||
try:
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
@@ -513,10 +516,11 @@ def _build_coach_tasks(
|
||||
WHERE tenant_member_id = %s
|
||||
AND site_assistant_id = ANY(%s)
|
||||
AND is_delete = 0
|
||||
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
|
||||
AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
|
||||
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
GROUP BY site_assistant_id
|
||||
""",
|
||||
(customer_id, assistant_ids),
|
||||
(customer_id, assistant_ids, ref_date, ref_date),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
svc = int(row[1]) if row[1] else 0
|
||||
|
||||
@@ -80,7 +80,7 @@ def _get_etl_connection(site_id: int):
|
||||
@contextmanager
|
||||
def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
|
||||
"""
|
||||
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id。
|
||||
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id + app.current_business_date。
|
||||
|
||||
⚠️ 不使用 zqyy_app 的 fdw_etl.* foreign table,而是直连 ETL 库
|
||||
查询 app.v_* RLS 视图。原因:postgres_fdw 不传递自定义 GUC 参数
|
||||
@@ -91,7 +91,31 @@ def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
|
||||
|
||||
CHANGE 2026-03-26 | ETL 连接复用:传入 etl_conn 时复用已有连接(不关闭),
|
||||
不传时新建连接并在 yield 后自动关闭。避免同一请求内多次新建连接(每次 ~2.6s)。
|
||||
CHANGE 2026-05-02 | 同时设置 app.current_business_date / app.current_runtime_mode,
|
||||
供 RLS 视图层(C 方案)做日期上界裁剪。conn=None 时降级 live。
|
||||
"""
|
||||
from app.services.runtime_context import (
|
||||
MODE_LIVE,
|
||||
MODE_SANDBOX,
|
||||
get_runtime_context,
|
||||
)
|
||||
|
||||
# 业务日:优先从 zqyy_app 业务库的 RuntimeContext 读取;conn 不可用时降级为系统今天
|
||||
bd_str = ""
|
||||
rt_mode = MODE_LIVE
|
||||
try:
|
||||
if conn is not None:
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
bd_str = ctx.business_date.isoformat()
|
||||
rt_mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
|
||||
else:
|
||||
from datetime import date as _date
|
||||
bd_str = _date.today().isoformat()
|
||||
except Exception:
|
||||
from datetime import date as _date
|
||||
bd_str = _date.today().isoformat()
|
||||
rt_mode = MODE_LIVE
|
||||
|
||||
owned = etl_conn is None
|
||||
if owned:
|
||||
etl_conn = _get_etl_connection(site_id)
|
||||
@@ -99,6 +123,8 @@ def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
|
||||
cur.execute("SET LOCAL app.current_business_date = %s", (bd_str,))
|
||||
cur.execute("SET LOCAL app.current_runtime_mode = %s", (rt_mode,))
|
||||
yield cur
|
||||
etl_conn.commit()
|
||||
finally:
|
||||
@@ -180,33 +206,53 @@ def get_last_visit_days(
|
||||
"""
|
||||
批量查询客户距上次到店天数。
|
||||
|
||||
来源: app.v_dws_member_consumption_summary.days_since_last(基于结算单)。
|
||||
FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录。
|
||||
来源: app.v_dws_member_consumption_summary。
|
||||
consumption_summary 按 stat_date 有多行快照,取最新一行。
|
||||
|
||||
CHANGE 2026-05-02 | 修复客户看板「最近到店」数据不准的问题:
|
||||
- 旧版直接用 days_since_last(ETL 在 stat_date 那天预计算的快照值)。
|
||||
若 ETL 没跑、跑得迟、或 sandbox_date 与 stat_date 不一致,结果就会严重失真。
|
||||
- 新版改为实时计算:``business_date - last_consume_date``,
|
||||
仅取 ``stat_date <= business_date`` 的快照行,沙箱模式下也能拿到一致结果。
|
||||
|
||||
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
|
||||
"""
|
||||
if not member_ids:
|
||||
return {}
|
||||
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
|
||||
ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
|
||||
result: dict[int, int | None] = {}
|
||||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, days_since_last
|
||||
SELECT DISTINCT ON (member_id)
|
||||
member_id,
|
||||
last_consume_date,
|
||||
stat_date
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = ANY(%s)
|
||||
AND days_since_last IS NOT NULL
|
||||
AND stat_date <= %s
|
||||
ORDER BY member_id, stat_date DESC
|
||||
""",
|
||||
(member_ids,),
|
||||
(member_ids, ref_date),
|
||||
)
|
||||
seen: set[int] = set()
|
||||
for row in cur.fetchall():
|
||||
mid = row[0]
|
||||
if mid not in seen:
|
||||
seen.add(mid)
|
||||
result[mid] = row[1]
|
||||
last_consume = row[1]
|
||||
if last_consume is None:
|
||||
result[mid] = None
|
||||
continue
|
||||
try:
|
||||
# last_consume_date 在 DWS 中是 date;少数实现可能给 timestamp,统一裁剪
|
||||
if hasattr(last_consume, "date"):
|
||||
last_consume = last_consume.date()
|
||||
days = (ref_date - last_consume).days
|
||||
result[mid] = max(days, 0)
|
||||
except Exception:
|
||||
result[mid] = None
|
||||
|
||||
return result
|
||||
|
||||
@@ -420,22 +466,33 @@ def batch_query_for_task_list(
|
||||
|
||||
# 3. 最后到店天数(基于消费汇总表,口径=结算单)
|
||||
# FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
|
||||
# CHANGE 2026-05-02 | 实时按 business_date - last_consume_date 计算,
|
||||
# 不再依赖 ETL 预计算的 days_since_last(解决看板显示偏差)。
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
_ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, days_since_last
|
||||
SELECT DISTINCT ON (member_id)
|
||||
member_id, last_consume_date, stat_date
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = ANY(%s)
|
||||
AND days_since_last IS NOT NULL
|
||||
AND stat_date <= %s
|
||||
ORDER BY member_id, stat_date DESC
|
||||
""",
|
||||
(member_ids,),
|
||||
(member_ids, _ref_date),
|
||||
)
|
||||
seen_members: set[int] = set()
|
||||
for row in cur.fetchall():
|
||||
mid = row[0]
|
||||
if mid not in seen_members:
|
||||
seen_members.add(mid)
|
||||
last_visit_map[mid] = row[1]
|
||||
last_consume = row[1]
|
||||
if last_consume is None:
|
||||
last_visit_map[mid] = None
|
||||
continue
|
||||
try:
|
||||
if hasattr(last_consume, "date"):
|
||||
last_consume = last_consume.date()
|
||||
last_visit_map[mid] = max((_ref_date - last_consume).days, 0)
|
||||
except Exception:
|
||||
last_visit_map[mid] = None
|
||||
|
||||
# 4. RS 指数
|
||||
cur.execute(
|
||||
@@ -486,10 +543,11 @@ def batch_query_for_task_list(
|
||||
WHERE sl.site_assistant_id = %s
|
||||
AND sl.tenant_member_id = ANY(%s)
|
||||
AND sl.is_delete = 0
|
||||
AND sl.create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
|
||||
AND sl.create_time >= (%s::date - INTERVAL '60 days')::timestamptz
|
||||
AND sl.create_time < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
GROUP BY sl.tenant_member_id
|
||||
""",
|
||||
(assistant_id, member_ids),
|
||||
(assistant_id, member_ids, _ref_date, _ref_date),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
recent60d_map[row[0]] = {
|
||||
@@ -559,15 +617,19 @@ def batch_query_for_task_list(
|
||||
|
||||
# 8. 绩效档位配置(用于构建 tier_nodes + bonus_money 计算)
|
||||
# CHANGE 2026-03-24 | 增加 bonus_deduction_ratio 用于打赏课抽成差额计算
|
||||
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE,沙箱按当时生效档位
|
||||
from app.services.runtime_context import as_runtime_today_param as _rt_today
|
||||
_ref_date = _rt_today(site_id, conn=conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tier_id, tier_code, tier_name, tier_level,
|
||||
min_hours, max_hours, base_deduction, bonus_deduction_ratio
|
||||
FROM app.v_cfg_performance_tier
|
||||
WHERE effective_from <= CURRENT_DATE
|
||||
AND effective_to >= CURRENT_DATE
|
||||
WHERE effective_from <= %s::date
|
||||
AND effective_to >= %s::date
|
||||
ORDER BY tier_level
|
||||
"""
|
||||
""",
|
||||
(_ref_date, _ref_date),
|
||||
)
|
||||
tier_rows = cur.fetchall()
|
||||
performance_tiers = [
|
||||
@@ -640,17 +702,21 @@ def get_performance_tiers(
|
||||
|
||||
返回 [{tier_id, tier_code, tier_name, tier_level, min_hours, max_hours,
|
||||
base_deduction, bonus_deduction_ratio}, ...]。
|
||||
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE,沙箱按当时生效档位
|
||||
"""
|
||||
from app.services.runtime_context import as_runtime_today_param as _rt_today
|
||||
ref_date = _rt_today(site_id, conn=conn)
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tier_id, tier_code, tier_name, tier_level,
|
||||
min_hours, max_hours, base_deduction, bonus_deduction_ratio
|
||||
FROM app.v_cfg_performance_tier
|
||||
WHERE effective_from <= CURRENT_DATE
|
||||
AND effective_to >= CURRENT_DATE
|
||||
WHERE effective_from <= %s::date
|
||||
AND effective_to >= %s::date
|
||||
ORDER BY tier_level
|
||||
"""
|
||||
""",
|
||||
(ref_date, ref_date),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
@@ -680,15 +746,18 @@ def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
|
||||
查询失败时返回空 dict(调用方应优雅降级)。
|
||||
"""
|
||||
try:
|
||||
from app.services.runtime_context import as_runtime_today_param as _rt_today
|
||||
ref_date = _rt_today(site_id, conn=conn)
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT level_code, level_name
|
||||
FROM app.v_cfg_assistant_level_price
|
||||
WHERE effective_from <= CURRENT_DATE
|
||||
AND effective_to >= CURRENT_DATE
|
||||
WHERE effective_from <= %s::date
|
||||
AND effective_to >= %s::date
|
||||
ORDER BY level_code
|
||||
"""
|
||||
""",
|
||||
(ref_date, ref_date),
|
||||
)
|
||||
return {row[0]: row[1] for row in cur.fetchall()}
|
||||
except Exception:
|
||||
@@ -1198,8 +1267,11 @@ def get_coach_60d_stats(
|
||||
|
||||
来源: app.v_dwd_assistant_service_log。
|
||||
⚠️ 废单排除: is_delete = 0。
|
||||
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE,沙箱不读「未来」60 天。
|
||||
返回 {service_count, total_hours, avg_hours}。
|
||||
"""
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -1212,9 +1284,10 @@ def get_coach_60d_stats(
|
||||
WHERE site_assistant_id = %s
|
||||
AND tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
|
||||
AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
|
||||
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
""",
|
||||
(assistant_id, member_id),
|
||||
(assistant_id, member_id, ref_date, ref_date),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
@@ -1917,14 +1990,17 @@ def get_customer_board_recall(
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# 分页数据
|
||||
# CHANGE 2026-05-02 | elapsed_days/overdue_days 用 business_date 替代 CURRENT_DATE
|
||||
from app.services.runtime_context import as_runtime_today_param as _rt_today
|
||||
ref_date = _rt_today(site_id, conn=conn)
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT wi.member_id,
|
||||
dm.nickname,
|
||||
wi.ideal_interval_days,
|
||||
CURRENT_DATE - wi.last_visit_time::date AS elapsed_days,
|
||||
(CURRENT_DATE - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
|
||||
%s::date - wi.last_visit_time::date AS elapsed_days,
|
||||
(%s::date - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
|
||||
wi.visits_30d,
|
||||
wi.display_score,
|
||||
COALESCE(ca.balance, 0) AS balance
|
||||
@@ -1937,11 +2013,11 @@ def get_customer_board_recall(
|
||||
WHERE scd2_is_current = 1
|
||||
GROUP BY tenant_member_id
|
||||
) ca ON wi.member_id = ca.tenant_member_id
|
||||
WHERE 1=1 {proj_clause}
|
||||
WHERE wi.last_visit_time <= %s::date + INTERVAL '1 day' {proj_clause}
|
||||
ORDER BY wi.display_score DESC, wi.member_id
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*proj_params, page_size, offset),
|
||||
(ref_date, ref_date, ref_date, *proj_params, page_size, offset),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
@@ -2165,6 +2241,10 @@ def get_customer_board_recharge(
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# CHANGE 2026-05-02 | 60 天充值窗口、stat_date、pay_time 全部按 business_date 截断
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
f"""
|
||||
@@ -2173,7 +2253,8 @@ def get_customer_board_recharge(
|
||||
MAX(ro.pay_time::date) AS last_recharge_date,
|
||||
SUM(ro.pay_amount) AS recharge_amount,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ro.pay_time >= CURRENT_DATE - INTERVAL '60 days'
|
||||
WHERE ro.pay_time >= %s::date - INTERVAL '60 days'
|
||||
AND ro.pay_time < %s::date + INTERVAL '1 day'
|
||||
) AS recharges_60d,
|
||||
COALESCE(ca_agg.balance, 0) AS current_balance,
|
||||
cs.days_since_last
|
||||
@@ -2190,15 +2271,16 @@ def get_customer_board_recharge(
|
||||
SELECT cs2.days_since_last
|
||||
FROM app.v_dws_member_consumption_summary cs2
|
||||
WHERE cs2.member_id = ro.member_id
|
||||
AND cs2.stat_date <= %s
|
||||
ORDER BY cs2.stat_date DESC
|
||||
LIMIT 1
|
||||
) cs ON true
|
||||
WHERE 1=1 {proj_clause}
|
||||
WHERE ro.pay_time <= %s::date + INTERVAL '1 day' {proj_clause}
|
||||
GROUP BY ro.member_id, dm.nickname, ca_agg.balance, cs.days_since_last
|
||||
ORDER BY MAX(ro.pay_time::date) DESC, ro.member_id
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*proj_params, page_size, offset),
|
||||
(ref_date, ref_date, ref_date, ref_date, *proj_params, page_size, offset),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
@@ -2228,6 +2310,13 @@ def get_customer_board_recent(
|
||||
不再硬编码为 0。来源: v_dws_member_visit_detail + v_dim_member + v_dws_member_winback_index。
|
||||
按 last_visit_date 降序。
|
||||
"""
|
||||
# CHANGE 2026-05-02 | 客户看板「最近到店」修复:
|
||||
# 1) WHERE/COUNT 中的 30/60 天窗口按 business_date 计算,沙箱不读「未来」到店;
|
||||
# 2) days_ago 用 business_date - last_visit_date,与窗口对齐。
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
|
||||
ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
|
||||
proj_clause, proj_params = _project_filter_clause(project, "vd.member_id")
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
@@ -2235,9 +2324,9 @@ def get_customer_board_recent(
|
||||
f"""
|
||||
SELECT COUNT(DISTINCT vd.member_id)
|
||||
FROM app.v_dws_member_visit_detail vd
|
||||
WHERE 1=1 {proj_clause}
|
||||
WHERE vd.visit_date <= %s {proj_clause}
|
||||
""",
|
||||
proj_params,
|
||||
(ref_date, *proj_params),
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
@@ -2248,11 +2337,11 @@ def get_customer_board_recent(
|
||||
SELECT vd.member_id,
|
||||
MAX(vd.visit_date) AS last_visit_date,
|
||||
COUNT(*) AS total_visits,
|
||||
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '30 days') AS visits_30d,
|
||||
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '60 days') AS visits_60d,
|
||||
COUNT(*) FILTER (WHERE vd.visit_date >= %s::date - INTERVAL '30 days') AS visits_30d,
|
||||
COUNT(*) FILTER (WHERE vd.visit_date >= %s::date - INTERVAL '60 days') AS visits_60d,
|
||||
AVG(vd.total_consume) AS avg_spend
|
||||
FROM app.v_dws_member_visit_detail vd
|
||||
WHERE 1=1 {proj_clause}
|
||||
WHERE vd.visit_date <= %s {proj_clause}
|
||||
GROUP BY vd.member_id
|
||||
)
|
||||
SELECT ma.member_id,
|
||||
@@ -2271,14 +2360,13 @@ def get_customer_board_recent(
|
||||
ORDER BY ma.last_visit_date DESC, ma.member_id
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*proj_params, page_size, offset),
|
||||
(ref_date, ref_date, ref_date, *proj_params, page_size, offset),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
last_visit = row[2]
|
||||
# CHANGE 2026-03-29 | 补充 days_ago(距今天数)和 visits_60d
|
||||
from datetime import date as _date
|
||||
days_ago = (_date.today() - last_visit).days if last_visit else None
|
||||
# CHANGE 2026-05-02 | days_ago 按 business_date 计算,沙箱与窗口对齐
|
||||
days_ago = (ref_date - last_visit).days if last_visit else None
|
||||
items.append({
|
||||
"member_id": row[0],
|
||||
"name": row[1] or "",
|
||||
@@ -2378,6 +2466,10 @@ def get_customer_board_freq60(
|
||||
按 visit_count_60d 降序。
|
||||
CHANGE 2026-04-08 | Fix:同 spend60,DISTINCT ON 取最新快照。
|
||||
"""
|
||||
# CHANGE 2026-05-02 | freq60 全链路按 business_date 截断(stat_date <= ref_date + 8 周窗口)
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
|
||||
proj_clause, proj_params = _project_filter_clause(project, "cs.member_id")
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
@@ -2387,11 +2479,11 @@ def get_customer_board_freq60(
|
||||
FROM (
|
||||
SELECT DISTINCT ON (cs.member_id) cs.member_id
|
||||
FROM app.v_dws_member_consumption_summary cs
|
||||
WHERE 1=1 {proj_clause}
|
||||
WHERE cs.stat_date <= %s {proj_clause}
|
||||
ORDER BY cs.member_id, cs.stat_date DESC
|
||||
) sub
|
||||
""",
|
||||
proj_params,
|
||||
(ref_date, *proj_params),
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
@@ -2402,7 +2494,7 @@ def get_customer_board_freq60(
|
||||
SELECT DISTINCT ON (cs.member_id)
|
||||
cs.member_id, cs.visit_count_60d, cs.consume_amount_60d
|
||||
FROM app.v_dws_member_consumption_summary cs
|
||||
WHERE 1=1 {proj_clause}
|
||||
WHERE cs.stat_date <= %s {proj_clause}
|
||||
ORDER BY cs.member_id, cs.stat_date DESC
|
||||
)
|
||||
SELECT cs.member_id,
|
||||
@@ -2415,7 +2507,7 @@ def get_customer_board_freq60(
|
||||
ORDER BY cs.visit_count_60d DESC, cs.member_id
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*proj_params, page_size, offset),
|
||||
(ref_date, *proj_params, page_size, offset),
|
||||
)
|
||||
items = []
|
||||
member_ids = []
|
||||
@@ -2436,21 +2528,31 @@ def get_customer_board_freq60(
|
||||
|
||||
# 批量查询 8 周到店数据
|
||||
if member_ids:
|
||||
weekly_map = _get_weekly_visits_batch(cur, member_ids)
|
||||
weekly_map = _get_weekly_visits_batch(cur, member_ids, ref_date=ref_date)
|
||||
for item in items:
|
||||
item["weekly_visits"] = weekly_map.get(item["member_id"], _empty_weekly())
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[dict]]:
|
||||
def _get_weekly_visits_batch(
|
||||
cur: Any, member_ids: list[int], *, ref_date: Any = None,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""
|
||||
批量查询客户最近 8 周的到店次数(用于 freq60 维度柱状图)。
|
||||
|
||||
CHANGE 2026-04-07 | Fix-5:数据源从 v_dwd_assistant_service_log 改为
|
||||
v_dwd_settlement_head(settle_type IN (1,3)),与汇总维度口径一致。
|
||||
CHANGE 2026-05-02 | 8 周窗口锚定 ref_date(业务日),沙箱不读「未来」。
|
||||
返回 {member_id: [{val: int, pct: int}, ...]},固定 8 个元素。
|
||||
"""
|
||||
from datetime import date as _date, timedelta as _timedelta
|
||||
|
||||
if ref_date is None:
|
||||
ref_date = _date.today()
|
||||
elif hasattr(ref_date, "date") and not isinstance(ref_date, _date):
|
||||
ref_date = ref_date.date()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
WITH weekly AS (
|
||||
@@ -2460,14 +2562,15 @@ def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[
|
||||
FROM app.v_dwd_settlement_head
|
||||
WHERE member_id = ANY(%s)
|
||||
AND settle_type IN (1, 3)
|
||||
AND pay_time >= CURRENT_DATE - INTERVAL '56 days'
|
||||
AND pay_time >= %s::date - INTERVAL '56 days'
|
||||
AND pay_time < %s::date + INTERVAL '1 day'
|
||||
GROUP BY member_id, DATE_TRUNC('week', pay_time::date)
|
||||
)
|
||||
SELECT member_id, week_start, cnt
|
||||
FROM weekly
|
||||
ORDER BY member_id, week_start
|
||||
""",
|
||||
(member_ids,),
|
||||
(member_ids, ref_date, ref_date),
|
||||
)
|
||||
|
||||
from collections import defaultdict
|
||||
@@ -2477,11 +2580,9 @@ def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[
|
||||
week_key = row[1].date() if hasattr(row[1], 'date') else row[1]
|
||||
raw[row[0]][str(week_key)] = row[2]
|
||||
|
||||
# 生成最近 8 周的周一日期
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
this_monday = today - timedelta(days=today.weekday())
|
||||
weeks = [this_monday - timedelta(weeks=i) for i in range(7, -1, -1)]
|
||||
# 生成最近 8 周的周一日期,以业务日为锚
|
||||
this_monday = ref_date - _timedelta(days=ref_date.weekday())
|
||||
weeks = [this_monday - _timedelta(weeks=i) for i in range(7, -1, -1)]
|
||||
|
||||
result: dict[int, list[dict]] = {}
|
||||
for mid in member_ids:
|
||||
|
||||
@@ -259,6 +259,28 @@ async def create_note(
|
||||
import asyncio
|
||||
asyncio.create_task(_async_ai_score(note["id"], site_id, target_id, content))
|
||||
|
||||
# 触发 AI 备注分析链(App6 → App8)
|
||||
# target_type='member' 时 target_id 即 member_id;'assistant' 时不触发(AI 只分析会员备注)
|
||||
if target_type == "member":
|
||||
try:
|
||||
from app.services.trigger_scheduler import fire_event
|
||||
fire_event(
|
||||
"ai_note_created",
|
||||
{
|
||||
"site_id": site_id,
|
||||
"member_id": target_id,
|
||||
"note_content": content,
|
||||
"noted_by_name": note.get("recorded_by_name")
|
||||
or note.get("user_nickname") or "",
|
||||
"noted_by_created_at": note.get("created_at") or "",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"触发 ai_note_created 事件失败: note_id=%s member_id=%s",
|
||||
note["id"], target_id,
|
||||
)
|
||||
|
||||
return note
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -22,6 +22,13 @@ import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from app.services.runtime_context import (
|
||||
LIVE_INSTANCE_ID,
|
||||
MODE_LIVE,
|
||||
MODE_SANDBOX,
|
||||
get_runtime_context,
|
||||
task_runtime_filter,
|
||||
)
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -141,6 +148,10 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
resolved = 0
|
||||
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
runtime_ctx = get_runtime_context(site_id, conn=conn)
|
||||
runtime_now = runtime_ctx.business_now
|
||||
runtime_mode = MODE_SANDBOX if runtime_ctx.is_sandbox else MODE_LIVE
|
||||
sandbox_instance_id = runtime_ctx.sandbox_instance_id if runtime_ctx.is_sandbox else LIVE_INSTANCE_ID
|
||||
|
||||
# ── 1. 获取本门店所有 MAIN 关系对 ──
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
@@ -173,13 +184,14 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
ON sl.order_settle_id = sh.order_settle_id
|
||||
AND sl.is_delete = 0
|
||||
WHERE sh.member_id = ANY(%s)
|
||||
AND sh.pay_time <= %s
|
||||
AND (
|
||||
sh.settle_type = 1
|
||||
OR (sh.settle_type = 3 AND sl.order_assistant_type = 2)
|
||||
)
|
||||
GROUP BY sl.site_assistant_id, sh.member_id
|
||||
""",
|
||||
(member_ids,),
|
||||
(member_ids, runtime_now),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
settlement_map[(row[0], row[1])] = row[2]
|
||||
@@ -190,6 +202,7 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
SELECT sh.member_id, MAX(sh.pay_time) AS latest_pay_time
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
WHERE sh.member_id = ANY(%s)
|
||||
AND sh.pay_time <= %s
|
||||
AND (
|
||||
sh.settle_type = 1
|
||||
OR (sh.settle_type = 3 AND EXISTS (
|
||||
@@ -201,7 +214,7 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
)
|
||||
GROUP BY sh.member_id
|
||||
""",
|
||||
(member_ids,),
|
||||
(member_ids, runtime_now),
|
||||
)
|
||||
member_visited_map = {}
|
||||
for row in cur.fetchall():
|
||||
@@ -209,16 +222,18 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
|
||||
# ── 3. 获取本门店所有 active 的召回/回访任务(用于匹配) ──
|
||||
active_tasks_map: dict[tuple[int, int], list] = {} # (assistant_id, member_id) → [(id, task_type, created_at)]
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, assistant_id, member_id, task_type, created_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
{runtime_clause}
|
||||
AND status = 'active'
|
||||
AND task_type IN ('high_priority_recall', 'priority_recall', 'follow_up_visit')
|
||||
""",
|
||||
(site_id,),
|
||||
[site_id, *runtime_params],
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
key = (row[1], row[2])
|
||||
@@ -238,7 +253,7 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
try:
|
||||
result = _process_pair(
|
||||
conn, site_id, assistant_id, member_id,
|
||||
latest_pay, active_tasks,
|
||||
latest_pay, active_tasks, runtime_now, runtime_mode, sandbox_instance_id,
|
||||
)
|
||||
completed += result["completed"]
|
||||
events += result["events"]
|
||||
@@ -257,25 +272,26 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, assistant_id, task_type, created_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND member_id = %s
|
||||
{runtime_clause}
|
||||
AND status = 'active'
|
||||
AND task_type IN ('high_priority_recall', 'priority_recall')
|
||||
AND created_at < %s
|
||||
""",
|
||||
(site_id, member_id, pay_time),
|
||||
[site_id, member_id, *runtime_params, pay_time],
|
||||
)
|
||||
remaining = cur.fetchall()
|
||||
for task_id, aid, task_type, _ in remaining:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'resolved', updated_at = NOW()
|
||||
SET status = 'resolved', updated_at = %s
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
(runtime_now, task_id),
|
||||
)
|
||||
_insert_history(
|
||||
cur, task_id,
|
||||
@@ -308,6 +324,9 @@ def _process_pair(
|
||||
member_id: int,
|
||||
latest_pay_time,
|
||||
active_tasks: list[dict],
|
||||
runtime_now,
|
||||
runtime_mode: str,
|
||||
sandbox_instance_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
处理单个 MAIN 关系对的召回检测。
|
||||
@@ -339,14 +358,16 @@ def _process_pair(
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.recall_events
|
||||
(site_id, assistant_id, member_id, pay_time, task_id, task_type)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (site_id, assistant_id, member_id, (date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
|
||||
(site_id, assistant_id, member_id, pay_time, task_id, task_type,
|
||||
created_at, runtime_mode, sandbox_instance_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (site_id, assistant_id, member_id, runtime_mode, sandbox_instance_id,
|
||||
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, assistant_id, member_id, latest_pay_time,
|
||||
event_task_id, event_task_type),
|
||||
event_task_id, event_task_type, runtime_now, runtime_mode, sandbox_instance_id),
|
||||
)
|
||||
inserted = cur.fetchone()
|
||||
if inserted is None:
|
||||
@@ -367,10 +388,10 @@ def _process_pair(
|
||||
completed_at = %s,
|
||||
completed_task_type = %s,
|
||||
completion_type = 'auto',
|
||||
updated_at = NOW()
|
||||
updated_at = %s
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(latest_pay_time, task["task_type"], task["id"]),
|
||||
(latest_pay_time, task["task_type"], runtime_now, task["id"]),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
@@ -393,18 +414,19 @@ def _process_pair(
|
||||
SELECT id FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
|
||||
AND task_type = 'follow_up_visit' AND status = 'active'
|
||||
AND runtime_mode = %s AND sandbox_instance_id = %s
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
(site_id, assistant_id, member_id, runtime_mode, sandbox_instance_id),
|
||||
)
|
||||
old_follow_ups = cur.fetchall()
|
||||
for (old_id,) in old_follow_ups:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
SET status = 'inactive', updated_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(old_id,),
|
||||
(runtime_now, old_id),
|
||||
)
|
||||
_insert_history(
|
||||
cur, old_id,
|
||||
@@ -423,11 +445,14 @@ def _process_pair(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type, status,
|
||||
expires_at, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, NOW(), NOW())
|
||||
expires_at, created_at, updated_at, runtime_mode, sandbox_instance_id)
|
||||
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, assistant_id, member_id, expires_at),
|
||||
(
|
||||
site_id, assistant_id, member_id, expires_at, runtime_now,
|
||||
runtime_now, runtime_mode, sandbox_instance_id,
|
||||
),
|
||||
)
|
||||
new_follow_up_id = cur.fetchone()[0]
|
||||
_insert_history(
|
||||
|
||||
263
apps/backend/app/services/runtime_context.py
Normal file
263
apps/backend/app/services/runtime_context.py
Normal file
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""业务运行上下文与业务时钟服务。
|
||||
|
||||
该模块是开发/测试沙箱的统一控制层:
|
||||
- live 模式:沿用真实系统日期和正式数据。
|
||||
- sandbox 模式:业务上假设今天是配置的历史日期,并用 sandbox_instance_id 隔离写入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from app import config
|
||||
|
||||
_LOCAL_TZ = timezone(timedelta(hours=8))
|
||||
MODE_LIVE = "live"
|
||||
MODE_SANDBOX = "sandbox"
|
||||
AI_MODE_LIVE = "live"
|
||||
LIVE_INSTANCE_ID = "live"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeContext:
|
||||
"""单门店当前业务运行上下文。"""
|
||||
|
||||
site_id: int
|
||||
mode: str = MODE_LIVE
|
||||
business_day_start_hour: int = config.BUSINESS_DAY_START_HOUR
|
||||
sandbox_date: date | None = None
|
||||
sandbox_instance_id: str | None = None
|
||||
ai_mode: str = AI_MODE_LIVE
|
||||
status: str = "active"
|
||||
|
||||
@property
|
||||
def is_sandbox(self) -> bool:
|
||||
return self.mode == MODE_SANDBOX and self.sandbox_date is not None
|
||||
|
||||
@property
|
||||
def business_date(self) -> date:
|
||||
if self.is_sandbox and self.sandbox_date is not None:
|
||||
return self.sandbox_date
|
||||
now = datetime.now(_LOCAL_TZ)
|
||||
today = now.date()
|
||||
if now.hour < self.business_day_start_hour:
|
||||
return today - timedelta(days=1)
|
||||
return today
|
||||
|
||||
@property
|
||||
def business_now(self) -> datetime:
|
||||
if not self.is_sandbox:
|
||||
return datetime.now(_LOCAL_TZ)
|
||||
now = datetime.now(_LOCAL_TZ)
|
||||
return datetime.combine(self.business_date, now.timetz(), tzinfo=_LOCAL_TZ)
|
||||
|
||||
@property
|
||||
def active_sandbox_instance_id(self) -> str | None:
|
||||
if not self.is_sandbox:
|
||||
return None
|
||||
return self.sandbox_instance_id
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"site_id": self.site_id,
|
||||
"mode": self.mode,
|
||||
"business_day_start_hour": self.business_day_start_hour,
|
||||
"business_date": self.business_date.isoformat(),
|
||||
"business_now": self.business_now.isoformat(),
|
||||
"sandbox_date": self.sandbox_date.isoformat() if self.sandbox_date else None,
|
||||
"sandbox_instance_id": self.sandbox_instance_id,
|
||||
"ai_mode": self.ai_mode,
|
||||
"status": self.status,
|
||||
"is_sandbox": self.is_sandbox,
|
||||
}
|
||||
|
||||
|
||||
def new_sandbox_instance_id() -> str:
|
||||
"""生成新的沙箱实例 ID。"""
|
||||
return f"sbx_{uuid.uuid4().hex[:24]}"
|
||||
|
||||
|
||||
def _default_context(site_id: int) -> RuntimeContext:
|
||||
return RuntimeContext(site_id=site_id)
|
||||
|
||||
|
||||
def get_runtime_context(site_id: int, conn: Any | None = None) -> RuntimeContext:
|
||||
"""读取门店运行上下文。
|
||||
|
||||
表不存在或未配置时降级为 live,保证迁移前不影响正式链路。
|
||||
"""
|
||||
own_conn = conn is None
|
||||
if own_conn:
|
||||
from app.database import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mode, sandbox_date, sandbox_instance_id, ai_mode, status
|
||||
FROM biz.site_runtime_context
|
||||
WHERE site_id = %s
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
except Exception:
|
||||
if own_conn:
|
||||
conn.rollback()
|
||||
return _default_context(site_id)
|
||||
|
||||
row = cur.fetchone()
|
||||
if own_conn:
|
||||
conn.commit()
|
||||
finally:
|
||||
if own_conn:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return _default_context(site_id)
|
||||
|
||||
mode, sandbox_date, sandbox_instance_id, ai_mode, status = row
|
||||
if mode not in (MODE_LIVE, MODE_SANDBOX):
|
||||
mode = MODE_LIVE
|
||||
if mode == MODE_SANDBOX and (sandbox_date is None or not sandbox_instance_id):
|
||||
mode = MODE_LIVE
|
||||
|
||||
return RuntimeContext(
|
||||
site_id=site_id,
|
||||
mode=mode,
|
||||
sandbox_date=sandbox_date,
|
||||
sandbox_instance_id=sandbox_instance_id,
|
||||
ai_mode=ai_mode or AI_MODE_LIVE,
|
||||
status=status or "active",
|
||||
)
|
||||
|
||||
|
||||
def namespace_ai_target_id(site_id: int, target_id: str, conn: Any | None = None) -> str:
|
||||
"""按当前上下文转换 AI cache target_id。
|
||||
|
||||
前端和调用方继续使用原始 target_id;沙箱命名空间在后端统一处理。
|
||||
"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
if not ctx.is_sandbox or not ctx.sandbox_instance_id:
|
||||
return target_id
|
||||
return f"{ctx.sandbox_instance_id}:{target_id}"
|
||||
|
||||
|
||||
def task_runtime_filter(
|
||||
site_id: int,
|
||||
*,
|
||||
alias: str = "",
|
||||
conn: Any | None = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""返回 coach_tasks 等表的运行上下文过滤条件。"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
prefix = f"{alias}." if alias else ""
|
||||
if ctx.is_sandbox and ctx.sandbox_instance_id:
|
||||
return (
|
||||
f" AND {prefix}runtime_mode = %s AND {prefix}sandbox_instance_id = %s",
|
||||
[MODE_SANDBOX, ctx.sandbox_instance_id],
|
||||
)
|
||||
return (
|
||||
f" AND COALESCE({prefix}runtime_mode, 'live') = %s "
|
||||
f"AND COALESCE({prefix}sandbox_instance_id, %s) = %s",
|
||||
[MODE_LIVE, LIVE_INSTANCE_ID, LIVE_INSTANCE_ID],
|
||||
)
|
||||
|
||||
|
||||
def runtime_insert_columns(site_id: int, conn: Any | None = None) -> tuple[str, str, list[Any]]:
|
||||
"""返回 INSERT SQL 片段:列名、占位符和值。"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
return (
|
||||
"runtime_mode, sandbox_instance_id",
|
||||
"%s, %s",
|
||||
[
|
||||
MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE,
|
||||
ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def runtime_update_assignments(site_id: int, conn: Any | None = None) -> tuple[str, list[Any]]:
|
||||
"""返回 UPDATE SQL 片段,用于把运行上下文写回已有记录。"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
return (
|
||||
"runtime_mode = %s, sandbox_instance_id = %s",
|
||||
[
|
||||
MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE,
|
||||
ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def as_runtime_now_param(site_id: int, conn: Any | None = None) -> datetime:
|
||||
"""返回可传给 SQL 的业务当前时间。"""
|
||||
return get_runtime_context(site_id, conn=conn).business_now
|
||||
|
||||
|
||||
def as_runtime_today_param(site_id: int, conn: Any | None = None) -> date:
|
||||
"""返回可传给 SQL 的业务当前日期。"""
|
||||
return get_runtime_context(site_id, conn=conn).business_date
|
||||
|
||||
|
||||
def as_runtime_year_month_param(site_id: int, conn: Any | None = None) -> str:
|
||||
"""返回 'YYYY-MM' 形式的业务年月,用于 performance 等月度查询。"""
|
||||
bd = get_runtime_context(site_id, conn=conn).business_date
|
||||
return f"{bd.year:04d}-{bd.month:02d}"
|
||||
|
||||
|
||||
def as_runtime_business_now_str(site_id: int, conn: Any | None = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""返回业务当前时间的格式化字符串,用于 AI prompts 中的 current_time。"""
|
||||
return get_runtime_context(site_id, conn=conn).business_now.strftime(fmt)
|
||||
|
||||
|
||||
def business_date_upper_bound_sql(
|
||||
site_id: int,
|
||||
*,
|
||||
column: str,
|
||||
alias: str = "",
|
||||
cast: str | None = None,
|
||||
conn: Any | None = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""返回业务日上界 SQL 片段。
|
||||
|
||||
sandbox 模式下,强制把 ``column`` 限制在业务日及之前(避免读到「未来」数据)。
|
||||
live 模式下返回空片段,不影响任何逻辑。
|
||||
|
||||
cast 用于把 timestamp/timestamptz 列裁剪成日期再比较,例如 ``cast='date'``。
|
||||
"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
if not ctx.is_sandbox:
|
||||
return ("", [])
|
||||
prefix = f"{alias}." if alias else ""
|
||||
expr = f"{prefix}{column}"
|
||||
if cast:
|
||||
expr = f"({expr})::{cast}"
|
||||
return (f" AND {expr} <= %s", [ctx.business_date])
|
||||
|
||||
|
||||
def apply_runtime_session_vars(conn: Any, ctx: RuntimeContext | None = None, *, site_id: int | None = None) -> None:
|
||||
"""在已有数据库连接上设置 ``app.current_business_date`` 等 GUC 变量。
|
||||
|
||||
供 RLS 视图层(C 方案)使用:视图通过 ``current_setting('app.current_business_date', true)``
|
||||
读取业务日,再对事实/维度表做日期上界裁剪。
|
||||
|
||||
无论 live / sandbox 都设置该变量;live 下视图仍按真实 ``CURRENT_DATE`` 行为。
|
||||
"""
|
||||
if ctx is None:
|
||||
if site_id is None:
|
||||
raise ValueError("apply_runtime_session_vars 需要 ctx 或 site_id 之一")
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
bd = ctx.business_date.isoformat()
|
||||
mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT set_config('app.current_business_date', %s, true), "
|
||||
"set_config('app.current_runtime_mode', %s, true)",
|
||||
(bd, mode),
|
||||
)
|
||||
@@ -11,6 +11,7 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.services.runtime_context import as_runtime_now_param, task_runtime_filter
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,32 +72,42 @@ def run() -> dict:
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# 查询所有已过期的 active 任务
|
||||
# 查询所有已过期的 active 任务。沙箱模式按业务时间判断,并只处理当前运行实例。
|
||||
expired_tasks = []
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
FROM biz.coach_tasks
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < NOW()
|
||||
AND status = 'active'
|
||||
"""
|
||||
)
|
||||
expired_tasks = cur.fetchall()
|
||||
cur.execute("SELECT site_id FROM biz.sites WHERE is_active = true")
|
||||
site_ids = [row[0] for row in cur.fetchall()]
|
||||
for site_id in site_ids:
|
||||
runtime_now = as_runtime_now_param(site_id, conn=conn)
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, task_type, site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
{runtime_clause}
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at < %s
|
||||
AND status = 'active'
|
||||
""",
|
||||
[site_id, *runtime_params, runtime_now],
|
||||
)
|
||||
expired_tasks.extend(cur.fetchall())
|
||||
conn.commit()
|
||||
|
||||
# 逐条处理,每条独立事务
|
||||
for task_id, task_type in expired_tasks:
|
||||
for task_id, task_type, site_id in expired_tasks:
|
||||
try:
|
||||
runtime_now = as_runtime_now_param(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
SET status = 'inactive', updated_at = %s
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
(runtime_now, task_id),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
|
||||
@@ -41,6 +41,13 @@ from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from app.trace.decorators import trace_service
|
||||
from app.services.runtime_context import (
|
||||
LIVE_INSTANCE_ID,
|
||||
MODE_LIVE,
|
||||
MODE_SANDBOX,
|
||||
get_runtime_context,
|
||||
task_runtime_filter,
|
||||
)
|
||||
|
||||
|
||||
class TaskPriority(IntEnum):
|
||||
@@ -189,6 +196,14 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _runtime_values(conn, site_id: int):
|
||||
"""返回当前门店任务写入所需的运行上下文值。"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
|
||||
instance_id = ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID
|
||||
return ctx, mode, instance_id, ctx.business_now
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
@@ -210,7 +225,10 @@ def run() -> dict:
|
||||
|
||||
返回: {"created": int, "replaced": int, "skipped": int, "transferred": int}
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
stats = {"created": 0, "replaced": 0, "skipped": 0, "transferred": 0}
|
||||
run_started_at = datetime.now(timezone.utc)
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
@@ -265,6 +283,14 @@ def run() -> dict:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ── 6. 触发 AI 消费事件 — 对本次 run 新建的任务逐个触发 ai_consumption_settled
|
||||
# 仅按 created_at >= run_started_at 过滤(精确锁定本次新建),避免误触发历史任务。
|
||||
# dispatcher 内部按 (event, member_id, site_id, date) 去重,重复触发无害。
|
||||
try:
|
||||
_fire_ai_consumption_events(conn, run_started_at)
|
||||
except Exception:
|
||||
logger.exception("ai_consumption_settled 事件触发失败(不影响任务生成主流程)")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -278,6 +304,54 @@ def run() -> dict:
|
||||
return stats
|
||||
|
||||
|
||||
def _fire_ai_consumption_events(conn, run_started_at) -> None:
|
||||
"""查询本次 run 新建的任务,对每条 (site_id, member_id, assistant_id) 触发 ai_consumption_settled。
|
||||
|
||||
has_assistant 恒为 True(任务必然绑定助教)。
|
||||
dispatcher 去重确保每 member 每天 AI 链路至多跑一次。
|
||||
"""
|
||||
from app.services.trigger_scheduler import fire_event
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id, member_id, assistant_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE created_at >= %s
|
||||
AND member_id IS NOT NULL
|
||||
AND assistant_id IS NOT NULL
|
||||
""",
|
||||
(run_started_at,),
|
||||
)
|
||||
pairs = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
triggered = 0
|
||||
for row in pairs:
|
||||
site_id, member_id, assistant_id = row[0], row[1], row[2]
|
||||
try:
|
||||
fire_event(
|
||||
"ai_consumption_settled",
|
||||
{
|
||||
"site_id": site_id,
|
||||
"member_id": member_id,
|
||||
"assistant_id": assistant_id,
|
||||
"has_assistant": True,
|
||||
},
|
||||
)
|
||||
triggered += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"触发 ai_consumption_settled 失败: site_id=%s member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ai_consumption_settled 触发完成: 新建任务去重后 %d 个 member,成功触发 %d 次",
|
||||
len(pairs), triggered,
|
||||
)
|
||||
|
||||
|
||||
def _run_for_site(conn, site_id: int, stats: dict) -> None:
|
||||
"""
|
||||
单门店处理流程。
|
||||
@@ -766,9 +840,10 @@ def _run_transfer_check(
|
||||
w_ms = params["transfer_score_w_ms"]
|
||||
w_ml = params["transfer_score_w_ml"]
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from app.services.runtime_context import as_runtime_now_param
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 业务时间锚:sandbox 模式下用 business_now,避免按真实时间把已转移很久的任务再算成候选
|
||||
now = as_runtime_now_param(site_id, conn=conn)
|
||||
|
||||
for task_id, from_assistant_id, member_id, task_type, transfer_count, created_at in candidates:
|
||||
# CHANGE 2026-03-29 | 用升级倍数判定是否触发转移
|
||||
@@ -805,9 +880,7 @@ def _run_transfer_check(
|
||||
)
|
||||
entry_dates = {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 沿用上方 business_now,避免「真实今天」的入驻时间保护
|
||||
eligible = []
|
||||
for a in pool:
|
||||
aid = a["assistant_id"]
|
||||
|
||||
@@ -37,6 +37,7 @@ from decimal import Decimal
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services import fdw_queries
|
||||
from app.services.runtime_context import get_runtime_context, task_runtime_filter
|
||||
from app.services.task_generator import compute_heart_icon
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
@@ -114,15 +115,17 @@ def _verify_task_ownership(
|
||||
- 不属于当前助教 → 403
|
||||
- required_status 不匹配 → 409
|
||||
"""
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, task_type, status, is_pinned, abandon_reason,
|
||||
assistant_id, site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE id = %s
|
||||
{runtime_clause}
|
||||
""",
|
||||
(task_id,),
|
||||
[task_id, *runtime_params],
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
@@ -166,22 +169,24 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# 查询有效 + 已放弃任务(abandoned 排最后)
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id, abandon_reason
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND status IN ('active', 'abandoned')
|
||||
{runtime_clause}
|
||||
ORDER BY
|
||||
CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
|
||||
is_pinned DESC,
|
||||
priority_score DESC NULLS LAST,
|
||||
created_at ASC
|
||||
""",
|
||||
(site_id, assistant_id),
|
||||
[site_id, assistant_id, *runtime_params],
|
||||
)
|
||||
tasks = cur.fetchall()
|
||||
conn.commit()
|
||||
@@ -605,8 +610,9 @@ async def get_task_list_v2(
|
||||
# 构建排除条件:relationship_building + member_id 不在 RS 范围内
|
||||
# 当排除列表为空时不加额外条件
|
||||
exclude_clause = ""
|
||||
query_params_count: list = [site_id, assistant_id, db_status]
|
||||
query_params_page: list = [site_id, assistant_id, db_status]
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
query_params_count: list = [site_id, assistant_id, db_status, *runtime_params]
|
||||
query_params_page: list = [site_id, assistant_id, db_status, *runtime_params]
|
||||
if rb_exclude_member_ids:
|
||||
exclude_clause = (
|
||||
" AND NOT (task_type = 'relationship_building' AND member_id = ANY(%s))"
|
||||
@@ -621,6 +627,7 @@ async def get_task_list_v2(
|
||||
SELECT COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND status = %s
|
||||
{runtime_clause}
|
||||
{exclude_clause}
|
||||
""",
|
||||
query_params_count,
|
||||
@@ -636,6 +643,7 @@ async def get_task_list_v2(
|
||||
expires_at, created_at, member_id, abandon_reason
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND status = %s
|
||||
{runtime_clause}
|
||||
{exclude_clause}
|
||||
ORDER BY is_pinned DESC,
|
||||
priority_score DESC NULLS LAST,
|
||||
@@ -669,9 +677,11 @@ async def get_task_list_v2(
|
||||
recent60d_map: dict[int, dict] = {}
|
||||
batch_data: dict | None = None
|
||||
try:
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
_ref_date = as_runtime_today_param(site_id, conn=conn)
|
||||
batch_data = fdw_queries.batch_query_for_task_list(
|
||||
conn, site_id, assistant_id, member_ids,
|
||||
datetime.now().year, datetime.now().month,
|
||||
_ref_date.year, _ref_date.month,
|
||||
)
|
||||
member_info_map = batch_data["member_info"]
|
||||
balance_map = batch_data["balance"]
|
||||
@@ -685,7 +695,11 @@ async def get_task_list_v2(
|
||||
# ── 6. 查询 ai_cache 获取 aiSuggestion(优雅降级) ──
|
||||
ai_suggestion_map: dict[int, str] = {}
|
||||
try:
|
||||
member_id_strs = [str(mid) for mid in member_ids]
|
||||
runtime_ctx = get_runtime_context(site_id, conn=conn)
|
||||
if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id:
|
||||
member_id_strs = [f"{runtime_ctx.sandbox_instance_id}:{mid}" for mid in member_ids]
|
||||
else:
|
||||
member_id_strs = [str(mid) for mid in member_ids]
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -706,7 +720,8 @@ async def get_task_list_v2(
|
||||
result = row[1] if isinstance(row[1], dict) else {}
|
||||
summary = result.get("summary", "")
|
||||
if summary:
|
||||
ai_suggestion_map[int(target_id_str)] = summary
|
||||
raw_target = target_id_str.split(":", 1)[-1]
|
||||
ai_suggestion_map[int(raw_target)] = summary
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询 ai_cache aiSuggestion 失败", exc_info=True)
|
||||
@@ -802,8 +817,11 @@ def build_performance_summary(
|
||||
当 batch_data 为 None 时(如无任务的空列表场景),回退到独立查询。
|
||||
课时/档位/客户数从 monthly_summary(每日更新)取实时数据,
|
||||
不再依赖月初结算的 salary_calc。收入仍从 salary_calc 取(如有)。
|
||||
|
||||
CHANGE 2026-05-02 | now 改用 RuntimeContext.business_date,沙箱不读「未来」月份。
|
||||
"""
|
||||
now = datetime.now()
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
now = as_runtime_today_param(site_id, conn=conn)
|
||||
year, month = now.year, now.month
|
||||
|
||||
if batch_data:
|
||||
@@ -971,15 +989,17 @@ async def get_task_by_member(
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, task_type
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
|
||||
AND status = 'active'
|
||||
{runtime_clause}
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
[site_id, assistant_id, member_id, *runtime_params],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
@@ -1020,16 +1040,18 @@ async def get_task_detail(
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# ── 1. 查询任务基础信息 ──
|
||||
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id, abandon_reason,
|
||||
assistant_id, site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE id = %s
|
||||
{runtime_clause}
|
||||
""",
|
||||
(task_id,),
|
||||
[task_id, *runtime_params],
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
@@ -1090,6 +1112,12 @@ async def get_task_detail(
|
||||
# ── 3. 查询维客线索 ──
|
||||
retention_clues = []
|
||||
try:
|
||||
runtime_ctx = get_runtime_context(site_id, conn=conn)
|
||||
member_target_id = (
|
||||
f"{runtime_ctx.sandbox_instance_id}:{member_id}"
|
||||
if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id
|
||||
else str(member_id)
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -1136,7 +1164,7 @@ async def get_task_detail(
|
||||
AND cache_type IN ('app4_analysis', 'app5_talking_points')
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(str(member_id), site_id),
|
||||
(member_target_id, site_id),
|
||||
)
|
||||
seen_types: set[str] = set()
|
||||
for cache_row in cur.fetchall():
|
||||
@@ -1173,8 +1201,10 @@ async def get_task_detail(
|
||||
|
||||
# CHANGE 2026-03-25 | 统计范围:近60天;列表不限
|
||||
# 预估规则:当月且日期 ≤ 5号
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
# CHANGE 2026-05-02 | today 改用 business_date,沙箱不读「未来」60 天
|
||||
from datetime import timedelta
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
today = as_runtime_today_param(site_id, conn=conn)
|
||||
cutoff_60d = today - timedelta(days=60)
|
||||
is_estimate_month = today.day <= 5
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -19,6 +22,34 @@ from app.trace.decorators import trace_service
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _invoke_handler(handler: Callable, **kwargs: Any) -> Any:
|
||||
"""统一调用 handler,自动识别 sync / async。
|
||||
|
||||
- sync handler:直接返回结果
|
||||
- async handler:
|
||||
- 当前线程有 running loop → loop.create_task(coro),后台异步执行
|
||||
- 当前线程无 running loop → 新起 daemon 线程跑 asyncio.run(coro),不阻塞调用方
|
||||
|
||||
说明:fire_event / check_scheduled_jobs 是 sync 函数,但部分 handler
|
||||
(如 dispatcher 注册的 AI 事件 handler)是 async def,本包装器保证正确调度。
|
||||
"""
|
||||
result = handler(**kwargs)
|
||||
if not inspect.iscoroutine(result):
|
||||
return result
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(result)
|
||||
return None
|
||||
except RuntimeError:
|
||||
# 同步线程(无 running loop):用后台线程异步执行 coroutine,不阻塞调用方
|
||||
threading.Thread(
|
||||
target=lambda coro=result: asyncio.run(coro),
|
||||
daemon=True,
|
||||
).start()
|
||||
return None
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
@@ -89,7 +120,8 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
continue
|
||||
try:
|
||||
# 将 job_id 传入 handler,handler 在最终 commit 前更新 last_run_at
|
||||
handler(payload=payload, job_id=job_id)
|
||||
# async handler 经 _invoke_handler 自动调度
|
||||
_invoke_handler(handler, payload=payload, job_id=job_id)
|
||||
executed += 1
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
@@ -136,7 +168,8 @@ def check_scheduled_jobs() -> int:
|
||||
continue
|
||||
try:
|
||||
# cron/interval handler 接受 conn + job_id,在最终 commit 前更新时间戳
|
||||
handler(conn=conn, job_id=job_id)
|
||||
# async handler 经 _invoke_handler 自动调度
|
||||
_invoke_handler(handler, conn=conn, job_id=job_id)
|
||||
# 计算 next_run_at 并更新(在 handler commit 后的新事务中)
|
||||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||||
with conn.cursor() as cur:
|
||||
@@ -276,7 +309,7 @@ def run_job_by_id(job_id: int) -> dict:
|
||||
return {"success": False, "message": f"任务 {job_name} 未注册处理器"}
|
||||
|
||||
try:
|
||||
handler()
|
||||
_invoke_handler(handler)
|
||||
# 更新 last_run_at 和 next_run_at
|
||||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||||
with conn.cursor() as cur:
|
||||
|
||||
Reference in New Issue
Block a user