feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
- 新增 GET /xcx/coaches/{id}/banner 轻量接口
- performance/records 加 coach_id 参数 + view_board_coach 权限分流
- coach/customer/performance/board/task 服务层重构
- fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
- task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
- recall_detector settle_type=3 双重限制 + 门店级 resolved
主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
- perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
- isScattered 散客标记端到端
- foodDetail/phoneFull/creator* 字段透传
主线 3: P19 指数回测框架 Phase 1+2
- 3 个指数表 stat_date 日快照模式
- 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
- task_engine 升级 HTTP 实时 + 推演回测双模式
主线 4: Core 维度层启用
- 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
- 修复 app 视图空查询问题
主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口
主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
- schema 基线与 DDL 快照同步
主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)
附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具
合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,4 +45,4 @@ LOG_LEVEL=INFO
|
||||
# ------------------------------------------------------------------------------
|
||||
# ETL 项目路径(子进程 cwd,缺省按 monorepo 相对路径推算)
|
||||
# ------------------------------------------------------------------------------
|
||||
# ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
|
||||
# ETL_PROJECT_PATH=C:/Project/NeoZQYY/apps/etl/connectors/feiqiu
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# CHANGE 2026-03-07 | 项目根目录定位:防止 junction/symlink 穿透到 D 盘
|
||||
# 背景:C:\NeoZQYY 是 junction → D:\NeoZQYY\...\repo,
|
||||
# 背景:C:\Project\NeoZQYY 是 junction → D:\NeoZQYY\...\repo,
|
||||
# Path(__file__).resolve() 和 absolute() 都可能解析到 D 盘,
|
||||
# 导致加载 D 盘的 .env(路径全指向 D 盘),ETL 命令因此携带错误路径。
|
||||
# 策略:环境变量 > 已知固定路径 > __file__ 推算(最后手段)
|
||||
|
||||
@@ -14,13 +14,27 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.xcx_coaches import CoachDetailResponse
|
||||
from app.schemas.xcx_coaches import CoachBannerResponse, CoachDetailResponse
|
||||
from app.services import coach_service
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/coaches", tags=["小程序助教"])
|
||||
|
||||
|
||||
@router.get("/{coach_id}/banner", response_model=CoachBannerResponse)
|
||||
@trace_service("获取助教 banner", "Get coach banner")
|
||||
async def get_coach_banner(
|
||||
coach_id: int,
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
):
|
||||
"""
|
||||
助教 banner 轻量信息(仅 name / level / store_name)。
|
||||
|
||||
比 /{coach_id} 快一个数量级,供 PERF-2 等只需 banner 的页面调用。
|
||||
"""
|
||||
return await coach_service.get_coach_banner(coach_id, user.site_id)
|
||||
|
||||
|
||||
@router.get("/{coach_id}", response_model=CoachDetailResponse)
|
||||
@trace_service("获取助教详情", "Get coach detail")
|
||||
async def get_coach_detail(
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved, require_permission
|
||||
@@ -20,6 +20,7 @@ from app.schemas.xcx_performance import (
|
||||
PerformanceRecordsResponse,
|
||||
)
|
||||
from app.services import performance_service
|
||||
from app.services.role import get_user_permissions
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])
|
||||
@@ -46,9 +47,29 @@ async def get_performance_records(
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
coach_id: int | None = Query(None, description="目标助教 ID(仅管理员可用)"),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""绩效明细(PERF-2)。"""
|
||||
"""
|
||||
绩效明细(PERF-2)。
|
||||
|
||||
权限分流(请求路径):
|
||||
- 不带 coach_id(查自己):要求 view_tasks 权限,assistant_id 由 user 绑定决定
|
||||
- 带 coach_id(查他人):要求 view_board_coach 权限(manager/head_coach/staff),
|
||||
assistant_id 直接用传入值;同 site 由 user.site_id 隐式约束
|
||||
"""
|
||||
user_perms = await get_user_permissions(user.user_id, user.site_id)
|
||||
|
||||
if coach_id is None:
|
||||
if "view_tasks" not in user_perms:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
return await performance_service.get_records(
|
||||
user.user_id, user.site_id, year, month, page, page_size,
|
||||
)
|
||||
|
||||
if "view_board_coach" not in user_perms:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
return await performance_service.get_records(
|
||||
user.user_id, user.site_id, year, month, page, page_size
|
||||
user.user_id, user.site_id, year, month, page, page_size,
|
||||
assistant_id_override=coach_id,
|
||||
)
|
||||
|
||||
@@ -8,12 +8,29 @@ from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class PerformanceMetrics(CamelModel):
|
||||
monthly_hours: float
|
||||
monthly_salary: float
|
||||
customer_balance: float
|
||||
tasks_completed: int
|
||||
perf_current: float
|
||||
perf_target: float
|
||||
"""绩效概览 -- 与任务页 PerformanceSummary 统一数据源(monthly_summary 实时值)。"""
|
||||
# 核心绩效字段(来自 build_performance_summary,与任务页一致)
|
||||
total_hours: float = 0
|
||||
total_income: float = 0
|
||||
total_customers: int = 0
|
||||
month_label: str = ""
|
||||
tier_nodes: list[float] = []
|
||||
basic_hours: float = 0
|
||||
bonus_hours: float = 0
|
||||
current_tier: int = 0
|
||||
next_tier_hours: float = 0
|
||||
tier_completed: bool = False
|
||||
bonus_money: float = 0
|
||||
income_trend: str = ""
|
||||
income_trend_dir: str = "up"
|
||||
prev_month: str = ""
|
||||
current_tier_label: str = ""
|
||||
# 助教详情页专属扩展字段
|
||||
customer_balance: float = 0
|
||||
tasks_completed: int = 0
|
||||
# 兼容旧字段名(前端渐进适配)
|
||||
monthly_hours: float = 0
|
||||
monthly_salary: float = 0
|
||||
|
||||
|
||||
class IncomeItem(CamelModel):
|
||||
@@ -56,6 +73,7 @@ class TopCustomer(CamelModel):
|
||||
# CHANGE 2026-03-29 | str → float:后端返回原始数字,前端 WXS 格式化(避免 NaN)
|
||||
balance: float
|
||||
consume: float
|
||||
is_scattered: bool = False # 散客标识,前端据此置灰名称
|
||||
|
||||
|
||||
class CoachServiceRecord(CamelModel):
|
||||
@@ -71,6 +89,7 @@ class CoachServiceRecord(CamelModel):
|
||||
income: float
|
||||
date: str
|
||||
perf_hours: str | None = None
|
||||
is_scattered: bool = False # 散客标识,前端据此置灰名称
|
||||
|
||||
|
||||
class HistoryMonth(CamelModel):
|
||||
@@ -94,6 +113,20 @@ class CoachNoteItem(CamelModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
class CoachTaskStats(CamelModel):
|
||||
"""当月任务完成统计(回访/召回分类)。"""
|
||||
callback: int = 0 # follow_up_visit 完成数
|
||||
recall: int = 0 # high_priority_recall + priority_recall 完成数
|
||||
|
||||
|
||||
class CoachBannerResponse(CamelModel):
|
||||
"""助教 banner 轻量响应(仅 name / level / store_name),用于 PERF-2 等只需 banner 的页面。"""
|
||||
id: int
|
||||
name: str
|
||||
level: str = ""
|
||||
store_name: str = ""
|
||||
|
||||
|
||||
class CoachDetailResponse(CamelModel):
|
||||
"""COACH-1 响应。"""
|
||||
# 基础信息
|
||||
@@ -101,6 +134,8 @@ class CoachDetailResponse(CamelModel):
|
||||
name: str
|
||||
avatar: str
|
||||
level: str
|
||||
# 门店名称:跟随被查看助教所在门店,供小程序 banner 展示
|
||||
store_name: str = ""
|
||||
skills: list[str] = []
|
||||
work_years: float = 0
|
||||
customer_count: int = 0
|
||||
@@ -111,6 +146,8 @@ class CoachDetailResponse(CamelModel):
|
||||
income: IncomeSection
|
||||
# 档位
|
||||
tier_nodes: list[float] = []
|
||||
# 当月任务完成统计
|
||||
task_stats: CoachTaskStats = CoachTaskStats()
|
||||
# 任务分组
|
||||
visible_tasks: list[CoachTaskItem] = []
|
||||
hidden_tasks: list[CoachTaskItem] = []
|
||||
|
||||
@@ -64,6 +64,7 @@ class ConsumptionRecord(CamelModel):
|
||||
coaches: list[CoachServiceItem] = []
|
||||
food_amount: float | None = None
|
||||
food_orig_price: float | None = None
|
||||
food_detail: str | None = None
|
||||
total_amount: float
|
||||
total_orig_price: float | None = None
|
||||
pay_method: str | None = None
|
||||
@@ -76,6 +77,8 @@ class RetentionClue(CamelModel):
|
||||
class CustomerNote(CamelModel):
|
||||
id: int
|
||||
tag_label: str
|
||||
creator_name: str = ""
|
||||
creator_role: str = ""
|
||||
created_at: str
|
||||
content: str
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class DateGroupRecord(CamelModel):
|
||||
member_id: int | None = None # 前端用于计算头像颜色
|
||||
avatar_char: str | None = None # PERF-1 返回,PERF-2 不返回
|
||||
heart_score: float | None = None # RS 分数,前端用于 heart-icon 组件
|
||||
is_scattered: bool = False # 散客(member_id ≤ 0)标识,前端据此置灰
|
||||
time_range: str
|
||||
hours: str
|
||||
course_type: str
|
||||
|
||||
@@ -406,9 +406,9 @@ def _query_coach_tasks(
|
||||
"""
|
||||
查询助教任务完成数(BOARD-1 task 维度)。
|
||||
|
||||
CHANGE 2026-04-08 | Fix-13 改造:
|
||||
- recall: 广义召回数(从 biz.recall_events 统计,按天去重,不重复叠加)
|
||||
- callback: 回访完成数(从 biz.coach_tasks 统计,status='completed')
|
||||
CHANGE 2026-04-08 | Fix-13 改造
|
||||
CHANGE 2026-04-13 | 狭义召回:recall 改为从 coach_tasks 统计 status='completed',
|
||||
不再使用 recall_events(广义)。recall + callback 统一口径。
|
||||
"""
|
||||
if not assistant_ids:
|
||||
return {}
|
||||
@@ -416,41 +416,27 @@ def _query_coach_tasks(
|
||||
result: dict[int, dict] = {}
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 广义召回数:从 recall_events 统计(天然去重)
|
||||
# 狭义召回+回访完成数:均从 coach_tasks 统计,status='completed' 表示助教亲自完成
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id, COUNT(*) AS recall_count
|
||||
FROM biz.recall_events
|
||||
WHERE assistant_id = ANY(%s)
|
||||
AND site_id = %s
|
||||
AND pay_time >= %s::date
|
||||
AND pay_time < (%s::date + INTERVAL '1 day')
|
||||
GROUP BY assistant_id
|
||||
""",
|
||||
(assistant_ids, site_id, start_date, end_date),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
result.setdefault(row[0], {"recall": 0, "callback": 0})
|
||||
result[row[0]]["recall"] = row[1] or 0
|
||||
|
||||
# 回访完成数:从 coach_tasks 统计
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id, COUNT(*) AS callback_count
|
||||
SELECT assistant_id, task_type, COUNT(*) AS cnt
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = ANY(%s)
|
||||
AND site_id = %s
|
||||
AND completed_at >= %s::date
|
||||
AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
|
||||
AND status = 'completed'
|
||||
AND task_type = 'follow_up_visit'
|
||||
GROUP BY assistant_id
|
||||
GROUP BY assistant_id, task_type
|
||||
""",
|
||||
(assistant_ids, site_id, start_date, end_date),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
result.setdefault(row[0], {"recall": 0, "callback": 0})
|
||||
result[row[0]]["callback"] = row[1] or 0
|
||||
aid, task_type, cnt = row[0], row[1], row[2] or 0
|
||||
result.setdefault(aid, {"recall": 0, "callback": 0})
|
||||
if task_type in ("high_priority_recall", "priority_recall"):
|
||||
result[aid]["recall"] += cnt
|
||||
elif task_type == "follow_up_visit":
|
||||
result[aid]["callback"] += cnt
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -25,6 +25,7 @@ from decimal import Decimal
|
||||
|
||||
from app.services import fdw_queries
|
||||
from app.services.task_generator import compute_heart_icon
|
||||
from app.services.task_manager import build_performance_summary
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,6 +88,51 @@ def _format_currency(amount: float) -> str:
|
||||
# ── 6.1 核心函数 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@trace_service("获取助教 banner", "Get coach banner")
|
||||
async def get_coach_banner(coach_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
助教 banner 轻量信息(仅 name / level / store_name)。
|
||||
|
||||
用途:小程序需要展示助教 banner 但不需要详情页全套数据时
|
||||
(如 PERF-2 业绩明细页 banner)。比 get_coach_detail 快一个数量级
|
||||
(仅 2~3 条 SQL,跳过绩效/TOP/服务记录/任务/备注/历史月份)。
|
||||
"""
|
||||
conn = _get_biz_connection()
|
||||
try:
|
||||
# 1. name + level(来自 v_dim_assistant + level_map)
|
||||
info = fdw_queries.get_assistant_info(conn, site_id, coach_id)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404, detail="助教不存在")
|
||||
|
||||
# 2. store_name(来自业务库 biz.sites)
|
||||
store_name = ""
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT site_name FROM biz.sites WHERE site_id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
store_name = row[0] or ""
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": coach_id,
|
||||
"name": info.get("name", ""),
|
||||
"level": info.get("level", ""),
|
||||
"store_name": store_name,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@trace_service("获取助教详情", "Get coach detail")
|
||||
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
@@ -103,14 +149,35 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
|
||||
|
||||
now = datetime.date.today()
|
||||
|
||||
# 绩效数据(当月)
|
||||
salary_this = fdw_queries.get_salary_calc(
|
||||
conn, site_id, coach_id, now.year, now.month
|
||||
)
|
||||
if not salary_this:
|
||||
salary_this = {}
|
||||
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店)
|
||||
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
|
||||
# (psycopg2 的 InFailedSqlTransaction),导致此处 SELECT 拿不到结果。
|
||||
store_name = ""
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT site_name FROM biz.sites WHERE site_id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
store_name = row[0] or ""
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("查询 store_name 失败,降级为空", exc_info=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# customerBalance:该助教所有客户余额合计
|
||||
# 绩效数据:统一使用 build_performance_summary(与任务页同源,数据来自 monthly_summary 实时值)
|
||||
try:
|
||||
perf_summary = build_performance_summary(conn, site_id, coach_id)
|
||||
except Exception:
|
||||
logger.warning("build_performance_summary 失败,降级为空", exc_info=True)
|
||||
perf_summary = {}
|
||||
|
||||
# customerBalance:该助教所有客户余额合计(绩效概览之外的扩展数据)
|
||||
customer_balance = 0.0
|
||||
try:
|
||||
top_custs = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=1000)
|
||||
@@ -121,76 +188,65 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
|
||||
except Exception:
|
||||
logger.warning("查询 customerBalance 失败,降级为 0", exc_info=True)
|
||||
|
||||
# tasksCompleted:当月已完成任务数
|
||||
# tasksCompleted + taskStats:当月已完成任务数,按类型分组
|
||||
# tasksCompleted + taskStats:当月已完成任务数(狭义:助教亲自完成,不含 resolved)
|
||||
tasks_completed = 0
|
||||
task_stats = {"callback": 0, "recall": 0}
|
||||
try:
|
||||
month_start = now.replace(day=1)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
SELECT task_type, COUNT(*) AS cnt
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s
|
||||
AND status = 'completed'
|
||||
AND updated_at >= %s
|
||||
GROUP BY task_type
|
||||
""",
|
||||
(coach_id, month_start),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
tasks_completed = row[0] if row else 0
|
||||
for row in cur.fetchall():
|
||||
task_type, cnt = row[0], row[1]
|
||||
tasks_completed += cnt
|
||||
if task_type == "follow_up_visit":
|
||||
task_stats["callback"] += cnt
|
||||
elif task_type in ("high_priority_recall", "priority_recall"):
|
||||
task_stats["recall"] += cnt
|
||||
except Exception:
|
||||
logger.warning("查询 tasksCompleted 失败,降级为 0", exc_info=True)
|
||||
|
||||
# customerCount:不重复客户数(从 top_customers 获取)
|
||||
customer_count = 0
|
||||
try:
|
||||
cc_map = fdw_queries.get_monthly_customer_count(
|
||||
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
|
||||
)
|
||||
customer_count = sum(cc_map.values())
|
||||
except Exception:
|
||||
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
|
||||
# customerCount:从绩效概览获取,回退到独立查询
|
||||
customer_count = perf_summary.get("total_customers", 0)
|
||||
if not customer_count:
|
||||
try:
|
||||
cc_map = fdw_queries.get_monthly_customer_count(
|
||||
conn, site_id, coach_id, [now.strftime("%Y-%m-01")]
|
||||
)
|
||||
customer_count = sum(cc_map.values())
|
||||
except Exception:
|
||||
logger.warning("查询 customerCount 失败,降级为 0", exc_info=True)
|
||||
|
||||
# 构建 performance 字段:合并绩效概览 + 助教详情专属扩展字段
|
||||
performance = {
|
||||
"monthly_hours": salary_this.get("total_hours", 0.0),
|
||||
# CHANGE 2026-03-26 | 到手 = base_income + bonus_income + bonus_money + room_income(DWS 层已扣抽成)
|
||||
"monthly_salary": (
|
||||
salary_this.get("assistant_pd_money_total", 0.0)
|
||||
+ salary_this.get("assistant_cx_money_total", 0.0)
|
||||
+ salary_this.get("bonus_money", 0.0)
|
||||
+ salary_this.get("room_income", 0.0)
|
||||
),
|
||||
**perf_summary,
|
||||
# 助教详情页专属字段(绩效概览中没有的)
|
||||
"customer_balance": customer_balance,
|
||||
"tasks_completed": tasks_completed,
|
||||
"perf_current": salary_this.get("total_hours", 0.0),
|
||||
# CHANGE 2026-03-19 | perf_target 从 tier_nodes 推算,不再依赖 salary_calc 的硬编码 0
|
||||
"perf_target": 0.0, # 占位,下方用 tier_nodes 覆盖
|
||||
# 兼容旧字段名(前端渐进适配)
|
||||
"monthly_hours": perf_summary.get("total_hours", 0.0),
|
||||
"monthly_salary": perf_summary.get("total_income", 0.0),
|
||||
}
|
||||
|
||||
# ── 扩展模块(独立 try/except 优雅降级)──
|
||||
|
||||
# 收入明细 + 档位
|
||||
# 收入明细
|
||||
try:
|
||||
income = _build_income(conn, site_id, coach_id, now)
|
||||
except Exception:
|
||||
logger.warning("构建 income 失败,降级为空", exc_info=True)
|
||||
income = {"this_month": [], "last_month": []}
|
||||
|
||||
try:
|
||||
tier_nodes = _build_tier_nodes(conn, site_id)
|
||||
except Exception:
|
||||
logger.warning("构建 tierNodes 失败,降级为 fallback", exc_info=True)
|
||||
tier_nodes = list(_FALLBACK_TIER_NODES)
|
||||
|
||||
# CHANGE 2026-03-19 | 用 tier_nodes 推算 perf_target(下一档 min_hours)
|
||||
current_hours = performance["perf_current"]
|
||||
perf_target = tier_nodes[-1] if tier_nodes else 0.0 # 默认最高档
|
||||
for node in tier_nodes:
|
||||
if node > current_hours:
|
||||
perf_target = node
|
||||
break
|
||||
performance["perf_target"] = perf_target
|
||||
|
||||
# TOP 客户
|
||||
try:
|
||||
top_customers = _build_top_customers(conn, site_id, coach_id)
|
||||
@@ -231,17 +287,20 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
|
||||
"id": coach_id,
|
||||
"name": assistant_info.get("name", ""),
|
||||
"avatar": assistant_info.get("avatar", ""),
|
||||
"level": salary_this.get("coach_level", assistant_info.get("level", "")),
|
||||
"level": perf_summary.get("current_tier_label", assistant_info.get("level", "")),
|
||||
"store_name": store_name,
|
||||
"skills": assistant_info.get("skills", []),
|
||||
"work_years": assistant_info.get("work_years", 0.0),
|
||||
"customer_count": customer_count,
|
||||
"hire_date": assistant_info.get("hire_date"),
|
||||
# 绩效
|
||||
# 绩效(包含 tier_nodes、total_hours 等完整字段)
|
||||
"performance": performance,
|
||||
# 收入
|
||||
"income": income,
|
||||
# 档位
|
||||
"tier_nodes": tier_nodes,
|
||||
# 档位(保留顶级字段兼容前端已有逻辑)
|
||||
"tier_nodes": perf_summary.get("tier_nodes", list(_FALLBACK_TIER_NODES)),
|
||||
# 当月任务完成统计(回访/召回分类)
|
||||
"task_stats": task_stats,
|
||||
# 任务分组
|
||||
"visible_tasks": task_groups["visible_tasks"],
|
||||
"hidden_tasks": task_groups["hidden_tasks"],
|
||||
@@ -377,7 +436,12 @@ def _build_top_customers(
|
||||
result = []
|
||||
for i, cust in enumerate(raw):
|
||||
mid = cust.get("member_id")
|
||||
name = cust.get("customer_name", "")
|
||||
# 散客(member_id ≤ 0)展示"散客待转换会员",头像首字统一为"?"
|
||||
is_scattered = not mid or mid <= 0
|
||||
if is_scattered:
|
||||
name = "散客待转换会员"
|
||||
else:
|
||||
name = cust.get("customer_name", "")
|
||||
score = relation_map.get(mid, 0.0)
|
||||
|
||||
# 四级 heart icon 映射(P6 AC3,rs_display 0-10 刻度)
|
||||
@@ -398,7 +462,8 @@ def _build_top_customers(
|
||||
result.append({
|
||||
"id": mid or 0,
|
||||
"name": name,
|
||||
"initial": _get_initial(name),
|
||||
"initial": "?" if is_scattered else _get_initial(name),
|
||||
"is_scattered": is_scattered,
|
||||
"avatar_gradient": _get_avatar_gradient(i),
|
||||
"heart_emoji": heart_emoji,
|
||||
"score": f"{score:.2f}",
|
||||
@@ -428,7 +493,13 @@ def _build_service_records(
|
||||
|
||||
result = []
|
||||
for i, rec in enumerate(raw):
|
||||
name = rec.get("customer_name", "")
|
||||
# 散客(member_id ≤ 0)展示"散客待转换会员",头像首字统一为"?"
|
||||
mid = rec.get("member_id")
|
||||
is_scattered = not mid or mid <= 0
|
||||
if is_scattered:
|
||||
name = "散客待转换会员"
|
||||
else:
|
||||
name = rec.get("customer_name", "")
|
||||
course_type = rec.get("course_type", "")
|
||||
|
||||
# type_class 映射
|
||||
@@ -446,11 +517,12 @@ def _build_service_records(
|
||||
result.append({
|
||||
"customer_id": rec.get("member_id"),
|
||||
"customer_name": name,
|
||||
"initial": _get_initial(name),
|
||||
"initial": "?" if is_scattered else _get_initial(name),
|
||||
"is_scattered": is_scattered,
|
||||
"avatar_gradient": _get_avatar_gradient(i),
|
||||
"type": course_type or "课程",
|
||||
"type_class": type_class,
|
||||
"table": rec.get("table_name") or None,
|
||||
"table": rec.get("table_name") or "",
|
||||
"duration": f"{hours:.1f}h",
|
||||
"income": float(income),
|
||||
"date": date_str,
|
||||
@@ -481,7 +553,15 @@ def _build_task_groups(
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s
|
||||
AND status IN ('active', 'inactive', 'abandoned')
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY
|
||||
CASE task_type
|
||||
WHEN 'high_priority_recall' THEN 0
|
||||
WHEN 'priority_recall' THEN 1
|
||||
WHEN 'follow_up_visit' THEN 2
|
||||
WHEN 'relationship_building' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
created_at DESC
|
||||
""",
|
||||
(coach_id,),
|
||||
)
|
||||
|
||||
@@ -287,32 +287,43 @@ def _build_retention_clues(customer_id: int, conn) -> list[dict]:
|
||||
return [{"type": r[0] or "", "text": r[1] or ""} for r in rows]
|
||||
|
||||
|
||||
NOTE_TYPE_LABELS = {"normal": "备注", "follow_up": "回访", "system": "系统", "ai": "AI"}
|
||||
|
||||
|
||||
def _build_notes(customer_id: int, conn) -> list[dict]:
|
||||
"""
|
||||
构建 notes 模块。
|
||||
|
||||
查询 biz.notes WHERE target_type='member',最多 20 条,按 created_at 倒序。
|
||||
JOIN auth.users 获取创建者名称,JOIN auth.user_site_roles + auth.roles 获取角色。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, type, created_at, content
|
||||
FROM biz.notes
|
||||
WHERE target_type = 'member'
|
||||
AND target_id = %s
|
||||
ORDER BY created_at DESC
|
||||
SELECT n.id, n.type, n.created_at, n.content,
|
||||
COALESCE(u.nickname, '') AS creator_name,
|
||||
COALESCE(r.name, '') AS role_name
|
||||
FROM biz.notes n
|
||||
LEFT JOIN auth.users u ON n.user_id = u.id
|
||||
LEFT JOIN auth.user_site_roles usr
|
||||
ON n.user_id = usr.user_id
|
||||
AND usr.is_removed = false
|
||||
LEFT JOIN auth.roles r ON usr.role_id = r.id
|
||||
WHERE n.target_type = 'member'
|
||||
AND n.target_id = %s
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(customer_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
NOTE_TYPE_LABELS = {"normal": "备注", "system": "系统", "ai": "AI"}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"tag_label": NOTE_TYPE_LABELS.get(r[1], r[1] or "备注"),
|
||||
"creator_name": r[4] or "",
|
||||
"creator_role": r[5] or "",
|
||||
"created_at": r[2].strftime("%Y-%m-%d %H:%M") if r[2] else "",
|
||||
"content": r[3] or "",
|
||||
}
|
||||
@@ -323,22 +334,92 @@ def _build_notes(customer_id: int, conn) -> list[dict]:
|
||||
# ── 3.3 消费记录 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_coaches_from_json(coaches_json: list, level_map: dict) -> list[dict]:
|
||||
"""从 SQL json_agg 结果构建 coaches 子数组。"""
|
||||
coaches = []
|
||||
for c in coaches_json:
|
||||
level_code = c.get("assistant_level")
|
||||
level_name = level_map.get(level_code, "") if level_code else ""
|
||||
hrs = float(c.get("service_hours") or 0)
|
||||
fee = float(c.get("ledger_amount") or 0)
|
||||
if fee or hrs:
|
||||
coaches.append({
|
||||
"name": c.get("assistant_name", ""),
|
||||
"level": level_name,
|
||||
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
|
||||
"course_type": c.get("course_type") or "基础课",
|
||||
"hours": f"{hrs:.1f}h",
|
||||
"perf_hours": None,
|
||||
"fee": fee,
|
||||
})
|
||||
return coaches
|
||||
|
||||
|
||||
def _build_settlement_card(rec: dict, table_name_map: dict, level_map: dict) -> dict:
|
||||
"""从一条结算单级记录构建前端卡片数据。"""
|
||||
import json as _json
|
||||
coaches_json = rec.get("coaches_json") or []
|
||||
if isinstance(coaches_json, str):
|
||||
coaches_json = _json.loads(coaches_json)
|
||||
coaches = _build_coaches_from_json(coaches_json, level_map)
|
||||
|
||||
settle_time = rec.get("settle_time")
|
||||
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
|
||||
start_raw = rec.get("start_time")
|
||||
end_raw = rec.get("end_time")
|
||||
start_str = start_raw.strftime("%H:%M") if start_raw else None
|
||||
end_str = end_raw.strftime("%H:%M") if end_raw else None
|
||||
|
||||
svc_hours = rec.get("service_hours", 0.0)
|
||||
dur_h = int(svc_hours)
|
||||
dur_m = int((svc_hours - dur_h) * 60)
|
||||
duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"
|
||||
|
||||
table_fee = rec.get("table_charge_money", 0.0)
|
||||
adjust = rec.get("adjust_amount", 0.0)
|
||||
table_orig = None
|
||||
if adjust > 0.01:
|
||||
table_orig = round(table_fee + adjust, 2)
|
||||
|
||||
total_actual = rec.get("total_amount", 0.0)
|
||||
consume_orig = rec.get("consume_money", 0.0)
|
||||
total_orig = consume_orig if consume_orig > total_actual + 0.01 else None
|
||||
|
||||
return {
|
||||
"id": rec.get("id", ""),
|
||||
"type": "table",
|
||||
"date": date_str,
|
||||
"table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
|
||||
"start_time": start_str,
|
||||
"end_time": end_str,
|
||||
"duration": duration_str,
|
||||
"table_fee": table_fee,
|
||||
"table_orig_price": table_orig,
|
||||
"coaches": coaches,
|
||||
"food_amount": rec.get("goods_money", 0.0),
|
||||
"food_orig_price": None,
|
||||
"food_detail": rec.get("drinks"),
|
||||
"total_amount": total_actual,
|
||||
"total_orig_price": total_orig,
|
||||
"pay_method": "",
|
||||
"recharge_amount": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_consumption_records(
|
||||
customer_id: int, site_id: int, conn, *, etl_conn: Any = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建 consumptionRecords 模块。
|
||||
|
||||
调用 fdw_queries.get_consumption_records() 获取结算单列表。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(items_sum 口径)。
|
||||
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
|
||||
按结算单粒度返回,同一结算单下多个助教聚合到 coaches 数组。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)(items_sum 口径)。
|
||||
⚠️ 废单排除: is_delete = 0,正向交易: settle_type IN (1, 3)。
|
||||
"""
|
||||
raw_records = fdw_queries.get_consumption_records(
|
||||
conn, site_id, customer_id, limit=5, offset=0, etl_conn=etl_conn
|
||||
)
|
||||
|
||||
result = []
|
||||
# 批量查询台桌名称
|
||||
table_ids = list({rec.get("table_id") for rec in raw_records if rec.get("table_id")})
|
||||
table_name_map: dict = {}
|
||||
@@ -364,81 +445,7 @@ def _build_consumption_records(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for rec in raw_records:
|
||||
# 构建 coaches 子数组
|
||||
coaches = []
|
||||
pd_money = rec.get("assistant_pd_money", 0.0)
|
||||
cx_money = rec.get("assistant_cx_money", 0.0)
|
||||
level_code = rec.get("assistant_level")
|
||||
level_name = level_map.get(level_code, "") if level_code else ""
|
||||
|
||||
if pd_money:
|
||||
hrs = rec.get("service_hours", 0.0)
|
||||
coaches.append({
|
||||
"name": rec.get("assistant_name", ""),
|
||||
"level": level_name,
|
||||
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
|
||||
"course_type": "基础课",
|
||||
"hours": f"{hrs:.1f}h",
|
||||
"perf_hours": None,
|
||||
"fee": pd_money,
|
||||
})
|
||||
if cx_money:
|
||||
coaches.append({
|
||||
"name": rec.get("assistant_name", ""),
|
||||
"level": level_name,
|
||||
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
|
||||
"course_type": "激励课",
|
||||
"hours": "0h",
|
||||
"perf_hours": None,
|
||||
"fee": cx_money,
|
||||
})
|
||||
|
||||
settle_time = rec.get("settle_time")
|
||||
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
|
||||
start_raw = rec.get("start_time")
|
||||
end_raw = rec.get("end_time")
|
||||
# 格式化时间为 HH:mm
|
||||
start_str = start_raw.strftime("%H:%M") if start_raw else None
|
||||
end_str = end_raw.strftime("%H:%M") if end_raw else None
|
||||
# 格式化时长为 Xh Xmin
|
||||
svc_hours = rec.get("service_hours", 0.0)
|
||||
dur_h = int(svc_hours)
|
||||
dur_m = int((svc_hours - dur_h) * 60)
|
||||
duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"
|
||||
|
||||
# 台费原价:table_charge_money + adjust_amount(台费调整/大客户优惠)
|
||||
table_fee = rec.get("table_charge_money", 0.0)
|
||||
adjust = rec.get("adjust_amount", 0.0)
|
||||
table_orig = None
|
||||
if adjust > 0.01:
|
||||
table_orig = round(table_fee + adjust, 2)
|
||||
|
||||
# 总金额原价(consume_money > items_sum 时显示)
|
||||
total_actual = rec.get("total_amount", 0.0)
|
||||
consume_orig = rec.get("consume_money", 0.0)
|
||||
total_orig = consume_orig if consume_orig > total_actual + 0.01 else None
|
||||
|
||||
result.append({
|
||||
"id": rec.get("id", ""),
|
||||
"type": "table",
|
||||
"date": date_str,
|
||||
"table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
|
||||
"start_time": start_str,
|
||||
"end_time": end_str,
|
||||
"duration": duration_str,
|
||||
"table_fee": table_fee,
|
||||
"table_orig_price": table_orig,
|
||||
"coaches": coaches,
|
||||
"food_amount": rec.get("goods_money", 0.0),
|
||||
"food_orig_price": None,
|
||||
"total_amount": total_actual,
|
||||
"total_orig_price": total_orig,
|
||||
"pay_method": "",
|
||||
"recharge_amount": None,
|
||||
})
|
||||
|
||||
return result
|
||||
return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records]
|
||||
|
||||
|
||||
# ── 3.4 关联助教任务(T2-2)──────────────────────────────
|
||||
@@ -996,10 +1003,9 @@ def _get_consumption_records_by_month(
|
||||
*, etl_conn=None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
按月份过滤的消费记录,复用 _build_consumption_records 的构建逻辑。
|
||||
按月份过滤的消费记录,复用 _build_settlement_card 构建逻辑。
|
||||
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount。
|
||||
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)(items_sum 口径)。
|
||||
"""
|
||||
raw_records = fdw_queries.get_consumption_records(
|
||||
conn, site_id, customer_id, limit=200, offset=0, etl_conn=etl_conn,
|
||||
@@ -1031,77 +1037,7 @@ def _get_consumption_records_by_month(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = []
|
||||
for rec in raw_records:
|
||||
coaches = []
|
||||
pd_money = rec.get("assistant_pd_money", 0.0)
|
||||
cx_money = rec.get("assistant_cx_money", 0.0)
|
||||
level_code = rec.get("assistant_level")
|
||||
level_name = level_map.get(level_code, "") if level_code else ""
|
||||
|
||||
if pd_money:
|
||||
hrs = rec.get("service_hours", 0.0)
|
||||
coaches.append({
|
||||
"name": rec.get("assistant_name", ""),
|
||||
"level": level_name,
|
||||
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
|
||||
"course_type": "基础课",
|
||||
"hours": f"{hrs:.1f}h",
|
||||
"perf_hours": None,
|
||||
"fee": pd_money,
|
||||
})
|
||||
if cx_money:
|
||||
coaches.append({
|
||||
"name": rec.get("assistant_name", ""),
|
||||
"level": level_name,
|
||||
"level_color": LEVEL_COLOR_MAP.get(level_name, ""),
|
||||
"course_type": "激励课",
|
||||
"hours": "0h",
|
||||
"perf_hours": None,
|
||||
"fee": cx_money,
|
||||
})
|
||||
|
||||
settle_time = rec.get("settle_time")
|
||||
date_str = settle_time.strftime("%Y-%m-%d") if settle_time else ""
|
||||
start_raw = rec.get("start_time")
|
||||
end_raw = rec.get("end_time")
|
||||
start_str = start_raw.strftime("%H:%M") if start_raw else None
|
||||
end_str = end_raw.strftime("%H:%M") if end_raw else None
|
||||
svc_hours = rec.get("service_hours", 0.0)
|
||||
dur_h = int(svc_hours)
|
||||
dur_m = int((svc_hours - dur_h) * 60)
|
||||
duration_str = f"{dur_h}h {dur_m}min" if dur_h > 0 else f"{dur_m}min"
|
||||
|
||||
table_fee = rec.get("table_charge_money", 0.0)
|
||||
adjust = rec.get("adjust_amount", 0.0)
|
||||
table_orig = None
|
||||
if adjust > 0.01:
|
||||
table_orig = round(table_fee + adjust, 2)
|
||||
|
||||
total_actual = rec.get("total_amount", 0.0)
|
||||
consume_orig = rec.get("consume_money", 0.0)
|
||||
total_orig = consume_orig if consume_orig > total_actual + 0.01 else None
|
||||
|
||||
result.append({
|
||||
"id": rec.get("id", ""),
|
||||
"type": "table",
|
||||
"date": date_str,
|
||||
"table_name": table_name_map.get(rec.get("table_id"), str(rec.get("table_id", ""))),
|
||||
"start_time": start_str,
|
||||
"end_time": end_str,
|
||||
"duration": duration_str,
|
||||
"table_fee": table_fee,
|
||||
"table_orig_price": table_orig,
|
||||
"coaches": coaches,
|
||||
"food_amount": rec.get("goods_money", 0.0),
|
||||
"food_orig_price": None,
|
||||
"total_amount": total_actual,
|
||||
"total_orig_price": total_orig,
|
||||
"pay_method": "",
|
||||
"recharge_amount": None,
|
||||
})
|
||||
|
||||
return result
|
||||
return [_build_settlement_card(rec, table_name_map, level_map) for rec in raw_records]
|
||||
|
||||
|
||||
def _get_consumption_month_summary(
|
||||
|
||||
@@ -180,10 +180,9 @@ def get_last_visit_days(
|
||||
"""
|
||||
批量查询客户距上次到店天数。
|
||||
|
||||
来源: app.v_dwd_assistant_service_log。
|
||||
废单排除: is_delete = 0(RLS 视图使用 is_delete 而非 is_trash)。
|
||||
时间字段: create_time(对应 design.md 中的 settle_time)。
|
||||
会员字段: tenant_member_id(对应 design.md 中的 member_id)。
|
||||
来源: app.v_dws_member_consumption_summary.days_since_last(基于结算单)。
|
||||
FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录。
|
||||
consumption_summary 按 stat_date 有多行快照,取最新一行。
|
||||
|
||||
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
|
||||
"""
|
||||
@@ -194,16 +193,20 @@ def get_last_visit_days(
|
||||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tenant_member_id,
|
||||
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
|
||||
GROUP BY tenant_member_id
|
||||
SELECT member_id, days_since_last
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = ANY(%s)
|
||||
AND days_since_last IS NOT NULL
|
||||
ORDER BY member_id, stat_date DESC
|
||||
""",
|
||||
(member_ids,),
|
||||
)
|
||||
seen: set[int] = set()
|
||||
for row in cur.fetchall():
|
||||
result[row[0]] = row[1]
|
||||
mid = row[0]
|
||||
if mid not in seen:
|
||||
seen.add(mid)
|
||||
result[mid] = row[1]
|
||||
|
||||
return result
|
||||
|
||||
@@ -415,19 +418,24 @@ def batch_query_for_task_list(
|
||||
for row in cur.fetchall():
|
||||
balance_map[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal("0")
|
||||
|
||||
# 3. 最后到店天数
|
||||
# 3. 最后到店天数(基于消费汇总表,口径=结算单)
|
||||
# FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tenant_member_id,
|
||||
CURRENT_DATE - MAX(create_time::date) AS days_since_visit
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = ANY(%s) AND is_delete = 0
|
||||
GROUP BY tenant_member_id
|
||||
SELECT member_id, days_since_last
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = ANY(%s)
|
||||
AND days_since_last IS NOT NULL
|
||||
ORDER BY member_id, stat_date DESC
|
||||
""",
|
||||
(member_ids,),
|
||||
)
|
||||
seen_members: set[int] = set()
|
||||
for row in cur.fetchall():
|
||||
last_visit_map[row[0]] = row[1]
|
||||
mid = row[0]
|
||||
if mid not in seen_members:
|
||||
seen_members.add(mid)
|
||||
last_visit_map[mid] = row[1]
|
||||
|
||||
# 4. RS 指数
|
||||
cur.execute(
|
||||
@@ -687,6 +695,63 @@ def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
|
||||
return {}
|
||||
|
||||
|
||||
@trace_service(description_zh="获取服务记录汇总", description_en="Get service records summary")
|
||||
def get_service_records_summary(
|
||||
conn: Any,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
year: int,
|
||||
month: int,
|
||||
) -> dict:
|
||||
"""
|
||||
单条 SQL 直接聚合月度汇总:count / sum(hours) / sum(income)。
|
||||
|
||||
用途:替代"先拉全量再 Python 算 summary"的高耗模式(PERF-2)。
|
||||
口径与 get_service_records 完全一致(同表/同 JOIN/同费率公式)。
|
||||
返回 { total_count, total_hours, total_hours_raw, total_income };
|
||||
total_hours_raw 暂沿用 0.0(DWD 层服务记录无折前时长字段)。
|
||||
"""
|
||||
start_date = f"{year}-{month:02d}-01"
|
||||
if month == 12:
|
||||
end_date = f"{year + 1}-01-01"
|
||||
else:
|
||||
end_date = f"{year}-{month + 1:02d}-01"
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(sl.income_seconds / 3600.0), 0),
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN sl.skill_name ILIKE '%%激励%%' OR sl.skill_name ILIKE '%%超休%%'
|
||||
THEN (sl.income_seconds / 3600.0) * COALESCE(sc.bonus_course_price * (1 - sc.bonus_deduction_ratio), 0)
|
||||
ELSE (sl.income_seconds / 3600.0) * COALESCE(sc.base_course_price - sc.base_deduction, 0)
|
||||
END
|
||||
), 0)
|
||||
FROM app.v_dwd_assistant_service_log sl
|
||||
LEFT JOIN app.v_dws_assistant_salary_calc sc
|
||||
ON sl.site_assistant_id = sc.assistant_id
|
||||
AND date_trunc('month', sl.create_time)::date = sc.salary_month
|
||||
WHERE sl.site_assistant_id = %s AND sl.is_delete = 0
|
||||
AND sl.create_time >= %s::timestamptz
|
||||
AND sl.create_time < %s::timestamptz
|
||||
""",
|
||||
(assistant_id, start_date, end_date),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return {"total_count": 0, "total_hours": 0.0, "total_hours_raw": 0.0, "total_income": 0.0}
|
||||
|
||||
return {
|
||||
"total_count": int(row[0] or 0),
|
||||
"total_hours": round(float(row[1] or 0), 2),
|
||||
"total_hours_raw": 0.0, # DWD 层无折前时长字段;与原 compute_summary 行为一致
|
||||
"total_income": round(float(row[2] or 0), 2),
|
||||
}
|
||||
|
||||
|
||||
@trace_service(description_zh="获取服务记录", description_en="Get service records")
|
||||
def get_service_records(
|
||||
conn: Any,
|
||||
@@ -1008,57 +1073,72 @@ def get_consumption_records(
|
||||
"""
|
||||
查询客户消费记录(CUST-1 consumptionRecords 用)。
|
||||
|
||||
来源: app.v_dwd_assistant_service_log + v_dwd_settlement_head + v_dim_assistant。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(来自 service_log)。
|
||||
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money(来自 settlement_head)。
|
||||
⚠️ 费用拆分字段(table_charge_money, goods_money, settle_type)来自 settlement_head。
|
||||
按结算单(order_settle_id)粒度返回,同一结算单下的多个助教聚合到 coaches 数组。
|
||||
来源: v_dwd_settlement_head + v_dwd_assistant_service_log + v_dim_assistant。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 SUM(ledger_amount)(items_sum 口径)。
|
||||
⚠️ 废单排除: is_delete = 0。
|
||||
⚠️ 正向交易: settle_type IN (1, 3)。
|
||||
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
|
||||
"""
|
||||
records: list[dict] = []
|
||||
# CHANGE 2026-03-29 | CUST-3: 支持按月份过滤消费记录
|
||||
date_clause = ""
|
||||
date_params: list = []
|
||||
if start_date:
|
||||
date_clause += " AND sl.create_time >= %s::timestamptz"
|
||||
date_clause += " AND sh.create_time >= %s::timestamptz"
|
||||
date_params.append(start_date)
|
||||
if end_date:
|
||||
date_clause += " AND sl.create_time < %s::timestamptz"
|
||||
date_clause += " AND sh.create_time < %s::timestamptz"
|
||||
date_params.append(end_date)
|
||||
|
||||
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT sl.assistant_service_id AS id,
|
||||
sl.create_time AS settle_time,
|
||||
sl.start_use_time AS start_time,
|
||||
sl.last_use_time AS end_time,
|
||||
sl.income_seconds / 3600.0 AS service_hours,
|
||||
sl.ledger_amount AS total_amount,
|
||||
sl.skill_name AS course_type,
|
||||
sl.site_table_id AS table_id,
|
||||
sl.site_assistant_id AS assistant_id,
|
||||
COALESCE(da.nickname, da.real_name, '') AS assistant_name,
|
||||
da.level AS assistant_level,
|
||||
SELECT sh.order_settle_id AS id,
|
||||
sh.create_time AS settle_time,
|
||||
MIN(sl.start_use_time) AS start_time,
|
||||
MAX(sl.last_use_time) AS end_time,
|
||||
SUM(sl.income_seconds) / 3600.0 AS service_hours,
|
||||
SUM(sl.ledger_amount) AS total_amount,
|
||||
MIN(sl.site_table_id) AS table_id,
|
||||
sh.table_charge_money,
|
||||
sh.goods_money,
|
||||
sh.assistant_pd_money,
|
||||
sh.assistant_cx_money,
|
||||
sh.settle_type,
|
||||
sh.consume_money,
|
||||
sh.adjust_amount
|
||||
FROM app.v_dwd_assistant_service_log sl
|
||||
sh.adjust_amount,
|
||||
gs_agg.drinks,
|
||||
json_agg(json_build_object(
|
||||
'assistant_id', sl.site_assistant_id,
|
||||
'assistant_name', COALESCE(da.nickname, da.real_name, ''),
|
||||
'assistant_level', da.level,
|
||||
'service_hours', sl.income_seconds / 3600.0,
|
||||
'ledger_amount', sl.ledger_amount,
|
||||
'course_type', sl.skill_name
|
||||
) ORDER BY sl.ledger_amount DESC NULLS LAST) AS coaches_json
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
INNER JOIN app.v_dwd_assistant_service_log sl
|
||||
ON sh.order_settle_id = sl.order_settle_id
|
||||
AND sl.tenant_member_id = %s
|
||||
AND sl.is_delete = 0
|
||||
LEFT JOIN app.v_dim_assistant da
|
||||
ON sl.site_assistant_id = da.assistant_id
|
||||
AND da.scd2_is_current = 1
|
||||
LEFT JOIN app.v_dwd_settlement_head sh
|
||||
ON sl.order_settle_id = sh.order_settle_id
|
||||
WHERE sl.tenant_member_id = %s
|
||||
AND sl.is_delete = 0
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND da.scd2_is_current = 1
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(gs.ledger_name || '*' || gs.total_count, ' | ' ORDER BY gs.subtotal DESC) AS drinks
|
||||
FROM (
|
||||
SELECT ledger_name,
|
||||
SUM(ledger_count) AS total_count,
|
||||
SUM(ledger_amount) AS subtotal
|
||||
FROM app.v_dwd_store_goods_sale
|
||||
WHERE order_settle_id = sh.order_settle_id
|
||||
AND is_delete = 0
|
||||
GROUP BY ledger_name
|
||||
) gs
|
||||
) gs_agg ON true
|
||||
WHERE sh.settle_type IN (1, 3)
|
||||
{date_clause}
|
||||
ORDER BY sl.create_time DESC
|
||||
GROUP BY sh.order_settle_id, sh.create_time,
|
||||
sh.table_charge_money, sh.goods_money,
|
||||
sh.consume_money, sh.adjust_amount,
|
||||
gs_agg.drinks
|
||||
ORDER BY sh.create_time DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(member_id,) + tuple(date_params) + (limit, offset),
|
||||
@@ -1071,18 +1151,13 @@ def get_consumption_records(
|
||||
"end_time": row[3],
|
||||
"service_hours": float(row[4]) if row[4] is not None else 0.0,
|
||||
"total_amount": float(row[5]) if row[5] is not None else 0.0,
|
||||
"course_type": row[6] or "",
|
||||
"table_id": row[7],
|
||||
"assistant_id": row[8],
|
||||
"assistant_name": row[9] or "",
|
||||
"assistant_level": row[10], # int level code
|
||||
"table_charge_money": float(row[11]) if row[11] is not None else 0.0,
|
||||
"goods_money": float(row[12]) if row[12] is not None else 0.0,
|
||||
"assistant_pd_money": float(row[13]) if row[13] is not None else 0.0,
|
||||
"assistant_cx_money": float(row[14]) if row[14] is not None else 0.0,
|
||||
"settle_type": row[15],
|
||||
"consume_money": float(row[16]) if row[16] is not None else 0.0,
|
||||
"adjust_amount": float(row[17]) if row[17] is not None else 0.0,
|
||||
"table_id": row[6],
|
||||
"table_charge_money": float(row[7]) if row[7] is not None else 0.0,
|
||||
"goods_money": float(row[8]) if row[8] is not None else 0.0,
|
||||
"consume_money": float(row[9]) if row[9] is not None else 0.0,
|
||||
"adjust_amount": float(row[10]) if row[10] is not None else 0.0,
|
||||
"drinks": row[11],
|
||||
"coaches_json": row[12] or [],
|
||||
})
|
||||
return records
|
||||
|
||||
@@ -1741,8 +1816,13 @@ def get_coach_sv_data(
|
||||
AND ri.session_count > 0
|
||||
),
|
||||
period_consume AS (
|
||||
-- DWD-DOC 规则 1: items_sum 需拆分计算,settlement_head 无此字段
|
||||
SELECT sh.member_id,
|
||||
COALESCE(SUM(sh.items_sum), 0) AS consume_amount
|
||||
COALESCE(SUM(
|
||||
sh.table_charge_money + sh.goods_money
|
||||
+ sh.assistant_pd_money + sh.assistant_cx_money
|
||||
+ sh.electricity_money
|
||||
), 0) AS consume_amount
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
WHERE sh.member_id = ANY(SELECT member_id FROM coach_members)
|
||||
AND sh.settle_type IN (1, 3)
|
||||
@@ -2044,7 +2124,7 @@ def get_customer_board_balance(
|
||||
"name": row[1] or "",
|
||||
"balance": float(row[2]) if row[2] is not None else 0.0,
|
||||
# CHANGE 2026-03-29 | last_visit 格式化为"X天前",ideal_days 从 winback_index 获取
|
||||
"last_visit": f"{row[3]}天前" if row[3] is not None else "--",
|
||||
"last_visit": "今天" if row[3] == 0 else f"{row[3]}天前" if row[3] is not None else "--",
|
||||
"last_visit_date": row[3],
|
||||
"ideal_days": None, # balance 维度无 ideal_days,由 board_service 补充
|
||||
# CHANGE 2026-04-07 | Fix-4:consume_amount_60d 是 60 天总额,月均 = /2
|
||||
@@ -2130,7 +2210,7 @@ def get_customer_board_recharge(
|
||||
"recharges_60d": row[4] or 0,
|
||||
"current_balance": float(row[5]) if row[5] is not None else 0.0,
|
||||
# CHANGE 2026-03-29 | 补充 last_visit 和 ideal_days(头部展示用)
|
||||
"last_visit": f"{row[6]}天前" if row[6] is not None else "--",
|
||||
"last_visit": "今天" if row[6] == 0 else f"{row[6]}天前" if row[6] is not None else "--",
|
||||
"ideal_days": None, # 由 board_service 补充
|
||||
})
|
||||
|
||||
@@ -3492,12 +3572,19 @@ def get_nci_batch(
|
||||
|
||||
来源: app.v_dws_member_newconv_index(RLS 视图)
|
||||
返回: {member_id: display_score}
|
||||
|
||||
FIX 2026-04-12: 排除已转老客的会员。NCI 表只在 NEW 阶段写入,
|
||||
会员转 OLD 后不再更新,导致残留过时高分。用 WBI status='OLD' 过滤。
|
||||
"""
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM app.v_dws_member_newconv_index
|
||||
SELECT n.member_id, COALESCE(n.display_score, 0)
|
||||
FROM app.v_dws_member_newconv_index n
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.v_dws_member_winback_index w
|
||||
WHERE w.member_id = n.member_id AND w.status = 'OLD'
|
||||
)
|
||||
"""
|
||||
)
|
||||
return {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
@@ -3547,6 +3634,7 @@ def get_all_service_pairs(
|
||||
返回: [{"assistant_id", "member_id", "rs"}]
|
||||
"""
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# POOL 客户需 session_count >= 3 才纳入保底任务,MAIN/COMANAGE 无限制
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id,
|
||||
@@ -3554,6 +3642,7 @@ def get_all_service_pairs(
|
||||
COALESCE(rs_display, 0) AS rs
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE session_count > 0
|
||||
AND (os_label IN ('MAIN', 'COMANAGE') OR session_count >= 3)
|
||||
"""
|
||||
)
|
||||
return [
|
||||
|
||||
@@ -82,10 +82,18 @@ def group_records_by_date(
|
||||
|
||||
# CHANGE 2026-03-24 | 课程类型直接用数据库原始值(skill_name),不做二次映射
|
||||
raw_course_type = rec.get("course_type", "") or "基础课"
|
||||
customer_name = rec.get("customer_name") or "未知客户"
|
||||
# 散客(member_id ≤ 0)展示"散客待转换会员";
|
||||
# 真实会员姓名缺失时回退"未知客户"
|
||||
mid_for_name = rec.get("member_id")
|
||||
is_scattered = not mid_for_name or mid_for_name <= 0
|
||||
if is_scattered:
|
||||
customer_name = "散客待转换会员"
|
||||
else:
|
||||
customer_name = rec.get("customer_name") or "未知客户"
|
||||
|
||||
record_item: dict = {
|
||||
"customer_name": customer_name,
|
||||
"is_scattered": is_scattered,
|
||||
"time_range": time_range,
|
||||
"hours": f"{rec.get('service_hours', 0.0):.1f}",
|
||||
"course_type": raw_course_type,
|
||||
@@ -594,29 +602,33 @@ def _build_customer_lists(
|
||||
async def get_records(
|
||||
user_id: int, site_id: int,
|
||||
year: int, month: int, page: int, page_size: int,
|
||||
assistant_id_override: int | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
绩效明细(PERF-2)。
|
||||
|
||||
1. 获取 assistant_id
|
||||
1. 获取 assistant_id(assistant_id_override 非空时直接使用,跳过 user 绑定查询)
|
||||
2. fdw_queries.get_service_records() 带分页
|
||||
3. 按日期分组为 dateGroups(不含 avatarChar/avatarColor)
|
||||
4. 计算 summary 汇总
|
||||
5. 返回 { summary, dateGroups, hasMore }
|
||||
|
||||
assistant_id_override 用于"管理员/店长查看其他助教"场景,
|
||||
调用方负责完成越权校验后再传入目标 assistant_id。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
if assistant_id_override is not None:
|
||||
assistant_id = assistant_id_override
|
||||
else:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# 先获取全量记录用于 summary 计算
|
||||
all_records = fdw_queries.get_service_records(
|
||||
# CHANGE 2026-04-20 | 性能优化:summary 改用 SQL 聚合,
|
||||
# 不再先 limit=100000 全量拉取再 Python 算 summary
|
||||
summary = fdw_queries.get_service_records_summary(
|
||||
conn, site_id, assistant_id, year, month,
|
||||
limit=100000, offset=0,
|
||||
)
|
||||
|
||||
# 计算月度汇总
|
||||
summary = compute_summary(all_records)
|
||||
|
||||
# 分页获取记录
|
||||
offset = (page - 1) * page_size
|
||||
page_records = fdw_queries.get_service_records(
|
||||
@@ -624,8 +636,8 @@ async def get_records(
|
||||
limit=page_size, offset=offset,
|
||||
)
|
||||
|
||||
# 判断 hasMore
|
||||
has_more = len(all_records) > page * page_size
|
||||
# 判断 hasMore(由 summary.total_count 直接推算,避免再次拉全量)
|
||||
has_more = summary["total_count"] > page * page_size
|
||||
|
||||
# CHANGE 2026-03-27 | 批量查 RS 分数,注入到服务记录
|
||||
page_member_ids = list({r.get("member_id") for r in page_records if r.get("member_id")})
|
||||
|
||||
@@ -12,6 +12,10 @@ CHANGE 2026-04-08 | Fix-13 改造:
|
||||
- 扫描范围从"有 active 任务的客户"扩大为"所有 os_label='MAIN' 的关联客户"
|
||||
- 新增 recall_events 事件表记录广义召回(按天去重)
|
||||
- 无 active 任务的客户到店也生成 follow_up_visit
|
||||
|
||||
CHANGE 2026-04-12 | 召回完成逻辑调整:
|
||||
- settle_type=3 仅计入有 BONUS 服务的订单(纯商品不算到店)
|
||||
- 门店级召回解除:客户到店后,未服务助教的召回任务标记 resolved
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -72,6 +76,7 @@ def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
"""
|
||||
completed_count = 0
|
||||
event_count = 0
|
||||
resolved_count = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
@@ -89,6 +94,7 @@ def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
result = _process_site(conn, site_id)
|
||||
completed_count += result["completed"]
|
||||
event_count += result["events"]
|
||||
resolved_count += result["resolved"]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理门店召回检测失败: site_id=%s", site_id
|
||||
@@ -108,10 +114,14 @@ def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"召回完成检测完成: completed_count=%d, event_count=%d",
|
||||
completed_count, event_count,
|
||||
"召回完成检测完成: completed=%d, events=%d, resolved=%d",
|
||||
completed_count, event_count, resolved_count,
|
||||
)
|
||||
return {"completed_count": completed_count, "event_count": event_count}
|
||||
return {
|
||||
"completed_count": completed_count,
|
||||
"event_count": event_count,
|
||||
"resolved_count": resolved_count,
|
||||
}
|
||||
|
||||
|
||||
def _process_site(conn, site_id: int) -> dict:
|
||||
@@ -122,9 +132,13 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
1. 从 ETL 查询所有 os_label='MAIN' 的 (assistant_id, member_id) 对
|
||||
2. 批量查询这些客户的最新结算记录
|
||||
3. 对每个有新结算的关系对:写 recall_events + 完成任务 + 生成回访
|
||||
|
||||
CHANGE 2026-04-12 | 门店级召回解除:
|
||||
4. 客户到店后,未被服务的助教的召回任务标记 resolved
|
||||
"""
|
||||
completed = 0
|
||||
events = 0
|
||||
resolved = 0
|
||||
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
@@ -140,13 +154,15 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
main_pairs = [(r[0], r[1]) for r in cur.fetchall()]
|
||||
|
||||
if not main_pairs:
|
||||
return {"completed": 0, "events": 0}
|
||||
return {"completed": 0, "events": 0, "resolved": 0}
|
||||
|
||||
# ── 2. 批量查询这些客户的最新结算时间 ──
|
||||
member_ids = list({mid for _, mid in main_pairs})
|
||||
settlement_map: dict[tuple[int, int], object] = {} # (assistant_id, member_id) → latest_pay_time
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# 助教级结算(用于狭义完成判定)
|
||||
# settle_type=1 全部计入;settle_type=3 仅计入有 BONUS 服务的
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT sl.site_assistant_id AS assistant_id,
|
||||
@@ -157,7 +173,10 @@ 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.settle_type IN (1, 3)
|
||||
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,),
|
||||
@@ -165,6 +184,29 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
for row in cur.fetchall():
|
||||
settlement_map[(row[0], row[1])] = row[2]
|
||||
|
||||
# 门店级到店检测(含无助教服务的 settle_type=1,用于 resolved 判定)
|
||||
cur.execute(
|
||||
"""
|
||||
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.settle_type = 1
|
||||
OR (sh.settle_type = 3 AND EXISTS (
|
||||
SELECT 1 FROM app.v_dwd_assistant_service_log sl
|
||||
WHERE sl.order_settle_id = sh.order_settle_id
|
||||
AND sl.is_delete = 0
|
||||
AND sl.order_assistant_type = 2
|
||||
))
|
||||
)
|
||||
GROUP BY sh.member_id
|
||||
""",
|
||||
(member_ids,),
|
||||
)
|
||||
member_visited_map = {}
|
||||
for row in cur.fetchall():
|
||||
member_visited_map[row[0]] = row[1]
|
||||
|
||||
# ── 3. 获取本门店所有 active 的召回/回访任务(用于匹配) ──
|
||||
active_tasks_map: dict[tuple[int, int], list] = {} # (assistant_id, member_id) → [(id, task_type, created_at)]
|
||||
with conn.cursor() as cur:
|
||||
@@ -207,7 +249,56 @@ def _process_site(conn, site_id: int) -> dict:
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
return {"completed": completed, "events": events}
|
||||
# ── 5. 门店级召回解除:客户到店后,未被服务的助教任务标记 resolved ──
|
||||
# 服务助教的任务已在 Step 4 中 completed(committed),
|
||||
# 此处查到的 active 召回任务是未被服务的助教持有的
|
||||
for member_id, pay_time in member_visited_map.items():
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, assistant_id, task_type, created_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND member_id = %s
|
||||
AND status = 'active'
|
||||
AND task_type IN ('high_priority_recall', 'priority_recall')
|
||||
AND created_at < %s
|
||||
""",
|
||||
(site_id, member_id, 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()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur, task_id,
|
||||
action="customer_returned",
|
||||
old_status="active",
|
||||
new_status="resolved",
|
||||
old_task_type=task_type,
|
||||
new_task_type=task_type,
|
||||
detail={
|
||||
"reason": "customer_visited_store",
|
||||
"service_time": str(pay_time),
|
||||
},
|
||||
)
|
||||
resolved += 1
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"门店级召回解除失败: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
return {"completed": completed, "events": events, "resolved": resolved}
|
||||
|
||||
|
||||
def _process_pair(
|
||||
@@ -224,7 +315,7 @@ def _process_pair(
|
||||
CHANGE 2026-04-08 | Fix-13 改造:
|
||||
- 写 recall_events(ON CONFLICT DO NOTHING 按天去重)
|
||||
- 有 active 召回任务且 pay_time > created_at → 完成任务
|
||||
- 关闭旧回访 → 新建回访(48h)
|
||||
- 关闭旧回访 → 新建回访(72h)
|
||||
- 无 active 任务也生成回访
|
||||
|
||||
返回: {"completed": int, "events": int}
|
||||
@@ -323,9 +414,9 @@ def _process_pair(
|
||||
detail={"reason": "new_service_record", "service_time": str(latest_pay_time)},
|
||||
)
|
||||
|
||||
# ── 4. 创建新的回访任务(48h 过期) ──
|
||||
# ── 4. 创建新的回访任务(72h / 3天过期) ──
|
||||
expires_at = (
|
||||
latest_pay_time + timedelta(hours=48)
|
||||
latest_pay_time + timedelta(hours=72)
|
||||
if hasattr(latest_pay_time, '__add__') else None
|
||||
)
|
||||
cur.execute(
|
||||
|
||||
@@ -147,7 +147,7 @@ _DEFAULT_PARAMS: dict[str, float] = {
|
||||
"transfer_score_w_ms": 0.3,
|
||||
"transfer_score_w_ml": 0.2,
|
||||
"max_transfer_count": 4,
|
||||
"follow_up_visit_retention_hours": 48,
|
||||
"follow_up_visit_retention_hours": 72,
|
||||
# CHANGE 2026-03-29 | OS 分级分配:升级倍数参数
|
||||
"escalation_comanage_multiplier": 2.5,
|
||||
"escalation_pool_multiplier": 4.0,
|
||||
@@ -554,15 +554,22 @@ def _process_pair(
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
# Case B: 不同类型的 active 任务 → 关闭旧任务 + 创建新任务
|
||||
for task_id, old_type, old_expires_at, old_created_at in existing_tasks:
|
||||
if should_replace_task(old_type, new_task_type):
|
||||
# follow_up_visit 被高优先级任务顶替时,填充 expires_at 而非直接 inactive
|
||||
if old_type == "follow_up_visit" and old_expires_at is None:
|
||||
# Case B: 不同类型的 active 任务 → 混合策略
|
||||
# - follow_up_visit 被替代:保留宽限期(填 expires_at 72h)+ 新建高优先任务
|
||||
# - 其他类型被替代:原地覆盖(UPDATE task_type + priority_score)
|
||||
overridden = False
|
||||
need_create_new = False
|
||||
for i, (task_id, old_type, old_expires_at, old_created_at) in enumerate(existing_tasks):
|
||||
if not should_replace_task(old_type, new_task_type):
|
||||
continue
|
||||
|
||||
if old_type == "follow_up_visit":
|
||||
# follow_up_visit 特殊处理:填充 72h 宽限期,不关闭
|
||||
if old_expires_at is None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
SET expires_at = created_at + INTERVAL '72 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
@@ -578,60 +585,86 @@ def _process_pair(
|
||||
new_task_type=old_type,
|
||||
detail={"reason": "higher_priority_task_created"},
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="type_change_close",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=old_type,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
need_create_new = True
|
||||
stats["replaced"] += 1
|
||||
elif not overridden:
|
||||
# 非 follow_up:原地覆盖
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET task_type = %s, priority_score = %s, updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(new_task_type, float(priority_score), task_id),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="type_override",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
old_task_type=old_type,
|
||||
new_task_type=new_task_type,
|
||||
detail={"old_priority": float(priority_score)},
|
||||
)
|
||||
overridden = True
|
||||
stats["replaced"] += 1
|
||||
else:
|
||||
# 多余的同对任务:关闭
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="type_change_close",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=old_type,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
# ── 创建新任务 ──
|
||||
expires_at_val = None
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type, status,
|
||||
priority_score, expires_at, parent_task_id)
|
||||
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
new_task_type,
|
||||
float(priority_score),
|
||||
expires_at_val,
|
||||
existing_tasks[0][0] if existing_tasks else None,
|
||||
),
|
||||
)
|
||||
new_task_id = cur.fetchone()[0]
|
||||
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created",
|
||||
old_status=None,
|
||||
new_status="active",
|
||||
old_task_type=existing_tasks[0][1] if existing_tasks else None,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["created"] += 1
|
||||
# 需要新建任务的场景:
|
||||
# 1. follow_up_visit 被替代(宽限期保留原任务,需新建高优先任务)
|
||||
# 2. 没有可覆盖的非 follow_up 任务
|
||||
if need_create_new or not overridden:
|
||||
# upsert:若同类型 active 已存在(recall_detector 先行创建)则更新 priority
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type, status,
|
||||
priority_score, expires_at, parent_task_id)
|
||||
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
|
||||
ON CONFLICT (site_id, assistant_id, member_id, task_type) WHERE (status = 'active')
|
||||
DO UPDATE SET priority_score = EXCLUDED.priority_score, updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
new_task_type,
|
||||
float(priority_score),
|
||||
None,
|
||||
existing_tasks[0][0] if existing_tasks else None,
|
||||
),
|
||||
)
|
||||
new_task_id = cur.fetchone()[0]
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created",
|
||||
old_status=None,
|
||||
new_status="active",
|
||||
old_task_type=existing_tasks[0][1] if existing_tasks else None,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
stats["created"] += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -1145,6 +1178,45 @@ def _generate_baseline_relationship_tasks(
|
||||
pair["member_id"],
|
||||
)
|
||||
biz_conn.rollback()
|
||||
|
||||
# Step 5: 反向清理 -- 关闭不再符合条件的 relationship_building 任务
|
||||
# 对已有 active relationship_building 但不在 get_all_service_pairs 结果中的对,关闭
|
||||
valid_pairs = {(p["assistant_id"], p["member_id"]) for p in all_pairs}
|
||||
stale_closed = 0
|
||||
try:
|
||||
with biz_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, assistant_id, member_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND task_type = 'relationship_building'
|
||||
AND status = 'active'
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
for task_id, aid, mid in cur.fetchall():
|
||||
if (aid, mid) not in valid_pairs:
|
||||
cur.execute(
|
||||
"UPDATE biz.coach_tasks SET status = 'inactive', updated_at = NOW() WHERE id = %s",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur, task_id,
|
||||
action="pool_cleanup",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type="relationship_building",
|
||||
new_task_type="relationship_building",
|
||||
detail={"reason": "pair_no_longer_qualifies"},
|
||||
)
|
||||
stale_closed += 1
|
||||
biz_conn.commit()
|
||||
if stale_closed:
|
||||
logger.info("保底任务清理: site_id=%s, 关闭 %d 个不再符合条件的 relationship_building", site_id, stale_closed)
|
||||
except Exception:
|
||||
logger.exception("保底任务清理失败: site_id=%s", site_id)
|
||||
biz_conn.rollback()
|
||||
finally:
|
||||
biz_conn.close()
|
||||
|
||||
@@ -1158,7 +1230,7 @@ def _handle_no_task_condition(
|
||||
) -> None:
|
||||
"""
|
||||
当不满足任何任务生成条件时:
|
||||
1. follow_up_visit → 填充 expires_at = created_at + 48h
|
||||
1. follow_up_visit → 填充 expires_at = created_at + 72h
|
||||
2. high_priority_recall / priority_recall → 直接关闭(inactive)
|
||||
|
||||
CHANGE 2026-03-24 | Prompt: 修复召回任务不自动关闭 bug |
|
||||
@@ -1185,7 +1257,7 @@ def _handle_no_task_condition(
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
SET expires_at = created_at + INTERVAL '72 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | get_task_list() 中 2 处、get_task_list_v2() 中 1 处、
|
||||
# get_task_detail() 中 1 处 fdw_etl.v_dim_member / v_dws_member_assistant_relation_index
|
||||
# 改为直连 ETL 库查询 app.v_* RLS 视图。使用 fdw_queries._fdw_context()。
|
||||
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | _build_performance_summary 中 tier_nodes
|
||||
# - 2026-03-24 | Prompt: 修复小程序前端没有档位进度 | build_performance_summary 中 tier_nodes
|
||||
# 从 cfg_performance_tier 配置表读取(不再依赖 salary_calc 的空列表),
|
||||
# next_tier_hours/tier_completed 根据 effective_hours 和 tier_nodes 实时计算。
|
||||
# - 2026-03-24 | Prompt: bonus_money 公式修正 | bonus_money 改为基础课节省 + 打赏课节省:
|
||||
@@ -649,7 +649,7 @@ async def get_task_list_v2(
|
||||
|
||||
if not tasks:
|
||||
# 即使无任务也需要返回绩效概览
|
||||
performance = _build_performance_summary(conn, site_id, assistant_id)
|
||||
performance = build_performance_summary(conn, site_id, assistant_id)
|
||||
return {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
@@ -732,7 +732,7 @@ async def get_task_list_v2(
|
||||
|
||||
# ── 8. 绩效概览(使用批量查询的预取数据) ──
|
||||
# CHANGE 2026-03-23 | 复用 batch_data 避免额外 3 次 ETL 连接
|
||||
performance = _build_performance_summary(
|
||||
performance = build_performance_summary(
|
||||
conn, site_id, assistant_id, batch_data=batch_data,
|
||||
)
|
||||
|
||||
@@ -792,7 +792,7 @@ async def get_task_list_v2(
|
||||
conn.close()
|
||||
|
||||
|
||||
def _build_performance_summary(
|
||||
def build_performance_summary(
|
||||
conn, site_id: int, assistant_id: int, *, batch_data: dict | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
@@ -858,9 +858,17 @@ def _build_performance_summary(
|
||||
basic_hours = summary["base_hours"] if summary else 0.0
|
||||
bonus_hours = summary["bonus_hours"] if summary else 0.0
|
||||
total_customers = summary["unique_customers"] if summary else 0
|
||||
current_tier = summary["tier_id"] if summary else (salary["tier_index"] if salary else 0)
|
||||
coach_level = summary["coach_level"] if summary else (salary["coach_level"] if salary else "")
|
||||
|
||||
# current_tier:根据 total_hours 在 tier_nodes 中的位置计算数组索引(0-based)
|
||||
# 不能用 tier_id(数据库主键),前端把 current_tier 当数组下标用
|
||||
current_tier = 0
|
||||
for i, node in enumerate(tier_nodes):
|
||||
if total_hours >= node:
|
||||
current_tier = i
|
||||
else:
|
||||
break
|
||||
|
||||
# next_tier_hours / tier_completed: 根据 effective_hours 和 tier_nodes 计算
|
||||
tier_completed = False
|
||||
next_tier_hours = 0.0
|
||||
|
||||
@@ -80,8 +80,10 @@ DWD_TASKS: list[TaskDefinition] = [
|
||||
# ── DWS 任务定义 ──────────────────────────────────────────────
|
||||
|
||||
DWS_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("CORE_DIM_SYNC", "Core 维度同步", "将 DWD 当前维度同步到 core 统一层(助教/会员/门店/台桌)", "通用", "DWS", requires_window=False),
|
||||
TaskDefinition("DWS_BUILD_ORDER_SUMMARY", "订单汇总构建", "构建订单汇总宽表", "结算", "DWS"),
|
||||
TaskDefinition("DWS_ASSISTANT_DAILY", "助教日报", "汇总助教每日业绩", "助教", "DWS"),
|
||||
TaskDefinition("DWS_ASSISTANT_ORDER_CONTRIBUTION", "助教订单贡献", "计算助教订单流水四项统计", "助教", "DWS"),
|
||||
TaskDefinition("DWS_ASSISTANT_MONTHLY", "助教月报", "汇总助教月度业绩", "助教", "DWS"),
|
||||
TaskDefinition("DWS_ASSISTANT_CUSTOMER", "助教客户分析", "汇总助教-客户关系", "助教", "DWS"),
|
||||
TaskDefinition("DWS_ASSISTANT_SALARY", "助教工资计算", "计算助教工资", "助教", "DWS"),
|
||||
@@ -114,7 +116,9 @@ INDEX_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("DWS_RELATION_INDEX", "关系指数 (RS)", "产出 RS/OS/MS/ML 四个子指数", "指数", "INDEX"),
|
||||
TaskDefinition("DWS_SPENDING_POWER_INDEX", "消费力指数 (SPI)", "计算会员消费力指数", "指数", "INDEX"),
|
||||
# CHANGE 2026-03-29 | DWS_TASK_ENGINE:编排后端任务引擎(完成检查→过期检查→任务生成)
|
||||
TaskDefinition("DWS_TASK_ENGINE", "任务引擎", "编排后端任务引擎:完成检查→过期检查→任务生成", "指数", "INDEX", requires_window=False, is_common=True),
|
||||
TaskDefinition("DWS_TASK_ENGINE", "任务引擎", "日常:编排完成检查→过期检查→任务生成;设置时间窗口时:基于历史指数快照推演任务生命周期", "指数", "INDEX", requires_window=False, is_common=True),
|
||||
# CHANGE 2026-04-12 | 指数回填工具任务
|
||||
TaskDefinition("DWS_INDEX_BACKFILL", "指数回填", "逐天回填 RS/WBI/NCI 历史日快照,需设置时间窗口", "指数", "INDEX", requires_window=True, is_common=True),
|
||||
]
|
||||
|
||||
# ── 工具类任务定义 ────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
..................F........... [100%]
|
||||
================================== FAILURES ===================================
|
||||
__________________ test_invalid_credentials_always_rejected ___________________
|
||||
+ Exception Group Traceback (most recent call last):\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 2246, in wrapped_test\n | raise the_error_hypothesis_found\n | hypothesis.errors.FlakyFailure: Hypothesis test_invalid_credentials_always_rejected(password='\u4d77\U0002325c\u0133', username='uv') produces unreliable results: Falsified on the first call but did not on a subsequent one (1 sub-exception)\n | Falsifying example: test_invalid_credentials_always_rejected(\n | username='uv',\n | password='\u4d77\U0002325c\u0133',\n | )\n | Unreliable test timings! On an initial run, this test took 285.80ms, which exceeded the deadline of 200.00ms, but on a subsequent run it took 5.67 ms, which did not. If you expect this sort of variability in your test timings, consider turning deadlines off for this test by setting deadline=None.\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1211, in _execute_once_for_engine\n | result = self.execute_once(data)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1150, in execute_once\n | result = self.test_runner(data, run)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 824, in default_executor\n | return function(data)\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1107, in run\n | return test(*args, **kwargs)\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1041, in test\n | raise DeadlineExceeded(\n | datetime.timedelta(seconds=runtime), self.settings.deadline\n | )\n | hypothesis.errors.DeadlineExceeded: Test took 285.80ms, which exceeds the deadline of 200.00ms. If you expect test cases to take this long, you can use @settings(deadline=...) to either set a higher deadline, or to disable it with deadline=None.\n +------------------------------------\n=========================== short test summary info ===========================
|
||||
+ Exception Group Traceback (most recent call last):\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 2246, in wrapped_test\n | raise the_error_hypothesis_found\n | hypothesis.errors.FlakyFailure: Hypothesis test_invalid_credentials_always_rejected(password='\u4d77\U0002325c\u0133', username='uv') produces unreliable results: Falsified on the first call but did not on a subsequent one (1 sub-exception)\n | Falsifying example: test_invalid_credentials_always_rejected(\n | username='uv',\n | password='\u4d77\U0002325c\u0133',\n | )\n | Unreliable test timings! On an initial run, this test took 285.80ms, which exceeded the deadline of 200.00ms, but on a subsequent run it took 5.67 ms, which did not. If you expect this sort of variability in your test timings, consider turning deadlines off for this test by setting deadline=None.\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1211, in _execute_once_for_engine\n | result = self.execute_once(data)\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1150, in execute_once\n | result = self.test_runner(data, run)\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 824, in default_executor\n | return function(data)\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1107, in run\n | return test(*args, **kwargs)\n | File "C:\\ProgramData\\miniconda3\\Lib\\unittest\\mock.py", line 1422, in test_invalid_credentials_always_rejected\n | def patched(*args, **keywargs):\n | ^^^^^^^^^^^^^^^^^^^^\n | File "C:\\Project\\NeoZQYY\\.venv\\Lib\\site-packages\\hypothesis\\core.py", line 1041, in test\n | raise DeadlineExceeded(\n | datetime.timedelta(seconds=runtime), self.settings.deadline\n | )\n | hypothesis.errors.DeadlineExceeded: Test took 285.80ms, which exceeds the deadline of 200.00ms. If you expect test cases to take this long, you can use @settings(deadline=...) to either set a higher deadline, or to disable it with deadline=None.\n +------------------------------------\n=========================== short test summary info ===========================
|
||||
FAILED tests/test_auth_properties.py::test_invalid_credentials_always_rejected
|
||||
1 failed, 29 passed in 11.59s
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\NeoZQYY\.venv\Scripts\python.exe
|
||||
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\Project\NeoZQYY\.venv\Scripts\python.exe
|
||||
cachedir: .pytest_cache
|
||||
hypothesis profile 'default'
|
||||
rootdir: C:\NeoZQYY\apps\backend
|
||||
rootdir: C:\Project\NeoZQYY\apps\backend
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.12.1, hypothesis-6.151.6, asyncio-1.3.0
|
||||
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
============================= test session starts =============================
|
||||
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\NeoZQYY\.venv\Scripts\python.exe
|
||||
platform win32 -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 -- C:\Project\NeoZQYY\.venv\Scripts\python.exe
|
||||
cachedir: .pytest_cache
|
||||
hypothesis profile 'default'
|
||||
rootdir: C:\NeoZQYY\apps\backend
|
||||
rootdir: C:\Project\NeoZQYY\apps\backend
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.12.1, hypothesis-6.151.6, asyncio-1.3.0
|
||||
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
|
||||
Reference in New Issue
Block a user