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:
Neo
2026-04-20 06:32:07 +08:00
parent 79d3c2e97e
commit 2a7a5d68aa
157 changed files with 14304 additions and 3717 deletions

View File

@@ -1,45 +1,13 @@
{
"permissions": {
"allow": [
"Bash(du -sh /c/NeoZQYY/*)",
"Bash(du -sh /c/NeoZQYY/.*)",
"Bash(wc -l /c/NeoZQYY/scripts/ops/*.py)",
"Read(//c/Users/Administrator/.kiro//**)",
"Bash(ls -lS /c/NeoZQYY/tmp/*.md)",
"Bash(xargs -I {} basename {})",
"Bash(sed 's/_[a-z].*//')",
"Bash(ls tests/*.py)",
"Bash(sed 's/test_//')",
"Bash(ls test_property_*.py)",
"Bash(ls test_p*.py)",
"Bash(ls test_rns*.py)",
"Bash(ls test_tenant_*.py)",
"Bash(ls test_trace_*.py)",
"Bash(mv .kiro/steering _DEL/.kiro/steering)",
"Bash(mv .kiroignore _DEL/.kiroignore)",
"Bash(mv .specstory _DEL/.specstory)",
"Bash(mv .cursorindexingignore _DEL/.cursorindexingignore)",
"Bash(mv AI_CHANGELOG.md _DEL/AI_CHANGELOG.md)",
"Bash(mv _tmp_replace2.py _DEL/)",
"Bash(mv backend_test_results.txt _DEL/)",
"Bash(mv test_results.txt _DEL/)",
"Bash(mv dev-trace-coverage-working.png _DEL/)",
"Bash(mv dev-trace-page.png _DEL/)",
"Bash(mv export/pytest_result.txt _DEL/export/)",
"Bash(mv export/test_auth_results.txt _DEL/export/)",
"Bash(mv export/p13_test_result.txt _DEL/export/)",
"Bash(mv export/p13_result.txt _DEL/export/)",
"Bash(cp -r tmp _DEL/tmp_backup)",
"Bash(*)",
"Bash(touch tmp/.gitkeep)",
"Bash(ls -la c:/NeoZQYY/docs/audit/session_logs/_session_index*.json)",
"mcp__pg-etl-test__execute_sql",
"mcp__pg-app-test__execute_sql",
"mcp__pg-app-test__list_schemas"
],
"additionalDirectories": [
"C:\\Users\\Administrator\\.claude",
"c:\\NeoZQYY\\.git"
"C:\\Users\\Administrator\\.claude"
]
},
"hooks": {

28
.env
View File

@@ -77,35 +77,35 @@ BUSINESS_DAY_START_HOUR=8
# ETL Connector飞球输出路径
# ------------------------------------------------------------------------------
# JSON 导出根目录ODS 抓取落盘,按 TASK_CODE/run_id 自动建子目录)
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
# ETL 运行日志根目录
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
# 在线抓取 JSON 输出根目录FETCH_ONLY 模式使用)
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
# ETL 质检/完整性报告输出目录
ETL_REPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
ETL_REPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
# ------------------------------------------------------------------------------
# 系统级输出路径
# ------------------------------------------------------------------------------
# 数据流结构分析报告输出目录gen_dataflow_report.py / analyze_dataflow.py
SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
SYSTEM_ANALYZE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
# 字段排查报告输出目录field_audit.py
FIELD_AUDIT_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/field_audit
FIELD_AUDIT_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/field_audit
# 全链路数据流文档输出目录gen_full_dataflow_doc.py
FULL_DATAFLOW_DOC_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
FULL_DATAFLOW_DOC_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
# API 样本缓存目录gen_full_dataflow_doc.py 的 24h 缓存)
API_SAMPLE_CACHE_ROOT=C:/NeoZQYY/export/SYSTEM/CACHE/api_samples
API_SAMPLE_CACHE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/CACHE/api_samples
# 系统级运维日志目录
SYSTEM_LOG_ROOT=C:/NeoZQYY/export/SYSTEM/LOGS
SYSTEM_LOG_ROOT=C:/Project/NeoZQYY/export/SYSTEM/LOGS
# ------------------------------------------------------------------------------
# 后端输出路径(预留)
# ------------------------------------------------------------------------------
# 后端结构化日志目录
BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
BACKEND_LOG_ROOT=C:/Project/NeoZQYY/export/BACKEND/LOGS
# 用户头像存储目录
AVATAR_EXPORT_PATH=C:/NeoZQYY/export/BACKEND/avatars
AVATAR_EXPORT_PATH=C:/Project/NeoZQYY/export/BACKEND/avatars
# ------------------------------------------------------------------------------
# DashScope AI 配置(百炼 Application API
@@ -156,9 +156,9 @@ PIPELINE_RATE_MAX=2.0
# 后端运维面板路径配置
# CHANGE 2026-03-06 | 显式锁定,避免 __file__ 推算在不同部署环境指向错误路径
# ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY
ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe
OPS_SERVER_BASE=C:/Project/NeoZQYY
ETL_PROJECT_PATH=C:/Project/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PYTHON_EXECUTABLE=C:/Project/NeoZQYY/.venv/Scripts/python.exe
# === Dev Trace Log ===
# 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭)

View File

@@ -83,26 +83,26 @@ BUSINESS_DAY_START_HOUR=8
# ------------------------------------------------------------------------------
# ETL Connector 输出路径
# ------------------------------------------------------------------------------
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
ETL_REPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
ETL_REPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
# ------------------------------------------------------------------------------
# 系统级输出路径
# ------------------------------------------------------------------------------
SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
FIELD_AUDIT_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/field_audit
FULL_DATAFLOW_DOC_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
API_SAMPLE_CACHE_ROOT=C:/NeoZQYY/export/SYSTEM/CACHE/api_samples
SYSTEM_LOG_ROOT=C:/NeoZQYY/export/SYSTEM/LOGS
SYSTEM_ANALYZE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
FIELD_AUDIT_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/field_audit
FULL_DATAFLOW_DOC_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc
API_SAMPLE_CACHE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/CACHE/api_samples
SYSTEM_LOG_ROOT=C:/Project/NeoZQYY/export/SYSTEM/LOGS
# ------------------------------------------------------------------------------
# 后端输出路径
# ------------------------------------------------------------------------------
BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
BACKEND_LOG_ROOT=C:/Project/NeoZQYY/export/BACKEND/LOGS
# 用户头像存储目录chooseAvatar 上传后保存到此目录,文件名 {user_id}.jpg
AVATAR_EXPORT_PATH=C:/NeoZQYY/export/BACKEND/avatars
AVATAR_EXPORT_PATH=C:/Project/NeoZQYY/export/BACKEND/avatars
# ------------------------------------------------------------------------------
# DashScope AI 配置(百炼 Application API
@@ -336,19 +336,19 @@ INDEX_LOOKBACK_DAYS=90
# ETL 项目路径(子进程 cwd
# CHANGE 2026-03-06 | 必须显式设置,禁止依赖 __file__ 推算
# ------------------------------------------------------------------------------
ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PROJECT_PATH=C:/Project/NeoZQYY/apps/etl/connectors/feiqiu
# ------------------------------------------------------------------------------
# ETL 子进程 Python 可执行路径
# CHANGE 2026-03-06 | 必须显式设置,避免 PATH 歧义
# ------------------------------------------------------------------------------
ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe
ETL_PYTHON_EXECUTABLE=C:/Project/NeoZQYY/.venv/Scripts/python.exe
# ------------------------------------------------------------------------------
# 运维面板服务器根目录
# CHANGE 2026-03-06 | 必须显式设置,消除 __file__ 推算风险
# ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY
OPS_SERVER_BASE=C:/Project/NeoZQYY
# === Dev Trace Log ===
# 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭)

View File

@@ -37,7 +37,7 @@
"args": ["/c", "npx", "-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"],
"env": {
"WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat",
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
"WECHAT_DEVTOOLS_PROJECT": "C:\\Project\\NeoZQYY\\apps\\miniprogram"
},
"disabled": false
},
@@ -55,7 +55,7 @@
"awslabs.openapi-mcp-server.exe",
"--api-name", "miniapp-backend",
"--api-url", "http://127.0.0.1:8000",
"--spec-path", "C:\\NeoZQYY\\docs\\contracts\\openapi\\backend-api.json",
"--spec-path", "C:\\Project\\NeoZQYY\\docs\\contracts\\openapi\\backend-api.json",
"--log-level", "ERROR"
],
"env": {

View File

@@ -610,7 +610,7 @@ Tailwind 的 `leading-*` 是比例值,不是 px。换算方式`line-height
#### 微信开发者工具连接
```bash
# 用户手动启动自动化端口
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\NeoZQYY\apps\miniprogram" --auto-port 9420
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\Project\NeoZQYY\apps\miniprogram" --auto-port 9420
```
连接规范:

View File

@@ -17,7 +17,7 @@ async (page) => {
deviceScaleFactor: 1.5
});
const p = await ctx.newPage();
await p.goto('file:///C:/NeoZQYY/docs/h5_ui/pages/<page>.html',
await p.goto('file:///C:/Project/NeoZQYY/docs/h5_ui/pages/<page>.html',
{ waitUntil: 'domcontentloaded', timeout: 30000 });
await p.waitForTimeout(3000); // Tailwind CDN JIT
@@ -36,7 +36,7 @@ async (page) => {
await p.evaluate((scrollTop) => window.scrollTo(0, scrollTop), <scrollTop>);
await p.waitForTimeout(300);
await p.screenshot({
path: 'C:/NeoZQYY/docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png',
path: 'C:/Project/NeoZQYY/docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png',
type: 'png', scale: 'device'
});
await ctx.close();

View File

@@ -10,13 +10,13 @@ inclusion: manual
1. 启动自动化端口:
```powershell
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\NeoZQYY\apps\miniprogram" --auto-port 9420
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\Project\NeoZQYY\apps\miniprogram" --auto-port 9420
```
2. AI 使用 `connect_devtools` 时,只能用 `wsEndpoint` 策略:
- `strategy`: `wsEndpoint`
- `wsEndpoint`: `ws://127.0.0.1:9420`
- `projectPath`: `C:\NeoZQYY\apps\miniprogram`
- `projectPath`: `C:\Project\NeoZQYY\apps\miniprogram`
## 禁止事项

View File

@@ -133,7 +133,7 @@ evaluate_script({
### 保存截图到文件
```
screenshot(path: "C:/NeoZQYY/export/screenshots/task-list.png")
screenshot(path: "C:/Project/NeoZQYY/export/screenshots/task-list.png")
```
### 配合 pixel-audit Power 做视觉对比

View File

@@ -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

View File

@@ -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__ 推算(最后手段)

View 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(

View File

@@ -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,
)

View File

@@ -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] = []

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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_incomeDWS 层已扣抽成
"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 AC3rs_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,),
)

View File

@@ -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_amountitems_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(

View File

@@ -180,10 +180,9 @@ def get_last_visit_days(
"""
批量查询客户距上次到店天数。
来源: app.v_dwd_assistant_service_log
废单排除: is_delete = 0RLS 视图使用 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.0DWD 层服务记录无折前时长字段)。
"""
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-4consume_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_indexRLS 视图)
返回: {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 [

View File

@@ -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_idassistant_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")})

View File

@@ -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 中 completedcommitted
# 此处查到的 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_eventsON 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(

View File

@@ -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
""",

View File

@@ -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

View File

@@ -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),
]
# ── 工具类任务定义 ────────────────────────────────────────────

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -35,7 +35,7 @@
}
},
"mini-ios": {
"sdkVersion": "1.6.28",
"sdkVersion": "1.6.29",
"toolkitVersion": "0.0.9",
"useExtendedSdk": {
"WeAppOpenFuns": true,

View File

@@ -1,6 +1,6 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "DEMO-NeoZQYY",
"projectname": "demo-miniprogram",
"setting": {
"compileHotReLoad": true,
"urlCheck": false,

View File

@@ -26,7 +26,7 @@ SCHEMA_ETL=meta
# API 配置(上游 SaaS API
# ------------------------------------------------------------------------------
API_BASE=https://pc.ficoo.vip/apiprod/admin/v1/
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6Ik9OUTkreFhSWjFPVFhzQWhieTJVa3RyVXR6UzdldVE5Q1VVQ3QzQ1ArMlE9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzQvMTQg5LiL5Y2INTo0MToyNSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzYxNTk2ODUsImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.KyULo2a6dirmiAkka5Ocu_ieoZY5VsVWVqMj5smwvmE
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IlJZNldUM2w1UUdLb2hiVnJnd3kzM3pZeVNUZjk3ZkNQS2xwSXNPa0RORTg9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzQvMjQg5LiK5Y2IMToyMzo0OSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzY5NjUwMjksImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.l1qPPgUy6Xq0XZprDhhuyZ1phL-zpzf-W7VHQy86DkA
API_TIMEOUT=20
API_PAGE_SIZE=200
API_RETRY_MAX=3
@@ -35,9 +35,9 @@ API_RETRY_MAX=3
# 路径配置
# CHANGE 2026-02-19 | 统一迁移到 export/ETL-Connectors/feiqiu/ 下
# ------------------------------------------------------------------------------
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
WRITE_PRETTY_JSON=true
# ------------------------------------------------------------------------------

View File

@@ -42,6 +42,12 @@
| 23 | create_time | TIMESTAMPTZ | YES | | 创建时间 |
| 24 | member_discount_money | NUMERIC(18,2) | YES | | 会员折扣金额 |
| 25 | coupon_sale_id | BIGINT | YES | | 优惠券销售 ID |
| 26 | mt_settlement_price | NUMERIC(14,2) | YES | | 美团结算价(扣费后实际到手,元) |
| 27 | mt_gross_income | NUMERIC(14,2) | YES | | 美团总收入(售价,元) |
| 28 | mt_service_fee | NUMERIC(14,2) | YES | | 美团平台技术服务费(负数,元) |
| 29 | mt_marketing_fee | NUMERIC(14,2) | YES | | 商家营销费用(负数,元) |
| 30 | mt_other_adjust | NUMERIC(14,2) | YES | | 其他调整(元) |
| 31 | mt_import_time | TIMESTAMPTZ | YES | | 美团结算数据导入时间 |
## 使用说明

View File

@@ -60,7 +60,7 @@
## 分类映射(完整台桌清单)
### BILLIARD 🎱 中式/追分43台)
### BILLIARD 🎱 中式/追分44台)
| 区域 | 台桌 |
|------|------|
| A区 | A1-A1818台 |
@@ -68,6 +68,7 @@
| C区 | C1-C66台 |
| VIP包厢 | VIP1, VIP2, VIP3 |
| TV台 | TV |
| 美洲豹赛台 | 美洲豹赛台 |
### SNOOKER 斯诺克5台
| 区域 | 台桌 |
@@ -110,6 +111,7 @@
| 2026-03-07 | 新增 source_table_name 支持台桌级细分;废弃 BILLIARD_VIP |
| 2026-03-09 | 改为纯台桌级精确映射,删除所有 LIKE 和区域级映射 |
| 2026-03-20 | 新增 sort_order 字段,控制前端筛选器分类显示排序 |
| 2026-04-11 | 新增美洲豹赛台 → BILLIARDBILLIARD 总数 43→44 |
## 验证 SQL
@@ -133,12 +135,12 @@ ORDER BY source_area_name, source_table_name;
-- 4. 确认总记录数
SELECT COUNT(*) AS total FROM dws.cfg_area_category;
-- 期望: 7574 台桌 + 1 DEFAULT 兜底)
-- 期望: 7675 台桌 + 1 DEFAULT 兜底)
-- 5. 按分类汇总
SELECT category_code, COUNT(*) AS cnt
FROM dws.cfg_area_category
WHERE match_type = 'EXACT'
GROUP BY category_code ORDER BY category_code;
-- 期望: BILLIARD=43, KTV=7, MAHJONG=11, SNOOKER=5, SPECIAL=8
-- 期望: BILLIARD=44, KTV=7, MAHJONG=11, SNOOKER=5, SPECIAL=8
```

View File

@@ -12,7 +12,7 @@
| 唯一键 | (site_id, member_id, time_window, category_code) |
| 数据来源 | dwd_table_fee_log + dim_table + cfg_area_category |
| 更新频率 | 每日全量重建(按 site_id 删除后重新插入) |
| 说明 | 按时间窗口计算客户在四大项目的消费时长占比≥25% 分配标签。散客不参与。 |
| 说明 | 按每位客户最近 30 次消费计算四大项目时长占比≥25% 分配标签。散客不参与。 |
## 字段说明
@@ -36,12 +36,11 @@
## 时间窗口
客户看板使用 2 个时间窗口:
| 枚举值 | 说明 |
|--------|------|
| LAST_30_DAYS | 近30天含今天base_date-29天 ~ base_date |
| LAST_60_DAYS | 近60天含今天base_date-59天 ~ base_date |
| LAST_30_VISITS | 每位客户最近 30 次消费(开台记录),按 ledger_end_time 倒序 |
> 2026-04-11 改为按消费次数取数,不再按固定日期范围,避免长期未到店或来店频率不稳定的客户标签丢失。
## 索引
@@ -55,26 +54,29 @@
## 数据链路
```
dwd.dwd_table_fee_log (ledger_count, site_table_id)
dwd.dwd_table_fee_log (ledger_count, site_table_id, ledger_end_time)
→ ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY ledger_end_time DESC)
→ 取最近 30 条记录LAST_N_VISITS=30
→ 按 (member_id, site_table_id) 聚合 ledger_count
→ JOIN dwd.dim_table (site_table_id → table_id, scd2_is_current=1)
→ get_area_category(area_name, table_name) -- 通过 cfg_area_category 映射
→ 只保留 BILLIARD/SNOOKER/MAHJONG/KTV
排除散客(member_id IS NULL 或 = 0
→ 按 (member_id, category_code) 汇总 ledger_count
按 (member_id, category_code) 汇总
→ 计算占比 percentage = duration_seconds / total_seconds
→ ≥0.25 标记 is_tagged=TRUE
→ 写入 dws.dws_member_project_tag
→ 写入 dws.dws_member_project_tagtime_window='LAST_30_VISITS'
```
### 关键规则
1. 数据链路走 `dim_table`(通过 `site_table_id` JOIN不直接用事实表的 `site_table_area_name`
2. 客户时长使用 `ledger_count`(计费时长),不使用 `income_seconds`(那是助教工作时长)
3. 散客member_id=0 或 NULL不参与标签计算
4. 只计算四大项目BILLIARD/SNOOKER/MAHJONG/KTV
5. 标签阈值 25%`TAG_THRESHOLD = 0.25`
6. 全量删除重建策略:按 `site_id` 删除后重新插入所有时间窗口
7. `COALESCE(is_delete, 0) = 0` 过滤已删除的台费记录
1. 按每位客户最近 30 次消费取数,不按固定日期范围,避免来店频率不稳定的客户标签丢失
2. 数据链路走 `dim_table`(通过 `site_table_id` JOIN不直接用事实表的 `site_table_area_name`
3. 客户时长使用 `ledger_count`(计费时长),不使用 `income_seconds`(那是助教工作时长)
4. 散客member_id=0 或 NULL不参与标签计算
5. 只计算四大项目BILLIARD/SNOOKER/MAHJONG/KTV
6. 标签阈值 25%`TAG_THRESHOLD = 0.25`
7. 全量删除重建策略:按 `site_id` 删除后重新插入
8. `COALESCE(is_delete, 0) = 0` 过滤已删除的台费记录
## ETL 任务
@@ -90,6 +92,7 @@ dwd.dwd_table_fee_log (ledger_count, site_table_id)
| 日期 | 变更 | 说明 |
|------|------|------|
| 2026-03-07 | 新建表 | 支持客户看板按项目类型筛选 |
| 2026-04-11 | 时间窗口改为消费次数 | LAST_30/60_DAYS → LAST_30_VISITS按最近30次消费取数 |
## 验证 SQL

View File

@@ -97,6 +97,12 @@ graph LR
| `DWD_LOAD_FROM_ODS` | `DwdLoadTask` | 核心装载:遍历 TABLE_MAP维度走 SCD2事实走增量 | [查看](dwd_tasks.md) |
| `DWD_QUALITY_CHECK` | `DwdQualityTask` | ODS 与 DWD 行数/金额核对,输出 JSON 报表 | [查看](dwd_tasks.md) |
### Core 层(统一维度)
| 任务代码 | Python 类 | 目标表 | 粒度 | 说明 |
|----------|-----------|--------|------|------|
| `CORE_DIM_SYNC` | `CoreDimSyncTask` | `core.dim_assistant/member/site/table` | 全量 | 将 DWD 当前版本scd2_is_current=1同步到 core 统一维度层TRUNCATE+INSERT 幂等 |
### DWS 层(数据服务)
#### 助教业绩域

View File

@@ -57,8 +57,11 @@ from tasks.dws import (
)
# CHANGE [2026-07-14] intent: 合并 MV 刷新 + 数据清理为 DWS_MAINTENANCE
from tasks.dws.maintenance_task import DwsMaintenanceTask
from tasks.dws.core_dim_sync_task import CoreDimSyncTask
# CHANGE 2026-03-29 | DWS_TASK_ENGINE编排后端任务引擎完成检查→过期检查→任务生成
from tasks.dws.task_engine import DwsTaskEngineTask
# CHANGE 2026-04-12 | 指数回填工具任务
from tasks.utility.index_backfill_task import IndexBackfillTask
@dataclass
@@ -150,6 +153,9 @@ default_registry.register("SEED_DWS_CONFIG", SeedDwsConfigTask, task_type="utili
# ── 校验类任务 ────────────────────────────────────────────────
default_registry.register("DATA_INTEGRITY_CHECK", DataIntegrityTask, requires_db_config=False, task_type="verification")
# ── Core 层同步任务 ────────────────────────────────────────────
default_registry.register("CORE_DIM_SYNC", CoreDimSyncTask, layer="DWS", depends_on=["DWD_LOAD_FROM_ODS"])
# ── DWS 层业务任务 ────────────────────────────────────────────
default_registry.register("DWS_BUILD_ORDER_SUMMARY", DwsBuildOrderSummaryTask, requires_db_config=False, layer="DWS")
default_registry.register("DWS_ASSISTANT_DAILY", AssistantDailyTask, layer="DWS")
@@ -201,5 +207,11 @@ default_registry.register("DWS_SPENDING_POWER_INDEX", SpendingPowerIndexTask, re
# CHANGE 2026-03-29 | DWS_TASK_ENGINEDWS 指数计算完成后执行后端任务引擎
# depends_on: 所有指数任务——任务生成依赖 WBI/NCI/RS 指数数据
default_registry.register("DWS_TASK_ENGINE", DwsTaskEngineTask, requires_db_config=False, layer="INDEX", depends_on=[
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX", "DWS_INDEX_BACKFILL",
])
# ── 回填 / 推演工具任务 ─────────────────────────────────────────
# CHANGE 2026-04-12 | 指数日快照回填工具任务
# layer="INDEX" 确保拓扑排序中 DWS_TASK_ENGINE 在其后执行(同层显式依赖优先)
# depends_on DWD_LOAD_FROM_ODS回填读取 dwd.* 表,需要 DWD 数据已入库
default_registry.register("DWS_INDEX_BACKFILL", IndexBackfillTask, requires_db_config=False, layer="INDEX", task_type="utility", depends_on=["DWD_LOAD_FROM_ODS"])

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""一次性调研脚本:拉取全部团购详情并写入 ods.group_buy_package_details。
用法cwd = C:\\NeoZQYY/
用法cwd = C:\\Project\\NeoZQYY/
python apps/etl/connectors/feiqiu/scripts/research_coupon_details.py
流程:

View File

@@ -31,6 +31,7 @@ from .finance_board_cache import FinanceBoardCacheTask
from .coach_area_hours_task import CoachAreaHoursTask
from .finance_base_task import FinanceBaseTask
from .maintenance_task import DwsMaintenanceTask
from .core_dim_sync_task import CoreDimSyncTask
from .goods_stock_daily_task import GoodsStockDailyTask
from .goods_stock_weekly_task import GoodsStockWeeklyTask
from .goods_stock_monthly_task import GoodsStockMonthlyTask
@@ -73,6 +74,7 @@ __all__ = [
"FinanceDiscountDetailTask",
"CoachAreaHoursTask",
"DwsMaintenanceTask",
"CoreDimSyncTask",
# 库存维度
"GoodsStockDailyTask",
"GoodsStockWeeklyTask",

View File

@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""
Core 维度同步任务
功能说明:
将 DWD 层当前版本scd2_is_current=1的维度数据同步到 core 层。
core 层作为跨平台统一维度层,屏蔽 ODS/DWD 多数据源差异。
同步表:
- core.dim_assistant <- dwd.dim_assistant
- core.dim_member <- dwd.dim_member
- core.dim_site <- dwd.dim_site
- core.dim_table <- dwd.dim_table
更新策略:
TRUNCATE + INSERT 全量刷新(维度表数据量小,全量代价低)
作者ETL 团队
创建日期2026-04-15
"""
from __future__ import annotations
from typing import Any, Dict, List
from .base_dws_task import BaseDwsTask, TaskContext
# 同步映射:(core 表, DWD 源表, 字段映射)
# 字段映射格式:(core_col, dwd_expr)
SYNC_TABLES = [
{
"core_table": "core.dim_assistant",
"dwd_source": "dwd.dim_assistant",
"columns": [
("assistant_id", "assistant_id"),
("tenant_id", "tenant_id"),
("site_id", "site_id"),
("real_name", "real_name"),
("nickname", "nickname"),
("mobile", "mobile"),
("level", "level"),
("assistant_status", "assistant_status"),
("leave_status", "leave_status"),
],
},
{
"core_table": "core.dim_member",
"dwd_source": "dwd.dim_member",
"columns": [
("member_id", "member_id"),
("system_member_id", "system_member_id"),
("tenant_id", "tenant_id"),
("register_site_id", "register_site_id"),
("mobile", "mobile"),
("nickname", "nickname"),
("member_card_grade_name", "member_card_grade_name"),
("status", "1"), # DWD 无 status 字段scd2_is_current=1 即有效
],
},
{
"core_table": "core.dim_site",
"dwd_source": "dwd.dim_site",
"columns": [
("site_id", "site_id"),
("tenant_id", "tenant_id"),
("shop_name", "shop_name"),
("site_label", "site_label"),
("shop_status", "shop_status"),
],
},
{
"core_table": "core.dim_table",
"dwd_source": "dwd.dim_table",
"columns": [
("table_id", "table_id"),
("site_id", "site_id"),
("table_name", "table_name"),
("site_table_area_name", "site_table_area_name"),
("table_price", "table_price"),
],
},
]
class CoreDimSyncTask(BaseDwsTask):
"""Core 维度同步任务DWD -> core 全量刷新"""
# 无日期列,全量刷新
DATE_COL = None
def get_task_code(self) -> str:
return "CORE_DIM_SYNC"
def get_target_table(self) -> str:
# 多表同步,此方法不直接使用
return "core_dim_sync"
def get_primary_keys(self) -> List[str]:
return []
def extract(self, context: TaskContext) -> Dict[str, Any]:
"""提取 DWD 当前版本维度数据"""
result = {}
for table_def in SYNC_TABLES:
core_table = table_def["core_table"]
dwd_source = table_def["dwd_source"]
dwd_exprs = [col[1] for col in table_def["columns"]]
select_clause = ", ".join(dwd_exprs)
sql = f"SELECT {select_clause} FROM {dwd_source} WHERE scd2_is_current = 1"
with self.db.conn.cursor() as cur:
cur.execute(sql)
rows = cur.fetchall()
core_cols = [col[0] for col in table_def["columns"]]
result[core_table] = {
"rows": [dict(zip(core_cols, row)) for row in rows],
"columns": core_cols,
}
self.logger.info(
"%s: %s <- %s: %d rows",
self.get_task_code(), core_table, dwd_source, len(rows),
)
return {"tables": result, "site_id": context.store_id}
def transform(self, extracted: Dict[str, Any], context: TaskContext) -> Dict[str, Any]:
"""直通,无需转换"""
return extracted
def load(self, transformed: Dict[str, Any], context: TaskContext) -> dict:
"""TRUNCATE + INSERT 全量刷新"""
tables = transformed.get("tables", {})
total_inserted = 0
total_deleted = 0
for core_table, data in tables.items():
rows = data["rows"]
columns = data["columns"]
with self.db.conn.cursor() as cur:
# TRUNCATE
cur.execute(f"TRUNCATE {core_table}")
self.logger.info("%s: TRUNCATE %s", self.get_task_code(), core_table)
# INSERT
if rows:
cols_str = ", ".join(columns)
placeholders = ", ".join(["%s"] * len(columns))
insert_sql = f"INSERT INTO {core_table} ({cols_str}) VALUES ({placeholders})"
for row in rows:
values = [row.get(col) for col in columns]
cur.execute(insert_sql, values)
total_inserted += len(rows)
self.logger.info(
"%s: INSERT %s: %d rows",
self.get_task_code(), core_table, len(rows),
)
return {
"counts": {
"fetched": total_inserted,
"inserted": total_inserted,
"updated": 0,
"skipped": 0,
"errors": 0,
},
"extra": {"tables_synced": len(tables)},
}

View File

@@ -530,10 +530,12 @@ class MemberIndexBaseTask(BaseIndexTask):
enable_stop_exception = int(params.get('enable_stop_high_balance_exception', 0)) == 1
high_balance_threshold = float(params.get('high_balance_threshold', 1000))
# CHANGE 2026-04-12 | STOP 不再排除:超出 recency 窗口的老客归入 OLD 继续计算
# WBI 衰减公式自然给出高分,避免最需要召回的客户被遗漏
if data.t_a >= recency_days:
if enable_stop_exception and data.sv_balance >= high_balance_threshold:
return "STOP", "STOP_HIGH_BALANCE", True
return "STOP", "STOP", False
return "OLD", "STOP_OVERDUE", True
new_visit_threshold = int(params.get('new_visit_threshold', 2))
new_days_threshold = int(params.get('new_days_threshold', 30))

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from .member_index_base import MemberActivityData, MemberIndexBaseTask
@@ -202,9 +203,10 @@ class NewconvIndexTask(MemberIndexBaseTask):
avg_raw=sum(all_raw) / len(all_raw)
)
# P19: 回测模式传入 calc_time
calc_time = (context.as_of_date if context and context.as_of_date else None)
inserted = self._save_newconv_data(newconv_list, calc_time=calc_time)
# 日快照模式:始终按 stat_date 写入
now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_newconv_data(newconv_list, stat_date=stat_date)
self.logger.info("NCI calculation finished, inserted %d rows", inserted)
return {
@@ -288,30 +290,23 @@ class NewconvIndexTask(MemberIndexBaseTask):
if data.raw_score < 0:
data.raw_score = 0.0
def _save_newconv_data(self, data_list: List[MemberNewconvData], *, calc_time=None) -> int:
"""保存 NCI 数据"""
def _save_newconv_data(self, data_list: List[MemberNewconvData], *, stat_date) -> int:
"""日快照模式:按 (site_id, stat_date) 删除后插入。"""
if not data_list:
return 0
site_id = data_list[0].activity.site_id
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
use_param_time = calc_time is not None
with self.db.conn.cursor() as cur:
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s",
(site_id,),
)
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
time_placeholder = "%s, %s, %s" if use_param_time else "NOW(), NOW(), NOW()"
insert_sql = f"""
site_id = data_list[0].activity.site_id
with self.db.conn.cursor() as cur:
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
insert_sql = """
INSERT INTO dws.dws_member_newconv_index (
site_id, tenant_id, member_id,
status, segment,
@@ -325,7 +320,7 @@ class NewconvIndexTask(MemberIndexBaseTask):
raw_score_welcome, raw_score_convert, raw_score,
display_score_welcome, display_score_convert, display_score,
last_wechat_touch_time,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s,
%s, %s,
@@ -339,32 +334,40 @@ class NewconvIndexTask(MemberIndexBaseTask):
%s, %s, %s,
%s, %s, %s,
%s,
{time_placeholder}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
# 批量写入executemany 替代逐行 execute
batch_params = []
for data in data_list:
activity = data.activity
batch_params.append((
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.need_new, data.salvage_new, data.recharge_new, data.value_new,
data.welcome_new,
data.raw_score_welcome, data.raw_score_convert, data.raw_score,
data.display_score_welcome, data.display_score_convert, data.display_score,
None,
stat_date,
))
from psycopg2.extras import execute_batch
with self.db.conn.cursor() as cur:
for data in data_list:
activity = data.activity
params = (
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.need_new, data.salvage_new, data.recharge_new, data.value_new,
data.welcome_new,
data.raw_score_welcome, data.raw_score_convert, data.raw_score,
data.display_score_welcome, data.display_score_convert, data.display_score,
None,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += cur.rowcount
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_newconv_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -180,9 +180,9 @@ class RelationIndexTask(BaseIndexTask):
self._apply_display_scores(pair_map, params_rs, params_ms, params_ml, site_id)
# P19: 仅回测模式传 calc_time按 calc_time 删除保留其他快照),正常模式传 None按 site_id 全量刷新)
backtest_calc_time = now if (context and context.as_of_date) else None
inserted = self._save_relation_rows(site_id, list(pair_map.values()), calc_time=backtest_calc_time)
# 日快照模式:始终按 stat_date 写入/覆盖,支持多日快照共存
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_relation_rows(site_id, list(pair_map.values()), stat_date=stat_date)
self.logger.info("关系指数计算完成,写入 %d 条记录", inserted)
return {
@@ -585,27 +585,23 @@ class RelationIndexTask(BaseIndexTask):
return "asinh"
return "none"
def _save_relation_rows(self, site_id: int, rows: List[RelationPairMetrics], *, calc_time: Optional[datetime] = None) -> int:
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
def _save_relation_rows(self, site_id: int, rows: List[RelationPairMetrics], *, stat_date) -> int:
"""日快照模式:始终按 (site_id, stat_date) 删除后插入,支持多日快照共存。"""
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
with self.db.conn.cursor() as cur:
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s",
(site_id,),
)
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
if not rows:
self.db.conn.commit()
return 0
insert_sql = f"""
insert_sql = """
INSERT INTO dws.dws_member_assistant_relation_index (
site_id, tenant_id, member_id, assistant_id,
session_count, total_duration_minutes, basic_session_count, incentive_session_count,
@@ -614,7 +610,7 @@ class RelationIndexTask(BaseIndexTask):
os_share, os_label, os_rank,
ms_f_short, ms_f_long, ms_raw, ms_display,
ml_order_count, ml_allocated_amount, ml_raw, ml_display,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
@@ -623,42 +619,34 @@ class RelationIndexTask(BaseIndexTask):
%s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
{('%s, %s, %s' if use_param_time else 'NOW(), NOW(), NOW()')}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
for row in rows:
params = (
row.site_id,
row.tenant_id,
row.member_id,
row.assistant_id,
row.session_count,
row.total_duration_minutes,
row.basic_session_count,
row.incentive_session_count,
# 批量写入executemany 替代逐行 execute
batch_params = [
(
row.site_id, row.tenant_id, row.member_id, row.assistant_id,
row.session_count, row.total_duration_minutes,
row.basic_session_count, row.incentive_session_count,
row.days_since_last_session,
row.rs_f,
row.rs_d,
row.rs_r,
row.rs_raw,
row.rs_display,
row.os_share,
row.os_label,
row.os_rank,
row.ms_f_short,
row.ms_f_long,
row.ms_raw,
row.ms_display,
row.ml_order_count,
row.ml_allocated_amount,
row.ml_raw,
row.ml_display,
row.rs_f, row.rs_d, row.rs_r, row.rs_raw, row.rs_display,
row.os_share, row.os_label, row.os_rank,
row.ms_f_short, row.ms_f_long, row.ms_raw, row.ms_display,
row.ml_order_count, row.ml_allocated_amount, row.ml_raw, row.ml_display,
stat_date,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += max(cur.rowcount, 0)
for row in rows
]
from psycopg2.extras import execute_batch
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_assistant_relation_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -178,9 +178,10 @@ class WinbackIndexTask(MemberIndexBaseTask):
avg_raw=sum(all_raw) / len(all_raw)
)
# P19: 回测模式传入 calc_time
calc_time = (context.as_of_date if context and context.as_of_date else None)
inserted = self._save_winback_data(winback_list, calc_time=calc_time)
# 日快照模式:始终按 stat_date 写入
now = (context.as_of_date if context and context.as_of_date else None) or datetime.now(self.tz)
stat_date = now.date() if hasattr(now, 'date') else now
inserted = self._save_winback_data(winback_list, stat_date=stat_date)
self.logger.info("WBI calculation finished, inserted %d rows", inserted)
return {
@@ -341,29 +342,23 @@ class WinbackIndexTask(MemberIndexBaseTask):
if data.raw_score < 0:
data.raw_score = 0.0
def _save_winback_data(self, data_list: List[MemberWinbackData], *, calc_time: Optional[datetime] = None) -> int:
"""保存 WBI 数据"""
def _save_winback_data(self, data_list: List[MemberWinbackData], *, stat_date) -> int:
"""日快照模式:按 (site_id, stat_date) 删除后插入。"""
if not data_list:
return 0
site_id = data_list[0].activity.site_id
# P19: 回测模式传入 calc_time正常模式用 NOW()
use_param_time = calc_time is not None
# P19: 回测模式按 calc_time 删除(保留其他快照),正常模式按 site_id 全量刷新
with self.db.conn.cursor() as cur:
if use_param_time:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND calc_time = %s",
(site_id, calc_time),
)
else:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s",
(site_id,),
)
from datetime import date as date_type
if not isinstance(stat_date, date_type):
stat_date = stat_date.date() if hasattr(stat_date, 'date') else stat_date
time_placeholder = "%s, %s, %s" if use_param_time else "NOW(), NOW(), NOW()"
insert_sql = f"""
site_id = data_list[0].activity.site_id
with self.db.conn.cursor() as cur:
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date = %s",
(site_id, stat_date),
)
insert_sql = """
INSERT INTO dws.dws_member_winback_index (
site_id, tenant_id, member_id,
status, segment,
@@ -376,7 +371,7 @@ class WinbackIndexTask(MemberIndexBaseTask):
ideal_interval_days, ideal_next_visit_date,
raw_score, display_score,
last_wechat_touch_time,
calc_time, created_at, updated_at
calc_time, created_at, updated_at, stat_date
) VALUES (
%s, %s, %s,
%s, %s,
@@ -389,31 +384,39 @@ class WinbackIndexTask(MemberIndexBaseTask):
%s, %s,
%s, %s,
%s,
{time_placeholder}
NOW(), NOW(), NOW(), %s
)
"""
inserted = 0
# 批量写入executemany 替代逐行 execute
batch_params = []
for data in data_list:
activity = data.activity
batch_params.append((
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
data.ideal_interval_days, data.ideal_next_visit_date,
data.raw_score, data.display_score,
None,
stat_date,
))
from psycopg2.extras import execute_batch
with self.db.conn.cursor() as cur:
for data in data_list:
activity = data.activity
params = (
activity.site_id, activity.tenant_id, activity.member_id,
data.status, data.segment,
activity.member_create_time, activity.first_visit_time, activity.last_visit_time, activity.last_recharge_time,
activity.t_v, activity.t_r, activity.t_a,
activity.visits_14d, activity.visits_30d, activity.visits_60d, activity.visits_total,
activity.spend_30d, activity.spend_180d, activity.sv_balance, activity.recharge_60d_amt,
activity.interval_count,
data.overdue_old, data.overdue_cdf_p, data.drop_old, data.recharge_old, data.value_old,
data.ideal_interval_days, data.ideal_next_visit_date,
data.raw_score, data.display_score,
None,
)
if use_param_time:
params = params + (calc_time, calc_time, calc_time)
cur.execute(insert_sql, params)
inserted += cur.rowcount
execute_batch(cur, insert_sql, batch_params, page_size=200)
inserted = len(batch_params)
# 保留策略:清理 365 天前的快照
cur.execute(
"DELETE FROM dws.dws_member_winback_index WHERE site_id = %s AND stat_date < CURRENT_DATE - INTERVAL '365 days'",
(site_id,),
)
self.db.conn.commit()
return inserted

View File

@@ -2,11 +2,17 @@
"""
DWS 客户项目标签任务
时间窗口计算每位客户在四大项目BILLIARD/SNOOKER/MAHJONG/KTV
消费时长占比占比≥25% 则分配标签。散客member_id=0不参与。
每位客户最近 N 次消费(开台记录)计算四大项目BILLIARD/SNOOKER/MAHJONG/KTV
消费时长占比占比≥25% 则分配标签。散客member_id=0不参与。
设计思路:
不按固定日期窗口30天/60天而按每位客户最近的消费记录数量取数
避免长期未到店或来店频率不稳定的客户标签丢失。
数据链路:
dwd_table_fee_log (ledger_count)
→ ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY ledger_end_time DESC)
→ 取最近 LAST_N_VISITS 条记录
→ JOIN dim_table (site_table_id → table_id, scd2_is_current=1)
→ get_area_category(area_name, table_name)
→ 按 category_code 汇总 → 计算占比 → 写入 dws_member_project_tag
@@ -15,25 +21,23 @@ DWS 客户项目标签任务
dws.dws_member_project_tag
更新策略:
全量删除重建(按 site_id 删除后重新插入所有时间窗口
全量删除重建(按 site_id 删除后重新插入)
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any, Dict, List, Optional
from tasks.dws.base_dws_task import BaseDwsTask, TimeWindow
from neozqyy_shared.datetime_utils import biz_date_sql_expr
from tasks.dws.base_dws_task import BaseDwsTask
# 只计算四大项目
VALID_CATEGORIES = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
# 客户看板的 2 个时间窗口
MEMBER_WINDOWS = [
TimeWindow.LAST_30_DAYS,
TimeWindow.LAST_60_DAYS,
]
# 取每位客户最近 30 次消费(开台记录)
LAST_N_VISITS = 30
# 写入 time_window 字段的枚举值
TIME_WINDOW_VALUE = "LAST_30_VISITS"
TAG_THRESHOLD = Decimal("0.25")
@@ -52,21 +56,15 @@ class MemberProjectTagTask(BaseDwsTask):
def extract(self, context) -> Dict[str, Any]:
site_id = context.store_id
self.logger.info("%s: 提取客户台费时长数据", self.get_task_code())
self.logger.info("%s: 提取客户最近 %d 次消费的台费时长数据",
self.get_task_code(), LAST_N_VISITS)
self.load_config_cache()
table_info = self._extract_table_info(site_id)
window_data: Dict[str, List[Dict]] = {}
for window in MEMBER_WINDOWS:
time_range = self.get_time_window_range(window)
rows = self._extract_member_durations(
site_id, time_range.start, time_range.end
)
window_data[window.value] = rows
rows = self._extract_member_durations(site_id)
return {
"window_data": window_data,
"rows": rows,
"table_info": table_info,
"site_id": site_id,
}
@@ -81,30 +79,37 @@ class MemberProjectTagTask(BaseDwsTask):
rows = self.db.query(sql, (site_id,))
return {r["table_id"]: dict(r) for r in (rows or [])}
def _extract_member_durations(
self, site_id: int, start_date: date, end_date: date
) -> List[Dict[str, Any]]:
"""提取客户台费时长明细(按客户+台桌聚合),排除散客"""
cutoff = self.config.get("app.business_day_start_hour", 8)
biz_expr = biz_date_sql_expr("tfl.ledger_end_time", cutoff)
sql = f"""
SELECT
tfl.member_id,
tfl.site_table_id AS table_id,
COALESCE(SUM(tfl.ledger_count), 0) AS duration_seconds
FROM dwd.dwd_table_fee_log tfl
WHERE tfl.site_id = %(site_id)s
AND {biz_expr} >= %(start_date)s
AND {biz_expr} <= %(end_date)s
AND COALESCE(tfl.is_delete, 0) = 0
AND tfl.member_id IS NOT NULL
AND tfl.member_id != 0
GROUP BY tfl.member_id, tfl.site_table_id
def _extract_member_durations(self, site_id: int) -> List[Dict[str, Any]]:
"""按每位客户最近 N 次消费提取台费时长明细,排除散客。
使用 ROW_NUMBER() 按 member_id 分区、ledger_end_time 倒序排名,
取最近 LAST_N_VISITS 条记录后再按 (member_id, table_id) 聚合。
"""
sql = """
WITH ranked AS (
SELECT tfl.member_id,
tfl.site_table_id AS table_id,
tfl.ledger_count AS duration_seconds,
ROW_NUMBER() OVER (
PARTITION BY tfl.member_id
ORDER BY tfl.ledger_end_time DESC
) AS rn
FROM dwd.dwd_table_fee_log tfl
WHERE tfl.site_id = %(site_id)s
AND COALESCE(tfl.is_delete, 0) = 0
AND tfl.member_id IS NOT NULL
AND tfl.member_id != 0
)
SELECT member_id,
table_id,
COALESCE(SUM(duration_seconds), 0) AS duration_seconds
FROM ranked
WHERE rn <= %(last_n)s
GROUP BY member_id, table_id
"""
rows = self.db.query(sql, {
"site_id": site_id,
"start_date": start_date,
"end_date": end_date,
"last_n": LAST_N_VISITS,
})
return [dict(r) for r in rows] if rows else []
@@ -114,59 +119,59 @@ class MemberProjectTagTask(BaseDwsTask):
tenant_id = getattr(context, "tenant_id", 0) or 0
results: List[Dict[str, Any]] = []
for window_value, rows in extracted["window_data"].items():
# member_id → category_code → seconds
member_cats: Dict[int, Dict[str, int]] = {}
# member_id → category_code → seconds
member_cats: Dict[int, Dict[str, int]] = {}
for row in rows:
mid = row["member_id"]
tid = row["table_id"]
secs = self.safe_int(row["duration_seconds"])
if secs <= 0:
continue
for row in extracted["rows"]:
mid = row["member_id"]
tid = row["table_id"]
secs = self.safe_int(row["duration_seconds"])
if secs <= 0:
continue
tinfo = table_info.get(tid, {})
area_name = tinfo.get("area_name")
table_name = tinfo.get("table_name")
cat = self.get_area_category(area_name, table_name)
code = cat.get("category_code", "OTHER")
tinfo = table_info.get(tid, {})
area_name = tinfo.get("area_name")
table_name = tinfo.get("table_name")
cat = self.get_area_category(area_name, table_name)
code = cat.get("category_code", "OTHER")
if code not in VALID_CATEGORIES:
continue
if code not in VALID_CATEGORIES:
continue
if mid not in member_cats:
member_cats[mid] = {}
member_cats[mid][code] = member_cats[mid].get(code, 0) + secs
if mid not in member_cats:
member_cats[mid] = {}
member_cats[mid][code] = member_cats[mid].get(code, 0) + secs
for mid, cats in member_cats.items():
total = sum(cats.values())
if total <= 0:
continue
for mid, cats in member_cats.items():
total = sum(cats.values())
if total <= 0:
continue
for code, secs in cats.items():
pct = Decimal(str(secs)) / Decimal(str(total))
pct = pct.quantize(Decimal("0.0001"))
cat_info = self._get_category_display(code)
for code, secs in cats.items():
pct = Decimal(str(secs)) / Decimal(str(total))
pct = pct.quantize(Decimal("0.0001"))
cat_info = self._get_category_display(code)
results.append({
"site_id": site_id,
"tenant_id": tenant_id,
"member_id": mid,
"time_window": window_value,
"category_code": code,
"category_name": cat_info["category_name"],
"short_name": cat_info["short_name"],
"duration_seconds": secs,
"total_seconds": total,
"percentage": float(pct),
"is_tagged": pct >= TAG_THRESHOLD,
})
results.append({
"site_id": site_id,
"tenant_id": tenant_id,
"member_id": mid,
"time_window": TIME_WINDOW_VALUE,
"category_code": code,
"category_name": cat_info["category_name"],
"short_name": cat_info["short_name"],
"duration_seconds": secs,
"total_seconds": total,
"percentage": float(pct),
"is_tagged": pct >= TAG_THRESHOLD,
})
self.logger.info(
"%s: 生成 %d 条标签记录(其中 %d 条达标)",
"%s: 生成 %d 条标签记录(其中 %d 条达标),基于每客户最近 %d 次消费",
self.get_task_code(),
len(results),
sum(1 for r in results if r["is_tagged"]),
LAST_N_VISITS,
)
return results

View File

@@ -2,96 +2,130 @@
# - 2026-03-29 | Prompt: DWS_TASK_ENGINE ETL 任务 | 新建文件。
# 编排任务引擎全流程:完成检查 → 过期检查 → 任务生成。
# 通过 HTTP 调用后端 POST /api/internal/run-job 按 job_name 执行。
# - 2026-04-12 | 合并 DWS_TASK_SIMULATION有时间窗口时走推演模式
# 无时间窗口时走原来的 HTTP 模式。
# -*- coding: utf-8 -*-
"""
DWS 任务引擎编排任务DWS_TASK_ENGINE
在 DWS 指数计算完成后执行,按顺序调用后端任务引擎的各个步骤
1. recall_completion_check — 检测召回是否完成,生成回访任务
2. task_expiry_check — 标记超时未处理的任务
3. task_generator — 根据 WBI/NCI/RS 指数生成/替换任务
双模式
- 无时间窗口(日常 Flow通过 HTTP 调用后端任务引擎
1. recall_completion_check — 检测召回完成
2. task_expiry_check — 标<><E6A087>超时任务
3. task_generator — 根据指数生成/替换任务
通过 HTTP 调用后端 POST /api/internal/run-jobInternal-Token 认证),
每步失败仅记录日志,不中断后续步骤。
- 有时间窗口(历史推演):基于指数日快照逐天重放任务生命周期
需先运行 DWS_INDEX_BACKFILL 生成历史快照
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
import requests
from dotenv import load_dotenv
from ..base_task import BaseTask, TaskContext
# 加载根 .envBACKEND_API_URL / INTERNAL_API_TOKEN 不在 AppConfig 映射中)
# task_engine.py → dws/ → tasks/ → feiqiu/ → connectors/ → etl/ → apps/ → root
# 加载根 .env
_REPO_ROOT = Path(__file__).resolve().parents[6]
load_dotenv(_REPO_ROOT / ".env", override=False)
logger = logging.getLogger(__name__)
_TIMEOUT = (5, 30) # 连接 5s读取 30s任务执行可能较慢
_TIMEOUT = (5, 30)
# 按顺序执行的后端任务列表
# HTTP 模式<E6A8A1><E5BC8F><EFBFBD>按顺序执行的后端任务
_JOB_SEQUENCE = [
"recall_completion_check",
"task_expiry_check",
"task_generator",
]
# 推演模式<EFBC9A><E5AFBC> task_generator 纯函数
_BACKEND = _REPO_ROOT / "apps" / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
try:
from app.services.task_generator import (
IndexData,
determine_task_type,
should_replace_task,
)
_SIMULATION_AVAILABLE = True
except ImportError:
_SIMULATION_AVAILABLE = False
# 推演截止日期(现有 active 任务从 03-29 开始)
CUTOFF_DATE = date(2026, 3, 28)
FOLLOW_UP_HOURS = 72
# ── HTTP <20><>式辅助 ──
def _run_backend_job(backend_url: str, token: str, job_name: str) -> dict:
"""调用后端 POST /api/internal/run-job 执行指定任务。
Returns:
{"success": bool, "message": str} 或 {"success": False, "message": error}
"""
url = f"{backend_url}/api/internal/run-job"
headers = {
"Authorization": f"Internal-Token {token}",
"Content-Type": "application/json",
}
body = {"job_name": job_name}
try:
resp = requests.post(url, json=body, headers=headers, timeout=_TIMEOUT)
resp = requests.post(url, json={"job_name": job_name}, headers=headers, timeout=_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
# 后端 ResponseWrapperMiddleware 包装:{"code": 0, "data": {...}}
inner = data.get("data", data)
return {
"success": inner.get("success", False),
"message": inner.get("message", ""),
}
else:
return {
"success": False,
"message": f"HTTP {resp.status_code}: {resp.text[:200]}",
}
return {"success": inner.get("success", False), "message": inner.get("message", "")}
return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except requests.RequestException as exc:
return {"success": False, "message": str(exc)}
class DwsTaskEngineTask(BaseTask):
"""DWS 任务引擎编排任务
"""DWS 任务引擎(双模式)
不读写 DWS 表,仅通过 HTTP 调用后端执行任务引擎步骤。
继承 BaseTask 而非 BaseDwsTask因为不需要 DWS 层的数据操作方法。
无时间窗口 → HTTP 模式(日常 Flow
有时间窗口 → 推演模式(历史回填)
"""
def get_task_code(self) -> str:
return "DWS_TASK_ENGINE"
def extract(self, context: TaskContext) -> dict[str, Any]:
"""无需提取数据,返回空上下文。"""
return {}
def execute(self, context=None) -> Dict[str, Any]:
"""直接 override execute(),绕过 BaseTask 的 E/T/L 模板。
def load(self, extracted: dict[str, Any], context: TaskContext) -> dict[str, Any]:
"""按顺序调用后端任务引擎的各个步骤。"""
根据是否有时间窗口决定模式:
- 有窗口 → 推演模式(逐天生成+完成任务)
- 无窗口 → HTTP 模式(调用后端执行当天任务引擎)
"""
if self._has_window(context):
return self._run_simulation_mode(context)
return self._run_http_mode()
def _has_window(self, context=None) -> bool:
"""检查是否指定了时间窗口config 或 context 均可)。"""
# 优先从 configCLI --window-start/--window-end
wo = self.config.get("run.window_override") or {}
if wo.get("start") and wo.get("end"):
return True
# 其次从 contexttask_executor 构建的)
if context and hasattr(context, 'window_start') and hasattr(context, 'window_end'):
if context.window_start and context.window_end and context.window_start != context.window_end:
return True
return False
# ── HTTP 模式(日常) ──
def _run_http_mode(self) -> dict[str, Any]:
backend_url = os.environ.get("BACKEND_API_URL", "").rstrip("/")
token = os.environ.get("INTERNAL_API_TOKEN", "")
@@ -103,22 +137,667 @@ class DwsTaskEngineTask(BaseTask):
return {"skipped": True, "reason": "INTERNAL_API_TOKEN 未配置"}
results: dict[str, Any] = {}
for job_name in _JOB_SEQUENCE:
self.logger.info("DWS_TASK_ENGINE: 执行 %s ...", job_name)
result = _run_backend_job(backend_url, token, job_name)
success = result.get("success", False)
message = result.get("message", "")
results[job_name] = {"success": success, "message": message}
if success:
self.logger.info(
"DWS_TASK_ENGINE: %s 成功 — %s", job_name, message
)
self.logger.info("DWS_TASK_ENGINE: %s 成功 — %s", job_name, message)
else:
self.logger.warning(
"DWS_TASK_ENGINE: %s 失败 — %s", job_name, message
)
self.logger.warning("DWS_TASK_ENGINE: %s 失败 — %s", job_name, message)
return results
# ── 推演模式(历史) ──
def _run_simulation_mode(self, context: Optional[TaskContext]) -> dict[str, Any]:
if not _SIMULATION_AVAILABLE:
raise RuntimeError("推演模式不可用:无法导入 app.services.task_generator")
import psycopg2
start_date, end_date = self._parse_date_range(context)
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
etl_conn = self.db.conn
app_dsn = os.environ.get("APP_DB_DSN")
if not app_dsn:
raise ValueError("推演模式需要 APP_DB_DSN 环境变量")
app_conn = psycopg2.connect(app_dsn)
app_conn.set_client_encoding("UTF8")
site_id = self._get_site_id(etl_conn)
total_days = (end_date - start_date).days + 1
self.logger.info(
"DWS_TASK_ENGINE [推演模式]: %s ~ %s (%d天), site_id=%s",
start_date, end_date, total_days, site_id,
)
# 清理指定范围内的旧数据,保留范围外的
self._clean_date_range(app_conn, tz, start_date, end_date)
# 加载推演范围之前就存在的 active 任务(不加载范围之后的"未来"任务)
active_tasks = self._load_existing_active_tasks(app_conn, site_id, before_date=start_date)
self.logger.info("DWS_TASK_ENGINE [推演]: 范围前已有 active 任务 %d", len(active_tasks))
stats = {
"created": 0, "completed": 0, "resolved": 0, "overridden": 0,
"expired": 0, "follow_up_created": 0,
"recall_events": 0, "skipped_no_snapshot": 0,
}
# 预加载全量数据,按日分片(避免 255 x 5 次逐日查询 -> 5 次全量查询)
self.logger.info("DWS_TASK_ENGINE [推演]: 预加载快照 + 结算数据 ...")
snapshots_by_date = self._bulk_load_snapshots(etl_conn, site_id, start_date, end_date)
settlements_by_date = self._bulk_load_settlements(etl_conn, site_id, start_date, end_date, tz)
member_visits_by_date = self._bulk_load_member_visits(etl_conn, site_id, start_date, end_date, tz)
self.logger.info(
"DWS_TASK_ENGINE [推演]: 预加载完成, %d 天有快照, %d 天有助教结算, %d 天有到店记录",
len(snapshots_by_date), len(settlements_by_date), len(member_visits_by_date),
)
# 加载任务生成参数(与日常 task_generator 保持一致)
task_params = self._load_task_generator_params(app_conn, site_id)
self.logger.info(
"DWS_TASK_ENGINE [推演]: 任务阈值 high=%.1f, normal=%.1f, rs=[%.1f, %.1f)",
task_params["high_threshold"], task_params["normal_threshold"],
task_params["rs_min"], task_params["rs_max"],
)
t0 = time.time()
current = start_date
while current <= end_date:
snapshot = snapshots_by_date.get(current, {"relation": {}, "wbi": {}, "nci": {}})
if not snapshot["relation"] and not snapshot["wbi"] and not snapshot["nci"]:
stats["skipped_no_snapshot"] += 1
current += timedelta(days=1)
continue
day_settlements = settlements_by_date.get(current, {})
day_visits = member_visits_by_date.get(current, {})
self._simulate_day(app_conn, etl_conn, site_id, current, tz, snapshot, active_tasks, stats,
preloaded_settlements=day_settlements, preloaded_visits=day_visits,
task_params=task_params)
day_num = (current - start_date).days + 1
if day_num % 30 == 0 or current == end_date:
elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_ENGINE [推演]: %s (%d/%d) 创建=%d 完成=%d 解除=%d 覆盖=%d 过期=%d %.0fs",
current, day_num, total_days,
stats["created"], stats["completed"], stats["resolved"],
stats["overridden"], stats["expired"], elapsed,
)
current += timedelta(days=1)
# 收尾:清理推演结束后仍 active 但 expires_at 已过期的任务
now_dt = datetime.now(tz)
cleanup_count = 0
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'expired', updated_at = %s
WHERE site_id = %s AND status = 'active'
AND expires_at IS NOT NULL AND expires_at < %s
RETURNING id, task_type""",
(now_dt, site_id, now_dt),
)
for task_id, task_type in cur.fetchall():
self._history(cur, task_id, "expired", "active", "expired",
task_type, task_type, {"reason": "post_simulation_cleanup"})
cleanup_count += 1
app_conn.commit()
stats["expired"] += cleanup_count
if cleanup_count:
self.logger.info("DWS_TASK_ENGINE [推演]: 收尾清理 %d 个已过期任务", cleanup_count)
total_elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_ENGINE [推演] 完成: %.0fs, 创建=%d 完成=%d 解除=%d 覆盖=%d 过期=%d 回访=%d 事件=%d 跳过=%d active=%d",
total_elapsed, stats["created"], stats["completed"], stats["resolved"],
stats["overridden"], stats["expired"],
stats["follow_up_created"], stats["recall_events"],
stats["skipped_no_snapshot"], len(active_tasks),
)
app_conn.close()
# 推演完成后触发日常流程recall_detector + task_generator
# 让最新到店数据触发召回完成、POOL 过滤清理存量
self.logger.info("DWS_TASK_ENGINE [推演]: 触发日常流程 ...")
try:
http_result = self._run_http_mode()
self.logger.info("DWS_TASK_ENGINE [推演]: 日常流程完成 %s", http_result)
except Exception:
self.logger.exception("DWS_TASK_ENGINE [推演]: 日常流程触发失败(不影响推演结果)")
return {
"status": "SUCCESS",
"counts": {
# 框架标准字段(总结框显示用)
"inserted": stats["created"],
"updated": stats["overridden"],
"skipped": stats["skipped_no_snapshot"],
"errors": 0,
# 原始明细
**stats,
},
}
# ── 推演辅助方法 ──
def _parse_date_range(self, context: Optional[TaskContext]) -> tuple[date, date]:
wo = self.config.get("run.window_override") or {}
start_str = wo.get("start")
end_str = wo.get("end")
if start_str and end_str:
return self._parse_date(start_str), self._parse_date(end_str)
if context and context.window_start and context.window_end:
return context.window_start.date(), context.window_end.date()
raise ValueError("推演模式需要指定时间窗口")
@staticmethod
def _parse_date(s) -> date:
if isinstance(s, date) and not isinstance(s, datetime):
return s
if isinstance(s, datetime):
return s.date()
return date.fromisoformat(str(s).strip()[:10])
def _get_site_id(self, etl_conn) -> int:
with etl_conn.cursor() as cur:
cur.execute("SELECT DISTINCT site_id FROM dws.dws_member_assistant_relation_index LIMIT 1")
row = cur.fetchone()
etl_conn.commit()
if not row:
raise RuntimeError("relation_index 表为空,请先运行 DWS_INDEX_BACKFILL")
return row[0]
def _load_snapshot(self, etl_conn, site_id: int, stat_date: date) -> dict:
result = {"relation": {}, "wbi": {}, "nci": {}}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT assistant_id, member_id, rs_display, os_label, session_count
FROM dws.dws_member_assistant_relation_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["relation"][(r[0], r[1])] = {
"rs": Decimal(str(r[2])), "os_label": r[3], "session_count": r[4],
}
# WBI同时记录 status 用于过滤老客 NCI
old_members = set()
cur.execute(
"""SELECT member_id, display_score, status FROM dws.dws_member_winback_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["wbi"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
if r[2] == "OLD":
old_members.add(r[0])
# NCI排除已转老客避免使用过时高分
cur.execute(
"""SELECT member_id, display_score FROM dws.dws_member_newconv_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
if r[0] not in old_members:
result["nci"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
etl_conn.commit()
return result
def _load_settlements(self, etl_conn, site_id: int, d: date) -> dict:
"""助教级结算settle_type=1 全部计入settle_type=3 仅 BONUS 服务。"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
day_start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
day_end = day_start + timedelta(days=1)
settlements = {}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sl.site_assistant_id, sh.member_id, MAX(sh.pay_time)
FROM dwd.dwd_settlement_head sh
JOIN dwd.dwd_assistant_service_log sl
ON sl.order_settle_id = sh.order_settle_id AND sl.is_delete = 0
WHERE sh.site_id = %s
AND (sh.settle_type = 1 OR (sh.settle_type = 3 AND sl.order_assistant_type = 2))
AND sh.pay_time >= %s AND sh.pay_time < %s
GROUP BY sl.site_assistant_id, sh.member_id""",
(site_id, day_start, day_end),
)
for r in cur.fetchall():
if r[0] and r[1]:
settlements[(r[0], r[1])] = r[2]
etl_conn.commit()
return settlements
def _load_member_visits(self, etl_conn, site_id: int, d: date) -> dict:
"""门店级到店检测:含无助教服务的 settle_type=1用于 resolved 判定。"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
day_start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
day_end = day_start + timedelta(days=1)
visits = {}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sh.member_id, MAX(sh.pay_time)
FROM dwd.dwd_settlement_head sh
WHERE sh.site_id = %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
SELECT 1 FROM dwd.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
))
)
AND sh.pay_time >= %s AND sh.pay_time < %s
GROUP BY sh.member_id""",
(site_id, day_start, day_end),
)
for r in cur.fetchall():
if r[0]:
visits[r[0]] = r[1]
etl_conn.commit()
return visits
def _bulk_load_settlements(self, etl_conn, site_id: int, start: date, end: date, tz) -> dict:
"""一次查全量助教级结算,按日分片返回 {date: {(aid,mid): pay_time}}。"""
from collections import defaultdict
day_start = datetime(start.year, start.month, start.day, 0, 0, 0, tzinfo=tz)
day_end = datetime(end.year, end.month, end.day, 0, 0, 0, tzinfo=tz) + timedelta(days=1)
result = defaultdict(dict)
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sl.site_assistant_id, sh.member_id, sh.pay_time
FROM dwd.dwd_settlement_head sh
JOIN dwd.dwd_assistant_service_log sl
ON sl.order_settle_id = sh.order_settle_id AND sl.is_delete = 0
WHERE sh.site_id = %s
AND (sh.settle_type = 1 OR (sh.settle_type = 3 AND sl.order_assistant_type = 2))
AND sh.pay_time >= %s AND sh.pay_time < %s""",
(site_id, day_start, day_end),
)
for aid, mid, pay_time in cur.fetchall():
if aid and mid:
d_key = pay_time.astimezone(tz).date()
existing = result[d_key].get((aid, mid))
if existing is None or pay_time > existing:
result[d_key][(aid, mid)] = pay_time
etl_conn.commit()
return dict(result)
def _bulk_load_member_visits(self, etl_conn, site_id: int, start: date, end: date, tz) -> dict:
"""一次查全量门店级到店,按日分片返回 {date: {mid: pay_time}}。"""
from collections import defaultdict
day_start = datetime(start.year, start.month, start.day, 0, 0, 0, tzinfo=tz)
day_end = datetime(end.year, end.month, end.day, 0, 0, 0, tzinfo=tz) + timedelta(days=1)
result = defaultdict(dict)
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sh.member_id, sh.pay_time
FROM dwd.dwd_settlement_head sh
WHERE sh.site_id = %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
SELECT 1 FROM dwd.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
))
)
AND sh.pay_time >= %s AND sh.pay_time < %s""",
(site_id, day_start, day_end),
)
for mid, pay_time in cur.fetchall():
if mid:
d_key = pay_time.astimezone(tz).date()
existing = result[d_key].get(mid)
if existing is None or pay_time > existing:
result[d_key][mid] = pay_time
etl_conn.commit()
return dict(result)
@staticmethod
def _load_task_generator_params(app_conn, site_id: int) -> dict:
"""从 cfg_task_generator_params 加载任务生成阈值,与日常 task_generator 保持一致。"""
defaults = {
"high_threshold": 7.5,
"normal_threshold": 4.0,
"rs_min": 1.0,
"rs_max": 6.0,
}
key_map = {
"high_priority_recall_threshold": "high_threshold",
"priority_recall_threshold": "normal_threshold",
"rs_min_for_relationship": "rs_min",
"rs_max_for_relationship": "rs_max",
}
with app_conn.cursor() as cur:
cur.execute("SELECT param_key, param_value FROM biz.cfg_task_generator_params")
for param_key, param_value in cur.fetchall():
mapped = key_map.get(param_key)
if mapped:
defaults[mapped] = float(param_value)
app_conn.commit()
return defaults
def _bulk_load_snapshots(self, etl_conn, site_id: int, start: date, end: date) -> dict:
"""一次查全量指数快照relation/wbi/nci按日分片返回 {date: snapshot_dict}。"""
from collections import defaultdict
result = defaultdict(lambda: {"relation": {}, "wbi": {}, "nci": {}})
with etl_conn.cursor() as cur:
# relation_index
cur.execute(
"""SELECT stat_date, assistant_id, member_id, rs_display, os_label, session_count
FROM dws.dws_member_assistant_relation_index
WHERE site_id = %s AND stat_date >= %s AND stat_date <= %s""",
(site_id, start, end),
)
for sd, aid, mid, rs, os_label, sc in cur.fetchall():
result[sd]["relation"][(aid, mid)] = {
"rs": Decimal(str(rs)), "os_label": os_label, "session_count": sc,
}
# WBI同时收集 OLD 状态用于过滤 NCI
old_members_by_date = defaultdict(set)
cur.execute(
"""SELECT stat_date, member_id, display_score, status
FROM dws.dws_member_winback_index
WHERE site_id = %s AND stat_date >= %s AND stat_date <= %s""",
(site_id, start, end),
)
for sd, mid, score, status in cur.fetchall():
result[sd]["wbi"][mid] = Decimal(str(score)) if score else Decimal(0)
if status == "OLD":
old_members_by_date[sd].add(mid)
# NCI排除已转老客
cur.execute(
"""SELECT stat_date, member_id, display_score
FROM dws.dws_member_newconv_index
WHERE site_id = %s AND stat_date >= %s AND stat_date <= %s""",
(site_id, start, end),
)
for sd, mid, score in cur.fetchall():
if mid not in old_members_by_date.get(sd, set()):
result[sd]["nci"][mid] = Decimal(str(score)) if score else Decimal(0)
etl_conn.commit()
return dict(result)
def _simulate_day(self, app_conn, etl_conn, site_id, d, tz, snapshot, active_tasks, stats,
*, preloaded_settlements=None, preloaded_visits=None, task_params=None):
day_dt = datetime(d.year, d.month, d.day, 7, 0, 0, tzinfo=tz)
# 1. 过期检测
expired_keys = [k for k, t in active_tasks.items() if t.get("expires_at") and t["expires_at"] < day_dt]
for key in expired_keys:
task = active_tasks.pop(key)
stats["expired"] += 1
with app_conn.cursor() as cur:
cur.execute("UPDATE biz.coach_tasks SET status = 'expired', updated_at = %s WHERE id = %s", (day_dt, task["id"]))
self._history(cur, task["id"], "expired", "active", "expired", task["task_type"], task["task_type"], {"simulated": True})
# 2. 任务生成<EFBC88><E6B7B7><EFBFBD>冲突策略
relation = snapshot["relation"]
wbi_map = snapshot["wbi"]
nci_map = snapshot["nci"]
ownership_pairs = [
(aid, mid, info)
for (aid, mid), info in relation.items()
if info["os_label"] in ("MAIN", "COMANAGE") and info["session_count"] > 0
]
for aid, mid, info in ownership_pairs:
wbi = wbi_map.get(mid, Decimal(0))
nci = nci_map.get(mid, Decimal(0))
rs = info["rs"]
# 参数化任务判定(与日常 task_generator._process_pair 保持一致)
priority_score = max(wbi, nci)
if task_params:
ht = Decimal(str(task_params["high_threshold"]))
nt = Decimal(str(task_params["normal_threshold"]))
rs_min = Decimal(str(task_params["rs_min"]))
rs_max = Decimal(str(task_params["rs_max"]))
else:
ht, nt, rs_min, rs_max = Decimal(7), Decimal(5), Decimal(1), Decimal(6)
if priority_score > ht:
new_type = "high_priority_recall"
elif priority_score > nt:
new_type = "priority_recall"
elif rs > rs_min and rs < rs_max:
new_type = "relationship_building"
else:
new_type = None
if not new_type:
continue
key = (aid, mid)
existing = active_tasks.get(key)
priority = float(max(wbi, nci)) if new_type in ("high_priority_recall", "priority_recall") else float(rs)
if existing:
if existing["task_type"] == new_type:
continue
if existing["task_type"] == "follow_up_visit":
# follow_up_visit 保留宽限期 + 新建高优先任务
with app_conn.cursor() as cur:
if not existing.get("expires_at"):
cur.execute(
"UPDATE biz.coach_tasks SET expires_at = created_at + INTERVAL '72 hours', updated_at = %s WHERE id = %s",
(day_dt, existing["id"]),
)
self._history(cur, existing["id"], "expires_at_filled", "active", "active",
"follow_up_visit", "follow_up_visit",
{"reason": "higher_priority_task_created", "simulated": True})
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, parent_task_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %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 = EXCLUDED.updated_at
RETURNING id""",
(site_id, aid, mid, new_type, priority, existing["id"], day_dt, day_dt),
)
new_id = cur.fetchone()[0]
self._history(cur, new_id, "created", None, "active", "follow_up_visit", new_type, {"simulated": True})
active_tasks[key] = {"id": new_id, "task_type": new_type, "created_at": day_dt, "expires_at": None, "priority": priority}
stats["created"] += 1
else:
# 非 follow_up原地覆盖
with app_conn.cursor() as cur:
# 先关闭可能冲突的同 new_type active 记录(避免唯一约束冲突)
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'inactive', updated_at = %s
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND task_type = %s AND status = 'active' AND id != %s""",
(day_dt, site_id, aid, mid, new_type, existing["id"]),
)
cur.execute(
"UPDATE biz.coach_tasks SET task_type = %s, priority_score = %s, updated_at = %s WHERE id = %s AND status = 'active'",
(new_type, priority, day_dt, existing["id"]),
)
self._history(cur, existing["id"], "type_override", "active", "active",
existing["task_type"], new_type,
{"old_priority": existing.get("priority"), "simulated": True})
existing["task_type"] = new_type
existing["priority"] = priority
stats["overridden"] += 1
else:
# 新建任务upsert若同类型 active 已存在则更新 priority
with app_conn.cursor() as cur:
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, created_at, updated_at)
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 = EXCLUDED.updated_at
RETURNING id""",
(site_id, aid, mid, new_type, priority, day_dt, day_dt),
)
task_id = cur.fetchone()[0]
self._history(cur, task_id, "created", None, "active", None, new_type, {"simulated": True})
active_tasks[key] = {"id": task_id, "task_type": new_type, "created_at": day_dt, "expires_at": None, "priority": priority}
stats["created"] += 1
# 3. 召回检测(优先使用预加载数据)
settlements = preloaded_settlements if preloaded_settlements is not None else self._load_settlements(etl_conn, site_id, d)
for (aid, mid), pay_time in settlements.items():
key = (aid, mid)
task = active_tasks.get(key)
with app_conn.cursor() as cur:
try:
cur.execute(
"""INSERT INTO biz.recall_events
(site_id, assistant_id, member_id, pay_time, task_id, task_type, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
DO NOTHING RETURNING id""",
(site_id, aid, mid, pay_time, task["id"] if task else None, task["task_type"] if task else None, day_dt),
)
if cur.fetchone():
stats["recall_events"] += 1
except Exception:
pass
if not task or task["task_type"] not in ("high_priority_recall", "priority_recall") or pay_time <= task["created_at"]:
continue
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'completed', completed_at = %s,
completed_task_type = %s, completion_type = 'auto', updated_at = %s
WHERE id = %s AND status = 'active'""",
(pay_time, task["task_type"], day_dt, task["id"]),
)
self._history(cur, task["id"], "completed", "active", "completed",
task["task_type"], task["task_type"],
{"service_time": str(pay_time), "simulated": True})
stats["completed"] += 1
expires_at = pay_time + timedelta(hours=FOLLOW_UP_HOURS)
with app_conn.cursor() as cur:
cur.execute(
"""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, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, task_type) WHERE (status = 'active')
DO UPDATE SET expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at
RETURNING id""",
(site_id, aid, mid, expires_at, day_dt, day_dt),
)
fu_id = cur.fetchone()[0]
self._history(cur, fu_id, "created", None, "active", None, "follow_up_visit",
{"reason": "recall_completed", "simulated": True})
active_tasks[key] = {"id": fu_id, "task_type": "follow_up_visit", "created_at": day_dt, "expires_at": expires_at}
stats["follow_up_created"] += 1
# 3b. 门店级召回解除:客户到店后,未被服务的助教任务标记 resolved
member_visits = preloaded_visits if preloaded_visits is not None else self._load_member_visits(etl_conn, site_id, d)
resolved_keys = [
k for k, t in active_tasks.items()
if k[1] in member_visits
and t["task_type"] in ("high_priority_recall", "priority_recall")
and member_visits[k[1]] > t["created_at"]
]
for key in resolved_keys:
task = active_tasks.pop(key)
pay_time = member_visits[key[1]]
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks SET status = 'resolved', updated_at = %s
WHERE id = %s AND status = 'active'""",
(day_dt, task["id"]),
)
self._history(cur, task["id"], "customer_returned", "active", "resolved",
task["task_type"], task["task_type"],
{"service_time": str(pay_time), "simulated": True})
stats["resolved"] += 1
app_conn.commit()
@staticmethod
def _history(cur, task_id, action, old_status, new_status, old_task_type, new_task_type, detail=None):
if task_id is None:
return
cur.execute(
"""INSERT INTO biz.coach_task_history
(task_id, action, old_status, new_status, old_task_type, new_task_type, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(task_id, action, old_status, new_status, old_task_type, new_task_type,
json.dumps(detail) if detail else None),
)
def _clean_date_range(self, app_conn, tz, start_date: date, end_date: date):
"""清理指定日期范围内的旧任务数据(保留范围外的)。"""
range_start = datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0, tzinfo=tz)
range_end = datetime(end_date.year, end_date.month, end_date.day, 23, 59, 59, tzinfo=tz)
with app_conn.cursor() as cur:
cur.execute(
"DELETE FROM biz.coach_task_history WHERE task_id IN (SELECT id FROM biz.coach_tasks WHERE created_at >= %s AND created_at <= %s)",
(range_start, range_end),
)
h = cur.rowcount
cur.execute(
"DELETE FROM biz.recall_events WHERE created_at >= %s AND created_at <= %s",
(range_start, range_end),
)
e = cur.rowcount
cur.execute(
"DELETE FROM biz.coach_tasks WHERE created_at >= %s AND created_at <= %s",
(range_start, range_end),
)
t = cur.rowcount
app_conn.commit()
if t > 0 or e > 0:
self.logger.info("DWS_TASK_ENGINE [推演]: 清理 %s~%s 旧数据: %d history, %d events, %d tasks", start_date, end_date, h, e, t)
def _load_existing_active_tasks(self, app_conn, site_id: int, before_date: date = None) -> dict:
"""加载数据库中已有的 active 任务到内存字典。
before_date: 只加载 created_at < before_date 的任务,避免加载推演范围之后的"未来"任务。
"""
active_tasks = {}
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
with app_conn.cursor() as cur:
if before_date:
cutoff = datetime(before_date.year, before_date.month, before_date.day, 0, 0, 0, tzinfo=tz)
cur.execute(
"""SELECT id, assistant_id, member_id, task_type, created_at, expires_at, priority_score
FROM biz.coach_tasks
WHERE site_id = %s AND status = 'active' AND created_at < %s""",
(site_id, cutoff),
)
else:
cur.execute(
"""SELECT id, assistant_id, member_id, task_type, created_at, expires_at, priority_score
FROM biz.coach_tasks
WHERE site_id = %s AND status = 'active'""",
(site_id,),
)
for row in cur.fetchall():
key = (row[1], row[2])
active_tasks[key] = {
"id": row[0], "task_type": row[3],
"created_at": row[4], "expires_at": row[5],
"priority": float(row[6]) if row[6] else 0,
}
app_conn.commit()
return active_tasks

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""
指数日快照回填任务DWS_INDEX_BACKFILL
逐天调用 RelationIndexTask / WinbackIndexTask / NewconvIndexTask
为 3 张指数表生成历史日快照。
CHANGE 2026-04-12 | 性能优化:
- 任务实例复用(创建 1 次,循环 N 天复用)
- 减少 765 次 Task 初始化和参数表查询
CLI 用法:
python -m cli.main --tasks DWS_INDEX_BACKFILL \\
--window-start 2025-08-01 --window-end 2026-04-11
admin-web在 ETL 任务配置页面选择 DWS_INDEX_BACKFILL设置时间窗口。
"""
from __future__ import annotations
import time
from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional
from ..base_task import BaseTask, TaskContext
from ..dws.index.relation_index_task import RelationIndexTask
from ..dws.index.winback_index_task import WinbackIndexTask
from ..dws.index.newconv_index_task import NewconvIndexTask
class IndexBackfillTask(BaseTask):
"""指数日快照回填工具任务。"""
def get_task_code(self) -> str:
return "DWS_INDEX_BACKFILL"
def execute(self, context: Optional[TaskContext] = None) -> Dict[str, Any]:
"""主流程:解析日期范围,逐天执行 3 个指数任务。"""
start_date, end_date = self._parse_date_range(context)
store_id = self._resolve_store_id(context)
total_days = (end_date - start_date).days + 1
self.logger.info(
"DWS_INDEX_BACKFILL: %s ~ %s (%d天), store_id=%s",
start_date, end_date, total_days, store_id,
)
# 创建 1 次实例,循环复用(避免 765 次 __init__ + 参数查询)
task_instances = [
RelationIndexTask(self.config, self.db, self.api, self.logger),
WinbackIndexTask(self.config, self.db, self.api, self.logger),
NewconvIndexTask(self.config, self.db, self.api, self.logger),
]
task_names = ["RS", "WBI", "NCI"]
completed = 0
errors = 0
t0 = time.time()
current = start_date
while current <= end_date:
ctx = self._build_day_context(current, store_id)
day_num = (current - start_date).days + 1
day_t0 = time.time()
for i, task in enumerate(task_instances):
try:
task.execute(ctx)
completed += 1
except Exception:
self.logger.exception(
"DWS_INDEX_BACKFILL: %s %s 失败",
task.__class__.__name__, current,
)
errors += 1
self.logger.info(
"DWS_INDEX_BACKFILL: %s [%d/%d] %s (%d/3)",
current, day_num, total_days, task_names[i], i + 1,
)
elapsed = time.time() - day_t0
total_elapsed_so_far = time.time() - t0
avg_per_day = total_elapsed_so_far / day_num
eta = avg_per_day * (total_days - day_num)
self.logger.info(
"DWS_INDEX_BACKFILL: %s [%d/%d %.0f%%] %.1fs/天 ETA %.0fs",
current, day_num, total_days, day_num / total_days * 100,
elapsed, eta,
)
current += timedelta(days=1)
total_elapsed = time.time() - t0
self.logger.info(
"DWS_INDEX_BACKFILL 完成: %d/%d 成功, %d 失败, %.0fs",
completed, total_days * 3, errors, total_elapsed,
)
return {
"status": "SUCCESS" if errors == 0 else "PARTIAL",
"counts": {
"days": total_days,
"completed": completed,
"errors": errors,
"elapsed_sec": round(total_elapsed, 1),
},
}
def _parse_date_range(self, context: Optional[TaskContext]) -> tuple[date, date]:
"""从 config 或 context 解析日期范围。"""
wo = self.config.get("run.window_override") or {}
start_str = wo.get("start")
end_str = wo.get("end")
if start_str and end_str:
return self._parse_date(start_str), self._parse_date(end_str)
if context and context.window_start and context.window_end:
return context.window_start.date(), context.window_end.date()
raise ValueError(
"DWS_INDEX_BACKFILL 需要指定日期范围。"
"CLI: --window-start 2025-08-01 --window-end 2026-04-11"
)
@staticmethod
def _parse_date(s) -> date:
if isinstance(s, date) and not isinstance(s, datetime):
return s
if isinstance(s, datetime):
return s.date()
return date.fromisoformat(str(s).strip()[:10])
def _resolve_store_id(self, context: Optional[TaskContext]) -> int:
if context and getattr(context, "store_id", None):
return int(context.store_id)
sid = self.config.get("app.store_id")
if sid:
return int(sid)
raise ValueError("DWS_INDEX_BACKFILL 需要 store_id")
def _build_day_context(self, d: date, store_id: int) -> TaskContext:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
as_of = datetime(d.year, d.month, d.day, 23, 59, 0, tzinfo=tz)
window_start = as_of - timedelta(days=90)
return TaskContext(
store_id=store_id,
window_start=window_start,
window_end=as_of,
window_minutes=int((as_of - window_start).total_seconds() / 60),
as_of_date=as_of,
)

View File

@@ -0,0 +1,473 @@
# -*- coding: utf-8 -*-
"""
历史任务推演任务DWS_TASK_SIMULATION
基于指数日快照,逐天重放 task_generator + recall_detector 逻辑,
还原完整的任务生命周期。
CLI 用法:
python -m cli.main --tasks DWS_TASK_SIMULATION \\
--window-start 2025-08-01 --window-end 2026-03-28
admin-web在 ETL 任务配置页面选择 DWS_TASK_SIMULATION设置时间窗口。
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
import psycopg2
from ..base_task import BaseTask, TaskContext
# 导入 task_generator 纯函数(后端代码)
_BACKEND = Path(__file__).resolve().parents[5] / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from app.services.task_generator import (
IndexData,
determine_task_type,
should_replace_task,
)
logger = logging.getLogger(__name__)
# 推演截止日期(现有 active 任务从 03-29 开始)
CUTOFF_DATE = date(2026, 3, 28)
FOLLOW_UP_HOURS = 48
class TaskSimulationTask(BaseTask):
"""历史任务推演工具任务。"""
def get_task_code(self) -> str:
return "DWS_TASK_SIMULATION"
def execute(self, context: Optional[TaskContext] = None) -> Dict[str, Any]:
"""主流程:解析日期范围,逐天推演。"""
start_date, end_date = self._parse_date_range(context)
if end_date > CUTOFF_DATE:
self.logger.warning(
"end_date %s 超过截止日期 %s,自动截断", end_date, CUTOFF_DATE
)
end_date = CUTOFF_DATE
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
# ETL 库连接复用 self.db.conn
etl_conn = self.db.conn
# 业务库需要独立连接
app_dsn = os.environ.get("APP_DB_DSN")
if not app_dsn:
raise ValueError("DWS_TASK_SIMULATION 需要 APP_DB_DSN 环境变量")
app_conn = psycopg2.connect(app_dsn)
app_conn.set_client_encoding("UTF8")
site_id = self._get_site_id(etl_conn)
total_days = (end_date - start_date).days + 1
self.logger.info(
"DWS_TASK_SIMULATION: %s ~ %s (%d天), site_id=%s",
start_date, end_date, total_days, site_id,
)
# 清理截止日期前的旧数据(模拟数据可安全清理)
self._clean_before_cutoff(app_conn, CUTOFF_DATE)
active_tasks: dict[tuple[int, int], dict] = {}
stats = {
"created": 0, "completed": 0, "overridden": 0,
"expired": 0, "follow_up_created": 0,
"recall_events": 0, "skipped_no_snapshot": 0,
}
t0 = time.time()
current = start_date
while current <= end_date:
snapshot = self._load_snapshot(etl_conn, site_id, current)
if not snapshot["relation"] and not snapshot["wbi"] and not snapshot["nci"]:
stats["skipped_no_snapshot"] += 1
current += timedelta(days=1)
continue
self._simulate_day(
app_conn, etl_conn, site_id, current, tz,
snapshot, active_tasks, stats,
)
day_num = (current - start_date).days + 1
if day_num % 30 == 0 or current == end_date:
elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_SIMULATION: %s (%d/%d) 创建=%d 完成=%d 覆盖=%d 过期=%d %.0fs",
current, day_num, total_days,
stats["created"], stats["completed"],
stats["overridden"], stats["expired"], elapsed,
)
current += timedelta(days=1)
total_elapsed = time.time() - t0
self.logger.info(
"DWS_TASK_SIMULATION 完成: %.0fs, 创建=%d 完成=%d 覆盖=%d 过期=%d 回访=%d 事件=%d 跳过=%d active=%d",
total_elapsed, stats["created"], stats["completed"],
stats["overridden"], stats["expired"],
stats["follow_up_created"], stats["recall_events"],
stats["skipped_no_snapshot"], len(active_tasks),
)
app_conn.close()
return {
"status": "SUCCESS",
"counts": stats,
}
# ── 日期解析 ──
def _parse_date_range(self, context: Optional[TaskContext]) -> tuple[date, date]:
wo = self.config.get("run.window_override") or {}
start_str = wo.get("start")
end_str = wo.get("end")
if start_str and end_str:
return self._parse_date(start_str), self._parse_date(end_str)
if context and context.window_start and context.window_end:
return context.window_start.date(), context.window_end.date()
raise ValueError(
"DWS_TASK_SIMULATION 需要指定日期范围。"
"CLI: --window-start 2025-08-01 --window-end 2026-03-28"
)
@staticmethod
def _parse_date(s) -> date:
if isinstance(s, date) and not isinstance(s, datetime):
return s
if isinstance(s, datetime):
return s.date()
return date.fromisoformat(str(s).strip()[:10])
def _get_site_id(self, etl_conn) -> int:
with etl_conn.cursor() as cur:
cur.execute(
"SELECT DISTINCT site_id FROM dws.dws_member_assistant_relation_index LIMIT 1"
)
row = cur.fetchone()
etl_conn.commit()
if not row:
raise RuntimeError("relation_index 表为空,请先运行 DWS_INDEX_BACKFILL")
return row[0]
# ── 数据加载 ──
def _load_snapshot(self, etl_conn, site_id: int, stat_date: date) -> dict:
result = {"relation": {}, "wbi": {}, "nci": {}}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT assistant_id, member_id, rs_display, os_label, session_count
FROM dws.dws_member_assistant_relation_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["relation"][(r[0], r[1])] = {
"rs": Decimal(str(r[2])), "os_label": r[3], "session_count": r[4],
}
cur.execute(
"""SELECT member_id, display_score FROM dws.dws_member_winback_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["wbi"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
cur.execute(
"""SELECT member_id, display_score FROM dws.dws_member_newconv_index
WHERE site_id = %s AND stat_date = %s""",
(site_id, stat_date),
)
for r in cur.fetchall():
result["nci"][r[0]] = Decimal(str(r[1])) if r[1] else Decimal(0)
etl_conn.commit()
return result
def _load_settlements(self, etl_conn, site_id: int, d: date) -> dict:
"""加载当天结算 → {(assistant_id, member_id): pay_time}"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
day_start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
day_end = day_start + timedelta(days=1)
settlements = {}
with etl_conn.cursor() as cur:
cur.execute(
"""SELECT sl.site_assistant_id, sh.member_id, MAX(sh.pay_time)
FROM dwd.dwd_settlement_head sh
JOIN dwd.dwd_assistant_service_log sl
ON sl.order_settle_id = sh.order_settle_id AND sl.is_delete = 0
WHERE sh.site_id = %s AND sh.settle_type IN (1,3)
AND sh.pay_time >= %s AND sh.pay_time < %s
GROUP BY sl.site_assistant_id, sh.member_id""",
(site_id, day_start, day_end),
)
for r in cur.fetchall():
if r[0] and r[1]:
settlements[(r[0], r[1])] = r[2]
etl_conn.commit()
return settlements
# ── 模拟逻辑 ──
def _simulate_day(
self, app_conn, etl_conn, site_id, d, tz,
snapshot, active_tasks, stats,
):
day_dt = datetime(d.year, d.month, d.day, 7, 0, 0, tzinfo=tz)
# 1. 过期检测
expired_keys = [
k for k, t in active_tasks.items()
if t.get("expires_at") and t["expires_at"] < day_dt
]
for key in expired_keys:
task = active_tasks.pop(key)
stats["expired"] += 1
with app_conn.cursor() as cur:
cur.execute(
"UPDATE biz.coach_tasks SET status = 'expired', updated_at = %s WHERE id = %s",
(day_dt, task["id"]),
)
self._history(cur, task["id"], "expired", "active", "expired",
task["task_type"], task["task_type"],
{"simulated": True})
# 2. 任务生成(冲突覆盖)
relation = snapshot["relation"]
wbi_map = snapshot["wbi"]
nci_map = snapshot["nci"]
ownership_pairs = [
(aid, mid, info)
for (aid, mid), info in relation.items()
if info["os_label"] in ("MAIN", "COMANAGE") and info["session_count"] > 0
]
for aid, mid, info in ownership_pairs:
wbi = wbi_map.get(mid, Decimal(0))
nci = nci_map.get(mid, Decimal(0))
rs = info["rs"]
new_type = determine_task_type(IndexData(
site_id=site_id, assistant_id=aid, member_id=mid,
wbi=wbi, nci=nci, rs=rs,
has_active_recall=False, has_follow_up_note=False,
))
if not new_type:
continue
key = (aid, mid)
existing = active_tasks.get(key)
priority = float(max(wbi, nci)) if new_type in (
"high_priority_recall", "priority_recall"
) else float(rs)
if existing:
if existing["task_type"] == new_type:
continue # 同类型跳过
if existing["task_type"] == "follow_up_visit":
# follow_up_visit 保留宽限期,填 expires_at新建高优先任务
with app_conn.cursor() as cur:
if not existing.get("expires_at"):
cur.execute(
"""UPDATE biz.coach_tasks
SET expires_at = created_at + INTERVAL '48 hours', updated_at = %s
WHERE id = %s""",
(day_dt, existing["id"]),
)
self._history(cur, existing["id"], "expires_at_filled",
"active", "active",
"follow_up_visit", "follow_up_visit",
{"reason": "higher_priority_task_created", "simulated": True})
# 新建高优先任务
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, parent_task_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s, %s)
RETURNING id""",
(site_id, aid, mid, new_type, priority,
existing["id"], day_dt, day_dt),
)
new_id = cur.fetchone()[0]
self._history(cur, new_id, "created", None, "active",
"follow_up_visit", new_type, {"simulated": True})
active_tasks[key] = {
"id": new_id, "task_type": new_type,
"created_at": day_dt, "expires_at": None,
"priority": priority,
}
stats["created"] += 1
else:
# 非 follow_up原地覆盖
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks
SET task_type = %s, priority_score = %s, updated_at = %s
WHERE id = %s AND status = 'active'""",
(new_type, priority, day_dt, existing["id"]),
)
self._history(cur, existing["id"], "type_override", "active", "active",
existing["task_type"], new_type,
{"old_priority": existing.get("priority"), "simulated": True})
existing["task_type"] = new_type
existing["priority"] = priority
stats["overridden"] += 1
else:
# 新建任务
with app_conn.cursor() as cur:
cur.execute(
"""INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, created_at, updated_at)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
RETURNING id""",
(site_id, aid, mid, new_type, priority, day_dt, day_dt),
)
task_id = cur.fetchone()[0]
self._history(cur, task_id, "created", None, "active",
None, new_type, {"simulated": True})
active_tasks[key] = {
"id": task_id, "task_type": new_type,
"created_at": day_dt, "expires_at": None,
"priority": priority,
}
stats["created"] += 1
# 3. 召回检测
settlements = self._load_settlements(etl_conn, site_id, d)
for (aid, mid), pay_time in settlements.items():
key = (aid, mid)
task = active_tasks.get(key)
# 写 recall_event
with app_conn.cursor() as cur:
try:
cur.execute(
"""INSERT INTO biz.recall_events
(site_id, assistant_id, member_id, pay_time,
task_id, task_type, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
DO NOTHING RETURNING id""",
(site_id, aid, mid, pay_time,
task["id"] if task else None,
task["task_type"] if task else None,
day_dt),
)
if cur.fetchone():
stats["recall_events"] += 1
except Exception:
pass
if not task:
continue
if task["task_type"] not in ("high_priority_recall", "priority_recall"):
continue
if pay_time <= task["created_at"]:
continue
# 完成召回
with app_conn.cursor() as cur:
cur.execute(
"""UPDATE biz.coach_tasks
SET status = 'completed', completed_at = %s,
completed_task_type = %s, completion_type = 'auto', updated_at = %s
WHERE id = %s AND status = 'active'""",
(pay_time, task["task_type"], day_dt, task["id"]),
)
self._history(cur, task["id"], "completed", "active", "completed",
task["task_type"], task["task_type"],
{"service_time": str(pay_time), "simulated": True})
stats["completed"] += 1
# 生成回访
expires_at = pay_time + timedelta(hours=FOLLOW_UP_HOURS)
with app_conn.cursor() as cur:
cur.execute(
"""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, %s, %s)
RETURNING id""",
(site_id, aid, mid, expires_at, day_dt, day_dt),
)
fu_id = cur.fetchone()[0]
self._history(cur, fu_id, "created", None, "active",
None, "follow_up_visit",
{"reason": "recall_completed", "simulated": True})
active_tasks[key] = {
"id": fu_id, "task_type": "follow_up_visit",
"created_at": day_dt, "expires_at": expires_at,
}
stats["follow_up_created"] += 1
app_conn.commit()
# ── 辅助 ──
@staticmethod
def _history(cur, task_id, action, old_status, new_status,
old_task_type, new_task_type, detail=None):
if task_id is None:
return
cur.execute(
"""INSERT INTO biz.coach_task_history
(task_id, action, old_status, new_status,
old_task_type, new_task_type, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(task_id, action, old_status, new_status,
old_task_type, new_task_type,
json.dumps(detail) if detail else None),
)
def _clean_before_cutoff(self, app_conn, cutoff: date):
"""清理截止日期前的数据(安全:只删模拟产生的历史数据)。"""
tz = ZoneInfo(self.config.get("app.timezone", "Asia/Shanghai"))
cutoff_dt = datetime(cutoff.year, cutoff.month, cutoff.day, 23, 59, 59, tzinfo=tz)
with app_conn.cursor() as cur:
cur.execute(
"""DELETE FROM biz.coach_task_history
WHERE task_id IN (SELECT id FROM biz.coach_tasks WHERE created_at < %s)""",
(cutoff_dt,),
)
h = cur.rowcount
cur.execute(
"DELETE FROM biz.recall_events WHERE created_at < %s",
(cutoff_dt,),
)
e = cur.rowcount
cur.execute(
"DELETE FROM biz.coach_tasks WHERE created_at < %s",
(cutoff_dt,),
)
t = cur.rowcount
app_conn.commit()
if t > 0:
self.logger.info(
"DWS_TASK_SIMULATION: 清理旧数据 %d history, %d events, %d tasks",
h, e, t,
)

View File

@@ -17,6 +17,7 @@
"pages/customer-service-records/customer-service-records",
"pages/customer-records/customer-records",
"pages/coach-detail/coach-detail",
"pages/coach-service-records/coach-service-records",
"pages/chat/chat",
"pages/chat-history/chat-history",
"pages/dev-tools/dev-tools"

View File

@@ -255,6 +255,6 @@
font-weight: 600;
}
.ppb-tick--highlight {
color: rgba(255, 255, 255, 0.85);
color: var(--ppb-tick-highlight-color, rgba(255, 255, 255, 0.85));
font-weight: 500;
}

View File

@@ -270,8 +270,8 @@ Page({
svAmountLabel: formatMoney(c.svAmount ?? 0),
svCustomerCountLabel: formatCount(c.svCustomerCount ?? 0, '人'),
svConsumeLabel: formatMoney(c.svConsume ?? 0),
taskRecallLabel: formatCount(c.taskRecall ?? 0, '次'),
taskCallbackLabel: formatCount(c.taskCallback ?? 0, '次'),
taskRecallLabel: `${c.taskRecall ?? 0}`,
taskCallbackLabel: `${c.taskCallback ?? 0}`,
}))
// 追加时按 id 去重,避免 wx:key 重复警告

View File

@@ -8,40 +8,12 @@ import { fetchCoachDetail } from '../../services/api'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { sortByTimestamp } from '../../utils/sort'
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
*/
const SHINE_SPEED = 70
const SPARK_DELAY_MS = -150
const SPARK_DUR_MS = 1400
const NEXT_LOOP_DELAY_MS = 400
const SHINE_WIDTH_RPX = 120
const TRACK_WIDTH_RPX = 634
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99
const baseDur = 5000 - t * (5000 - 50)
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
interface TickItem {
value: number
label: string
left: string
highlight: boolean
}
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2,
}))
}
import { nameToAvatarColor } from '../../utils/avatar-color'
import {
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
calcShineDur, buildProgressBarData,
type TickItem,
} from '../../utils/perf-progress'
/** 助教详情(含绩效、收入、任务、客户关系等) */
interface CoachDetail {
@@ -54,18 +26,33 @@ interface CoachDetail {
customerCount: number
hireDate: string
performance: {
monthlyHours: number
monthlySalary: number
// 核心绩效字段(与任务页 PerformanceSummary 一致)
totalHours: number
totalIncome: number
totalCustomers: number
tierNodes: number[]
basicHours: number
bonusHours: number
currentTier: number
nextTierHours: number
tierCompleted: boolean
bonusMoney: number
incomeTrend: string
incomeTrendDir: string
currentTierLabel: string
// 助教详情专属扩展
customerBalance: number
tasksCompleted: number
/** 绩效档位 */
perfCurrent: number
perfTarget: number
// 兼容旧字段
monthlyHours: number
monthlySalary: number
}
income: {
thisMonth: IncomeItem[]
lastMonth: IncomeItem[]
}
taskStats: { callback: number; recall: number }
tierNodes: number[]
notes: NoteItem[]
}
@@ -147,17 +134,30 @@ const mockCoachDetail: CoachDetail = {
customerCount: 0,
hireDate: '',
performance: {
monthlyHours: 0,
monthlySalary: 0,
totalHours: 0,
totalIncome: 0,
totalCustomers: 0,
tierNodes: [],
basicHours: 0,
bonusHours: 0,
currentTier: 0,
nextTierHours: 0,
tierCompleted: false,
bonusMoney: 0,
incomeTrend: '',
incomeTrendDir: 'up',
currentTierLabel: '',
customerBalance: 0,
tasksCompleted: 0,
perfCurrent: 0,
perfTarget: 0,
monthlyHours: 0,
monthlySalary: 0,
},
income: {
thisMonth: [],
lastMonth: [],
},
taskStats: { callback: 0, recall: 0 },
tierNodes: [],
notes: [],
}
@@ -197,6 +197,8 @@ Page({
coachId: '',
/** 助教详情 */
detail: null as CoachDetail | null,
/** 等级标签背景色(助教详情页专属,适配深色背景) */
levelBgColor: '',
/** 绩效指标卡片 */
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
/** 绩效进度 */
@@ -307,52 +309,67 @@ Page({
}
const perf = d.performance || {} as any
// 统一使用 totalHours来自 monthly_summary 实时值),与任务页一致
const totalHours = perf.totalHours ?? perf.total_hours ?? perf.monthlyHours ?? 0
const perfCards = [
{ label: '本月定档业绩', value: formatHours(perf.monthlyHours ?? 0), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
{ label: '本月定档业绩', value: formatHours(totalHours), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? perf.totalIncome ?? perf.total_income ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? perf.customer_balance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? perf.tasks_completed ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
]
const perfGap = (perf.perfTarget ?? 0) - (perf.perfCurrent ?? 0)
const perfPercent = perf.perfTarget > 0 ? Math.min(Math.round(((perf.perfCurrent ?? 0) / perf.perfTarget) * 100), 100) : 0
// 统一使用共用模块计算进度条数据(与任务页相同逻辑)
const pbData = buildProgressBarData(perf)
// 档位节点从 API 返回fallback [0, 120, 150, 180, 210]
const tierNodes = d.tierNodes && d.tierNodes.length > 0 ? d.tierNodes : [0, 120, 150, 180, 210]
const maxHours = tierNodes[tierNodes.length - 1] || 210
const totalHours = perf.monthlyHours ?? 0
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
let pbCurrentTier = 0
for (let i = 1; i < tierNodes.length; i++) {
if (totalHours >= tierNodes[i]) pbCurrentTier = i
else break
}
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const perfGap = Math.max(0, nextTierHours - totalHours)
const tierNodes = perf.tierNodes ?? perf.tier_nodes ?? [0]
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
const perfPercent = maxHours > 0 ? Math.min(Math.round((totalHours / maxHours) * 100), 100) : 0
const sorted = sortByTimestamp(d.notes || [], 'timestamp') as NoteItem[]
const taskStats = d.taskStats ?? { recall: 0, callback: 0 }
// 等级标签背景色(助教详情页深色背景专用)
const level = (d as any).level || ''
const levelBgMap: Record<string, string> = {
'junior': '#E5EDFB', '初级': '#E5EDFB',
'middle': '#FDEFE5', '中级': '#FDEFE5',
'senior': '#FDE3EC', '高级': '#FDE3EC',
}
const levelBgColor = levelBgMap[level] || ''
this.setData({
pageState: 'normal',
detail: d,
levelBgColor,
perfCards,
perfCurrent: perf.perfCurrent ?? 0,
perfTarget: perf.perfTarget ?? 0,
perfCurrent: totalHours,
perfTarget: nextTierHours,
perfGap,
perfPercent,
taskStats,
visibleTasks: d.visibleTasks || [],
hiddenTasks: d.hiddenTasks || [],
abandonedTasks: d.abandonedTasks || [],
topCustomers: d.topCustomers || [],
serviceRecords: d.serviceRecords || [],
topCustomers: (d.topCustomers || [])
.map((c: any) => ({
...c,
avatarGradient: nameToAvatarColor(String(c.id || '')),
}))
.sort((a: any, b: any) => parseFloat(b.score || 0) - parseFloat(a.score || 0)),
serviceRecords: (d.serviceRecords || []).map((r: any) => ({
...r,
avatarGradient: nameToAvatarColor(String(r.customerId || '')),
})),
historyMonths: d.historyMonths || [],
sortedNotes: sorted,
pbFilledPct,
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
pbCurrentTier,
pbTicks: buildTicks(tierNodes, maxHours),
pbShineDurMs: calcShineDur(pbFilledPct),
pbSparkDurMs: SPARK_DUR_MS,
pbFilledPct: pbData.filledPct,
pbClampedSparkPct: pbData.clampedSparkPct,
pbCurrentTier: pbData.currentTier,
pbTicks: pbData.ticks,
pbShineDurMs: pbData.shineDurMs,
pbSparkDurMs: pbData.sparkDurMs,
})
this.switchIncomeTab('this')
@@ -394,11 +411,18 @@ Page({
this.setData({ tasksExpanded: !this.data.tasksExpanded })
},
/** 点击任务项 — 跳转客户详情 */
/** 点击任务项 — 跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
const name = e.currentTarget.dataset.name as string
if (!name) return
wx.navigateTo({ url: `/pages/customer-detail/customer-detail?name=${encodeURIComponent(name)}` })
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 展开/收起客户关系列表 */
@@ -432,29 +456,39 @@ Page({
this.setData({ notesPopupVisible: false })
},
/** 点击客户卡片 — 跳转客户详情 */
/** 点击客户卡片 — 跳转客户详情(散客 id ≤ 0 时无详情可看) */
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
const id = e.currentTarget.dataset.id as string
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${id}`,
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 近期服务明细 — 点击跳转客户详情 */
/** 近期服务明细 — 点击跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail${id ? '?id=' + id : ''}`,
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 查看更多服务记录 */
/** 查看更多服务记录 → 跳"助教业绩明细"页(管理者视角,独立于任务 tab 自查页) */
onViewMoreRecords() {
const coachId = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/performance-records/performance-records?coachId=${coachId}`,
url: `/pages/coach-service-records/coach-service-records?coachId=${coachId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},

View File

@@ -25,7 +25,7 @@
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{fmt.safe(detail.name)}}</text>
<coach-level-tag level="{{detail.level}}" />
<coach-level-tag level="{{detail.level}}" bgColor="{{levelBgColor}}" shadowColor="transparent" />
</view>
<view class="skill-row">
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="index">{{item}}</text>
@@ -79,7 +79,7 @@
sparkRunning="{{pbSparkRunning}}"
shineDurMs="{{pbShineDurMs}}"
sparkDurMs="{{pbSparkDurMs}}"
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6);"
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6); --ppb-tick-highlight-color: #3b82f6;"
/>
</view>
</view>
@@ -117,14 +117,14 @@
<text class="section-title title-orange">任务执行</text>
<view class="task-summary">
<text class="task-summary-label">本月完成</text>
<text class="task-summary-callback">回访<text class="task-summary-num">{{fmt.count(taskStats.callback, '个')}}</text></text>
<text class="task-summary-recall">召回<text class="task-summary-num">{{fmt.count(taskStats.recall, '个')}}</text></text>
<text class="task-summary-callback">回访<text class="task-summary-num">{{taskStats.callback}}</text></text>
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}</text></text>
</view>
</view>
<view class="task-list">
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" wx:key="index"
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover">
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="task-customer-name">{{item.customerName}}</text>
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-index="{{index}}" hover-class="task-note-btn--hover">
@@ -137,7 +137,7 @@
<block wx:if="{{tasksExpanded}}">
<view class="task-list task-list-extra">
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{hiddenTasks}}" wx:key="index"
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover">
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
<text class="task-customer-name">{{item.customerName}}</text>
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-hidden-index="{{index}}" hover-class="task-note-btn--hover">
@@ -177,7 +177,7 @@
</view>
<view class="top-customer-info">
<view class="top-customer-name-row">
<text class="top-customer-name">{{item.name}}</text>
<text class="top-customer-name {{item.isScattered ? 'top-customer-name--scattered' : ''}}">{{item.name}}</text>
<text class="top-customer-heart">{{item.heartEmoji}}</text>
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{fmt.safe(item.score)}}</text>
</view>
@@ -211,14 +211,14 @@
<view class="svc-content">
<!-- 第1行客户名 + 类型标签 + 日期 -->
<view class="svc-row1">
<text class="svc-customer">{{item.customerName}}</text>
<text class="svc-customer {{item.isScattered ? 'svc-customer--scattered' : ''}}">{{item.customerName}}</text>
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
<text class="svc-date">{{item.date}}</text>
</view>
<!-- 第2行台号 + 时长 + 绩效 + 收入 -->
<view class="svc-row2">
<view class="svc-row2-left">
<text class="svc-table-tag">{{item.table}}</text>
<text class="svc-table-tag" wx:if="{{item.table}}">{{item.table}}</text>
<text class="svc-duration">{{item.duration}}</text>
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
</view>

View File

@@ -744,6 +744,8 @@ view {
}
.svc-row1 { display: flex; align-items: center; gap: 12rpx; }
.svc-customer { font-size: 28rpx; font-weight: 600; color: #242424; }
/* 散客名称置灰 */
.svc-customer--scattered { color: #999; font-weight: 500; }
.svc-type {
font-size: 22rpx;
padding: 2rpx 12rpx;
@@ -1083,6 +1085,8 @@ view {
.top-customer-info { flex: 1; min-width: 0; }
.top-customer-name-row { display: flex; align-items: center; gap: 8rpx; margin-bottom: 8rpx; }
.top-customer-name { font-size: 28rpx; font-weight: 600; color: #242424; }
/* 散客名称置灰 */
.top-customer-name--scattered { color: #999; font-weight: 500; }
.top-customer-heart { font-size: 24rpx; }
.top-customer-score { font-size: 24rpx; font-weight: 700; font-variant-numeric: tabular-nums; }
.top-customer-score-success { color: #00a870; }

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "助教业绩明细",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"heart-icon": "/components/heart-icon/heart-icon",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,275 @@
/**
* 助教服务明细页(管理者视角)。
*
* 入口pages/coach-detail/coach-detail "近期服务明细" 卡片"查看更多"按钮
* 必传 querycoachIdassistant_id
*
* 与任务 tab 下的 pages/performance-records/ 区别:
* - Banner 用 fetchCoachBanner 取目标助教信息name/level/storeName
* - 标题展示"<助教名>的业绩"突出查看视角
* - 单条记录右下角显示"助教预估收入"(去第一人称)
* - 点击单条记录跳 customer-detail管理者关心客户而非任务
* - 后端 /api/xcx/performance/records?coach_id=xxx权限码 view_board_coach
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchPerformanceRecords, fetchCoachBanner } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
const COURSE_TAG_MAP: Record<string, string> = {
'陪打': 'basic', '基础课': 'basic',
'包厢': 'room', '包厢课': 'room',
'超休': 'incentive', '激励课': 'incentive', '打赏课': 'incentive',
}
function courseTagClass(courseType: string): string {
return COURSE_TAG_MAP[courseType] || 'basic'
}
interface DateGroup {
date: string
totalHours: string
totalIncome: string
records: RecordItem[]
}
interface RecordItem {
customerName: string
memberId: number
avatarChar: string
avatarColor: string
timeRange: string
hours: string
courseType: string
courseTagClass: string
location: string
income: string
isScattered?: boolean
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 目标助教 ID必传来自 query */
coachId: 0,
/** Banner — 来自 fetchCoachBanner */
coachName: '',
coachRole: '',
storeName: '',
/** Banner 主标题:用助教名生成"<助教名>的业绩" */
pageTitle: '业绩明细',
/** 月份切换 */
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthLabel: '',
canGoPrev: true,
canGoNext: false,
/** 当月预估判断 */
isCurrentMonth: false,
/** 统计概览 */
totalCountLabel: '--',
totalHoursLabel: '--',
totalHoursRawLabel: '',
totalIncomeLabel: '--',
/** 按日期分组的记录 */
dateGroups: [] as DateGroup[],
/** 分页 */
page: 1,
pageSize: 20,
hasMore: false,
},
onLoad(options: Record<string, string | undefined>) {
const coachIdNum = Number(options?.coachId)
const coachId = Number.isFinite(coachIdNum) && coachIdNum > 0 ? coachIdNum : 0
if (coachId === 0) {
// 必传参数缺失:提示并退回上一页
wx.showToast({ title: '缺少助教标识', icon: 'none' })
setTimeout(() => wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/board-finance/board-finance' }) }), 1000)
return
}
const now = new Date()
this.setData({
coachId,
currentYear: now.getFullYear(),
currentMonth: now.getMonth() + 1,
monthLabel: `${now.getFullYear()}${now.getMonth() + 1}`,
})
this.loadBanner()
this.loadData()
},
onShow() {
checkPageAccess('pages/coach-service-records/coach-service-records')
},
onPullDownRefresh() {
this.setData({ page: 1, dateGroups: [] })
this.loadData(() => wx.stopPullDownRefresh())
},
onReachBottom() {
if (!this.data.hasMore) return
this.setData({ page: this.data.page + 1 })
this.loadData()
},
/** 加载 Banner仅 name/level/storeName3 字段轻量接口) */
async loadBanner() {
try {
const banner = await fetchCoachBanner(String(this.data.coachId))
if (!banner) return
const name = banner.name || ''
this.setData({
coachName: name,
coachRole: banner.level || '助教',
storeName: banner.storeName || '',
pageTitle: name ? `${name}的业绩` : '业绩明细',
})
// 同步原生 navbar 标题
if (name) {
wx.setNavigationBarTitle({ title: `${name}的业绩` })
}
} catch (_e) {
// banner 加载失败不阻塞列表
}
},
/** 加载服务明细 */
async loadData(cb?: () => void) {
if (this.data.page === 1) {
this.setData({ pageState: 'loading' })
}
wx.showLoading({ title: '加载中...', mask: true })
const now = new Date()
const { currentYear, currentMonth } = this.data
const isCurrentMonth = currentYear === now.getFullYear()
&& currentMonth === now.getMonth() + 1
&& now.getDate() <= 5
try {
const res = await fetchPerformanceRecords({
year: currentYear,
month: currentMonth,
page: this.data.page,
pageSize: this.data.pageSize,
coachId: this.data.coachId,
})
const newGroups = (res.dateGroups || []).map((g: any) => ({
...g,
records: (g.records || []).map((rec: any) => ({
...rec,
avatarColor: nameToAvatarColor(String(rec.memberId ?? '')),
avatarChar: rec.avatarChar || (rec.customerName || '?').charAt(0),
courseTagClass: courseTagClass(rec.courseType || ''),
})),
}))
let dateGroups: DateGroup[]
if (this.data.page === 1) {
dateGroups = newGroups
} else {
dateGroups = this._mergeGroups(this.data.dateGroups, newGroups)
}
const updates: Record<string, any> = {
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
isCurrentMonth,
dateGroups,
hasMore: res.hasMore ?? false,
}
if (this.data.page === 1 && res.summary) {
const s = res.summary
updates.totalCountLabel = formatCount(s.totalCount, '笔')
updates.totalHoursLabel = formatHours(s.totalHours)
updates.totalIncomeLabel = formatMoney(s.totalIncome)
updates.totalHoursRawLabel = (s.totalHoursRaw !== s.totalHours && s.totalHoursRaw > 0)
? formatHours(s.totalHoursRaw) : ''
}
this.setData(updates)
} catch (_err) {
if (this.data.page === 1) {
this.setData({ pageState: 'error' })
}
} finally {
wx.hideLoading()
}
cb?.()
},
_mergeGroups(existing: DateGroup[], incoming: DateGroup[]): DateGroup[] {
const merged = [...existing]
for (const g of incoming) {
const found = merged.find(m => m.date === g.date)
if (found) {
found.records = [...found.records, ...g.records]
} else {
merged.push(g)
}
}
return merged
},
onRetry() {
this.setData({ page: 1, dateGroups: [] })
this.loadData()
},
/** 点击单条记录 → 跳 customer-detail管理者视角关心客户 */
onRecordTap(e: WechatMiniprogram.TouchEvent) {
const { memberId } = e.currentTarget.dataset
const mid = Number(memberId)
if (!mid || mid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${memberId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
let { currentYear, currentMonth } = this.data
if (direction === 'prev') {
currentMonth--
if (currentMonth < 1) { currentMonth = 12; currentYear-- }
} else {
currentMonth++
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
}
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5
this.setData({
currentYear,
currentMonth,
monthLabel: `${currentYear}${currentMonth}`,
canGoNext,
canGoPrev: true,
isCurrentMonth,
page: 1,
dateGroups: [],
})
this.loadData()
},
})

View File

@@ -0,0 +1,123 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 错误态 -->
<view class="page-error" wx:if="{{pageState === 'error'}}">
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
<text class="error-text">加载失败,请点击重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
<text>重试</text>
</view>
</view>
<!-- 主体内容 -->
<block wx:if="{{pageState !== 'error'}}">
<!-- Banner 区域(对齐 performance 页面) -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
<view class="banner-content">
<view class="user-info-section">
<view class="user-info-row">
<view class="avatar-wrap">
<image src="{{avatarUrl || '/assets/images/avatar-coach.png'}}" class="avatar-img" mode="aspectFill" />
</view>
<view class="user-detail">
<view class="user-name-row">
<text class="user-name">{{fmt.safe(coachName)}}</text>
<text class="user-role-tag">{{fmt.safe(coachRole)}}</text>
</view>
<view class="user-store-row">
<text class="user-store">{{fmt.safe(storeName)}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="prev" bindtap="switchMonth">
<t-icon name="chevron-left" size="32rpx" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canGoNext ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="next" bindtap="switchMonth">
<t-icon name="chevron-right" size="32rpx" />
</view>
</view>
<!-- 统计概览 -->
<view class="stats-overview">
<view class="stat-item">
<text class="stat-label">总记录</text>
<text class="stat-value">{{totalCountLabel}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">总业绩时长</text>
<text class="stat-value stat-primary">{{totalHoursLabel}}</text>
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
<text class="stat-hours-raw" wx:if="{{totalHoursRawLabel}}">折前 {{totalHoursRawLabel}}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">{{isCurrentMonth ? '预估收入' : '收入'}}</text>
<text class="stat-value stat-success">{{totalIncomeLabel}}</text>
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
<text class="empty-text">暂无数据</text>
</view>
<!-- 记录列表(复用 performance 页面服务记录明细样式) -->
<view class="records-container" wx:elif="{{pageState === 'normal' || pageState === 'loading'}}">
<view class="records-card">
<block wx:for="{{dateGroups}}" wx:key="date">
<!-- 日期分隔线 -->
<view class="date-divider">
<text decode class="dd-date">{{fmt.safe(item.date)}}&nbsp;&nbsp;—</text>
<text decode class="dd-stats" wx:if="{{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时&nbsp;·&nbsp;{{isCurrentMonth ? '预估 ' : ''}}¥{{fmt.safe(item.totalIncome)}}&nbsp;&nbsp;</text>
<view class="dd-line"></view>
</view>
<!-- 该日期下的记录(与 performance 页面卡片一致) -->
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName"
hover-class="record-item--hover" bindtap="onRecordTap"
data-member-id="{{rec.memberId}}">
<view class="record-avatar avatar-{{rec.avatarColor}}">
<text>{{rec.avatarChar}}</text>
</view>
<view class="record-content">
<view class="record-top">
<view class="record-name-time">
<text class="record-name {{rec.isScattered ? 'record-name--scattered' : ''}}">{{fmt.safe(rec.customerName)}}</text>
<heart-icon score="{{rec.heartScore}}" size="small" />
<text class="record-time">{{fmt.safe(rec.timeRange)}}</text>
</view>
<text class="record-hours">{{fmt.safe(rec.hours)}}小时</text>
</view>
<view class="record-bottom">
<view class="record-tags">
<text class="course-tag course-tag--{{rec.courseTagClass}}">{{fmt.safe(rec.courseType)}}</text>
<text class="record-location">{{fmt.safe(rec.location)}}</text>
</view>
<text class="record-income">{{isCurrentMonth ? '助教预估收入' : '助教收入'}} <text class="record-income-val">¥{{fmt.safe(rec.income)}}</text></text>
</view>
</view>
</view>
</block>
<!-- 列表底部提示 -->
<view class="list-end-hint" wx:if="{{!hasMore && dateGroups.length > 0}}">
<text>— 已加载全部记录 —</text>
</view>
</view>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<dev-fab />

View File

@@ -0,0 +1,400 @@
/* pages/performance-records/performance-records.wxss */
/* CHANGE 2026-03-27 | 联调改造Banner 对齐 performance卡片样式复用 performance 服务记录明细 */
page {
background-color: #f3f3f3;
line-height: 1.5;
}
view {
line-height: inherit;
}
/* ============================================
* 加载态 / 空态 / 错误态
* ============================================ */
.page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-text {
font-size: 26rpx;
color: #a6a6a6;
}
.page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 24rpx;
}
.error-text {
font-size: 28rpx;
color: #a6a6a6;
}
.retry-btn {
padding: 16rpx 48rpx;
background: #0052d9;
color: #ffffff;
border-radius: 16rpx;
font-size: 28rpx;
}
.retry-btn--hover {
opacity: 0.7;
}
/* ============================================
* Banner对齐 performance 页面)
* ============================================ */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
}
.banner-bg-img {
position: absolute;
top: -50rpx;
left: 0;
width: 100%;
height: auto;
z-index: 0;
}
.banner-content {
position: relative;
z-index: 2;
padding: 40rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.user-info-section {
position: relative;
z-index: 2;
}
.user-info-row {
display: flex;
align-items: center;
gap: 29rpx;
}
.avatar-wrap {
width: 98rpx;
height: 98rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
overflow: hidden;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.user-detail {
flex: 1;
}
.user-name-row {
display: flex;
align-items: center;
gap: 15rpx;
margin-bottom: 7rpx;
line-height: 51rpx;
}
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.user-role-tag {
font-size: 22rpx;
line-height: 29rpx;
padding: 4rpx 15rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 9999rpx;
color: #ffffff;
}
.user-store-row {
line-height: 36rpx;
}
.user-store {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
/* ============================================
* 月份切换
* ============================================ */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn-disabled {
opacity: 0.3;
pointer-events: none;
}
.month-btn--hover {
opacity: 0.6;
}
.month-label {
font-size: 28rpx;
font-weight: 600;
color: #242424;
}
/* ============================================
* 统计概览
* ============================================ */
.stats-overview {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24rpx 32rpx;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
}
.stat-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 20rpx;
color: #a6a6a6;
margin-bottom: 4rpx;
}
.stat-value {
font-size: 36rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
}
.stat-primary { color: #0052d9; }
.stat-success { color: #00a870; }
.stat-hours-raw {
font-size: 20rpx;
color: #a6a6a6;
margin-top: 2rpx;
line-height: 26rpx;
}
.stat-hint {
font-size: 20rpx;
color: #ed7b2f;
margin-top: 2rpx;
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: #eeeeee;
margin-top: 4rpx;
}
/* ============================================
* 记录列表(复用 performance 页面服务记录明细样式)
* ============================================ */
.records-container {
padding: 24rpx;
padding-bottom: 40rpx;
}
.records-card {
background: #ffffff;
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
overflow: hidden;
padding: 0 32rpx;
}
.date-divider {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 0 8rpx;
}
.dd-date {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 500;
white-space: nowrap;
line-height: 29rpx;
}
.dd-line {
flex: 1;
height: 2rpx;
background: #dcdcdc;
}
.dd-stats {
font-size: 22rpx;
color: #a6a6a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 29rpx;
}
.record-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
}
.record-item--hover {
opacity: 0.7;
}
/* 头像(渐变色由 app.wxss 全局 .avatar-{key} 提供) */
.record-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30rpx;
font-weight: 500;
flex-shrink: 0;
}
.record-content {
flex: 1;
min-width: 0;
}
.record-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.record-name-time {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.record-name {
font-size: 26rpx;
font-weight: 500;
color: #242424;
flex-shrink: 0;
line-height: 36rpx;
}
/* 散客名称置灰 */
.record-name--scattered {
color: #999;
font-weight: 400;
}
.record-time {
font-size: 22rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.record-hours {
font-size: 26rpx;
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
line-height: 36rpx;
}
.record-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
}
.record-tags {
display: flex;
align-items: center;
gap: 12rpx;
}
.course-tag {
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
line-height: 29rpx;
}
.course-tag--basic { background: #ecfdf5; color: #15803d; }
.course-tag--room { background: #eff6ff; color: #1d4ed8; }
.course-tag--incentive { background: #fffbeb; color: #a16207; }
.record-location {
font-size: 22rpx;
color: #8b8b8b;
line-height: 29rpx;
}
.record-income {
font-size: 22rpx;
color: #c5c5c5;
flex-shrink: 0;
line-height: 29rpx;
}
.record-income-val {
font-weight: 500;
color: #5e5e5e;
}
/* 列表底部提示 */
.list-end-hint {
text-align: center;
padding: 24rpx 0 28rpx;
font-size: 22rpx;
color: #c5c5c5;
}

View File

@@ -27,6 +27,7 @@ interface ConsumptionRecord {
}>
foodAmount?: number
foodOrigPrice?: number
foodDetail?: string
totalAmount?: number
totalOrigPrice?: number
payMethod?: string
@@ -46,6 +47,7 @@ Page({
name: '',
avatarChar: '',
phone: '',
phoneFull: '',
balance: null as number | null,
consumption60d: null as number | null,
idealInterval: null as number | null,
@@ -109,6 +111,7 @@ Page({
name: d.name || '',
avatarChar: (d.name || '')[0] || '',
phone: d.phone || '',
phoneFull: d.phoneFull || '',
balance: d.balance ?? null,
// CHANGE 2026-03-29 | Pydantic 把 consumption_60d → consumption60D大写 D
consumption60d: d.consumption60D ?? d.consumption60d ?? null,
@@ -147,9 +150,9 @@ Page({
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号 */
/** 复制手机号(复制完整号码) */
onCopyPhone() {
const phone = this.data.detail.phone
const phone = this.data.detail.phoneFull || this.data.detail.phone
wx.setClipboardData({
data: phone,
success: () => {

View File

@@ -27,7 +27,7 @@
<text class="customer-name">{{detail.name}}</text>
</view>
<view class="sub-info">
<text class="phone">{{phoneVisible ? detail.phone : '138****5678'}}</text>
<text class="phone">{{phoneVisible ? detail.phoneFull : detail.phone}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
@@ -221,7 +221,7 @@
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<view class="record-food-right">
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
@@ -261,7 +261,7 @@
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
@@ -292,7 +292,7 @@
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
<view class="note-top">
<text class="note-author">{{fmt.safe(item.tagLabel)}}</text>
<text class="note-author">{{item.creatorName || item.tagLabel}}{{item.creatorRole ? ' · ' + item.creatorRole : ''}}</text>
<view class="note-top-right">
<text class="note-time">{{fmt.safe(item.createdAt)}}</text>
<view class="note-delete-btn" data-id="{{item.id}}" bindtap="onDeleteNote" hover-class="note-delete-btn--hover">

View File

@@ -857,11 +857,20 @@ view {
justify-content: space-between;
padding: 16rpx 24rpx;
border-top: 2rpx solid var(--border-light);
gap: 16rpx;
}
.record-food-label {
font-size: 24rpx;
color: var(--text-secondary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
word-break: break-all;
}
.record-food-right {

View File

@@ -131,7 +131,7 @@
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<view class="record-food-right">
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
@@ -156,7 +156,7 @@
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">

View File

@@ -288,8 +288,15 @@ page {
justify-content: space-between;
padding: 16rpx 24rpx;
border-top: 2rpx solid var(--border-light, #f0f0f0);
gap: 16rpx;
}
.record-food-label {
font-size: 24rpx;
color: var(--text-secondary, #666);
flex: 1;
min-width: 0;
word-break: break-all;
}
.record-food-label { font-size: 24rpx; color: var(--text-secondary, #666); }
.record-food-right { display: flex; align-items: baseline; gap: 8rpx; }
.record-food-amount {
font-size: 28rpx;

View File

@@ -3,6 +3,7 @@
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-27 | 联调改造 | 重写Banner 对齐 performance 页面,数据对接后端 PERF-2卡片样式复用 performance 服务记录明细 |
| 2026-04-20 | 拆分助教视角 | 删除 coachId 分支,本页恢复"任务 tab 助教自查"单一职责;管理者视角迁至 pages/coach-service-records/ |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchPerformanceRecords, fetchMe } from '../../services/api'

View File

@@ -92,7 +92,7 @@
<view class="record-content">
<view class="record-top">
<view class="record-name-time">
<text class="record-name">{{fmt.safe(rec.customerName)}}</text>
<text class="record-name {{rec.isScattered ? 'record-name--scattered' : ''}}">{{fmt.safe(rec.customerName)}}</text>
<heart-icon score="{{rec.heartScore}}" size="small" />
<text class="record-time">{{fmt.safe(rec.timeRange)}}</text>
</view>

View File

@@ -138,8 +138,8 @@ view {
}
.user-store {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.75);
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
/* ============================================
@@ -327,6 +327,12 @@ view {
line-height: 36rpx;
}
/* 散客名称置灰 */
.record-name--scattered {
color: #999;
font-weight: 400;
}
.record-time {
font-size: 22rpx;
color: #a6a6a6;

View File

@@ -22,62 +22,15 @@ import { formatDeadline } from '../../utils/time'
import { formatStorageLevel } from '../../utils/storage-level'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
import {
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
calcShineDur, buildTicks, buildProgressBarData,
type TickItem,
} from '../../utils/perf-progress'
/** CHANGE 2026-03-14 | 所有任务类型统一跳转到 task-detail由详情页根据 taskId 动态展示内容 */
const DETAIL_ROUTE = '/pages/task-detail/task-detail'
/* ╔══════════════════════════════════════════════════════╗
* ║ 进度条动画参数 — 在此调节 ║
* ╚══════════════════════════════════════════════════════╝
*
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
*
* ┌─────────────┐ SPARK_DELAY_MS ┌─────────────┐ NEXT_LOOP_DELAY_MS ┌─────────────┐
* │ 高光匀速扫过 │ ───────────────▶ │ 火花迸发 │ ──────────────────▶ │ 下一轮 │
* │ 时长由速度决定│ │ SPARK_DUR_MS│ │(重新读进度) │
* └─────────────┘ └─────────────┘ └─────────────┘
*
* SHINE_SPEED : 高光移动速度,范围 1~100
* 1 = 最慢最宽进度条100%)下 5 秒走完
* 100 = 最快最宽进度条100%)下 0.05 秒走完
* 实际时长 = 基准时长 × (filledPct/100)
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) × (5-0.05)s
*
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
* 正数 = 高光结束后停顿再点亮
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
*
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
*
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
* 正数 = 停顿一段时间
* 负数 = 火花还未消散完,高光已从左端启动
*/
const SHINE_SPEED = 70 // 1~100速度值
const SPARK_DELAY_MS = -200 // 毫秒,高光结束→光柱点亮+火花(负=提前)
const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束→下轮高光(负=提前)
/* 根据速度值和进度百分比计算高光时长
* 高光宽度固定SHINE_WIDTH_RPX需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 634rpx750 - 左右padding各58rpx高光宽度约占轨道 19%
* 时长正比于需要走过的总距离,保证视觉速度恒定
*
* 速度1 → baseDur=5000ms最慢速度100 → baseDur=50ms最快
* shineDurMs = baseDur × (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
const SHINE_WIDTH_RPX = 120 // rpx需与 WXSS 的 --shine-width 保持一致
const TRACK_WIDTH_RPX = 634 // rpx进度条轨道宽度750 - padding 116rpx
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ≈19%
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
const baseDur = 5000 - t * (5000 - 50) // ms走完100%进度条所需时长
// 实际距离 = 填充条 + 高光自身,相对于(100% + 高光宽度%)归一化
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
/** 扩展任务字段 */
interface EnrichedTask extends Task {
lastVisitDays: number
@@ -94,24 +47,6 @@ interface EnrichedTask extends Task {
recent60dIncome: number
}
/** 刻度项 */
interface TickItem {
value: number // 刻度数值(如 100
label: string // 显示文字(如 '100'
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
highlight: boolean // 是否加粗高亮
}
/** Mock: 根据档位节点数组生成刻度数据 */
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2, // 第3个档位如130h高亮可由接口控制
}))
}
/** P0: 业绩进度卡片数据 */
interface PerfData {
nextTierHours: number
@@ -414,7 +349,6 @@ Page({
const totalHours = perf.totalHours ?? perf.total_hours ?? 0
const basicHours = perf.basicHours ?? perf.basic_hours ?? 0
const bonusHours = perf.bonusHours ?? perf.bonus_hours ?? 0
const currentTier = perf.currentTier ?? perf.current_tier ?? 0
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const tierCompleted = perf.tierCompleted ?? perf.tier_completed ?? false
const bonusMoney = perf.bonusMoney ?? perf.bonus_money ?? 0
@@ -423,19 +357,16 @@ Page({
const totalIncome = perf.totalIncome ?? perf.total_income ?? 0
const incomeTrend = perf.incomeTrend ?? perf.income_trend ?? ''
const incomeTrendDir = perf.incomeTrendDir ?? perf.income_trend_dir ?? 'up'
const tierNodes: number[] = perf.tierNodes ?? perf.tier_nodes ?? [0, 100, 130, 160, 190, 220]
// 计算进度条百分比(基于最大档位)
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 220
const filledPct = maxHours > 0 ? Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10) : 0
const remainHours = Math.max(0, nextTierHours - totalHours)
// 统一使用共用模块计算进度条数据
const pbData = buildProgressBarData(perf)
perfData.totalHours = totalHours
perfData.basicHours = basicHours
perfData.bonusHours = bonusHours
perfData.currentTier = currentTier
perfData.currentTier = pbData.currentTier
perfData.nextTierHours = nextTierHours
perfData.remainHours = remainHours
perfData.remainHours = pbData.remainHours
perfData.tierCompleted = tierCompleted
perfData.bonusMoney = bonusMoney
perfData.incomeMonth = monthLabel
@@ -443,21 +374,15 @@ Page({
perfData.incomeFormatted = formatMoney(totalIncome)
perfData.incomeTrend = incomeTrend
perfData.incomeTrendDir = incomeTrendDir === 'down' ? 'down' : 'up'
// 从 "7373" / "368" 中提取纯数字,用于千分位格式化
// 从 "^7373" / "v368" 中提取纯数字,用于千分位格式化
const trendNumMatch = incomeTrend.replace(/[^0-9.]/g, '')
perfData.incomeTrendValue = trendNumMatch ? parseFloat(trendNumMatch) : null
perfData.filledPct = filledPct
perfData.clampedSparkPct = Math.max(0, Math.min(100, filledPct))
perfData.ticks = buildTicks(tierNodes, maxHours)
perfData.shineDurMs = calcShineDur(filledPct)
perfData.sparkDurMs = SPARK_DUR_MS
// 计算段内进度
const segStart = tierNodes[currentTier] ?? 0
const segEnd = tierNodes[currentTier + 1] ?? maxHours
perfData.tierProgress = segEnd > segStart
? Math.min(100, Math.round(((totalHours - segStart) / (segEnd - segStart)) * 100))
: 100
perfData.filledPct = pbData.filledPct
perfData.clampedSparkPct = pbData.clampedSparkPct
perfData.ticks = pbData.ticks
perfData.shineDurMs = pbData.shineDurMs
perfData.sparkDurMs = pbData.sparkDurMs
perfData.tierProgress = pbData.tierProgress
}
// G2: 当月预估判断

View File

@@ -169,7 +169,7 @@
<text class="expected-tag expected-tag--overdue" wx:if="{{item.expectedDays != null && item.expectedDays < 0 && item.taskType !== 'relationship_building' && item.taskType !== 'follow_up_visit'}}">逾期{{-item.expectedDays}}天</text>
</view>
<view class="card-row-2">
<text class="visit-text">到店:{{fmt.days(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
<text class="visit-text">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
@@ -213,7 +213,7 @@
<text class="expected-tag expected-tag--overdue" wx:if="{{item.expectedDays != null && item.expectedDays < 0 && item.taskType !== 'relationship_building' && item.taskType !== 'follow_up_visit'}}">逾期{{-item.expectedDays}}天</text>
</view>
<view class="card-row-2">
<text class="visit-text">到店:{{fmt.days(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
<text class="visit-text">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
@@ -255,7 +255,7 @@
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<view class="card-row-2">
<text class="visit-text visit-text--abandoned">到店:{{fmt.days(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
<text class="visit-text visit-text--abandoned">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
</view>
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
<text class="abandon-reason">放弃原因:{{fmt.safe(item.abandonReason)}}</text>

View File

@@ -179,6 +179,8 @@ export async function fetchPerformanceRecords(params: {
month: number
page?: number
pageSize?: number
/** 目标助教 ID从 coach-detail 跳入时传入,要求调用者具备 view_board_coach 权限 */
coachId?: number
}): Promise<{
summary: { totalCount: number; totalHours: number; totalHoursRaw: number; totalIncome: number }
dateGroups: Array<{
@@ -198,10 +200,20 @@ export async function fetchPerformanceRecords(params: {
}>
hasMore: boolean
}> {
// 后端 FastAPI Query 参数为 snake_case前端 camelCase 需手动映射
const query: Record<string, any> = {
year: params.year,
month: params.month,
page: params.page ?? 1,
page_size: params.pageSize ?? 20,
}
if (params.coachId !== undefined && params.coachId !== null) {
query.coach_id = params.coachId
}
return request({
url: '/api/xcx/performance/records',
method: 'GET',
data: params,
data: query,
needAuth: true,
})
}
@@ -317,6 +329,20 @@ export async function fetchBoardFinance(params: {
// 助教模块
// ============================================
/** 助教 banner 轻量信息(仅 name/level/storeName— 比 fetchCoachDetail 快一个数量级 */
export async function fetchCoachBanner(coachId: string): Promise<{
id: number
name: string
level: string
storeName: string
} | null> {
return request({
url: `/api/xcx/coaches/${coachId}/banner`,
method: 'GET',
needAuth: true,
})
}
/** 助教详情 */
export async function fetchCoachDetail(coachId: string): Promise<CoachCard | null> {
return request({

View File

@@ -155,6 +155,16 @@ function days(value) {
return value + '天'
}
/**
* 距今天数格式化WXS 版)
* "今天" / "3天前" / "--"
*/
function daysAgo(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '今天'
return value + '天前'
}
/**
* 储值等级格式化WXS 版)
* 无/少/一般/多/非常多
@@ -219,6 +229,7 @@ module.exports = {
thousands: thousands,
trendValue: trendValue,
days: days,
daysAgo: daysAgo,
storageLevel: storageLevel,
maskPhone: maskPhone,
negativeMoney: negativeMoney,

View File

@@ -0,0 +1,124 @@
/**
* 绩效进度条共用模块 -- task-list / coach-detail 统一使用
*
* 包含:动画参数、刻度计算、高光时长计算、类型定义
*/
/* ======================================================
* 进度条动画参数 -- 在此调节
* ======================================================
*
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
*
* +-------------+ SPARK_DELAY_MS +-------------+ NEXT_LOOP_DELAY_MS +-------------+
* | 高光匀速扫过 | --------------> | 火花迸发 | ------------------> | 下一轮 |
* | 时长由速度决定| | SPARK_DUR_MS| |(重新读进度) |
* +-------------+ +-------------+ +-------------+
*
* SHINE_SPEED : 高光移动速度,范围 1~100
* 1 = 最慢,最宽进度条(100%)下 5 秒走完
* 100 = 最快,最宽进度条(100%)下 0.05 秒走完
* 实际时长 = 基准时长 x (filledPct/100)
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) x (5-0.05)s
*
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
* 正数 = 高光结束后停顿再点亮
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
*
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
*
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
* 正数 = 停顿一段时间
* 负数 = 火花还未消散完,高光已从左端启动
*
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
*/
export const SHINE_SPEED = 70 // 1~100速度值
export const SPARK_DELAY_MS = -200 // 毫秒,高光结束->光柱点亮+火花(负=提前)
export const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
export const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束->下轮高光(负=提前)
/* 高光宽度固定(SHINE_WIDTH_RPX),需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 634rpx(750 - 左右padding各58rpx),高光宽度约占轨道 19%
* 时长正比于需要走过的总距离,保证视觉速度恒定
*
* 速度1 -> baseDur=5000ms(最慢)速度100 -> baseDur=50ms(最快)
* shineDurMs = baseDur x (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
export const SHINE_WIDTH_RPX = 120 // rpx需与 WXSS 的 --shine-width 保持一致
export const TRACK_WIDTH_RPX = 634 // rpx进度条轨道宽度(750 - padding 116rpx)
export const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ~19%
// ── 类型定义 ────────────────────────────────────────────────
/** 刻度项(传给 perf-progress-bar 组件的 ticks 属性) */
export interface TickItem {
value: number // 刻度数值(如 100
label: string // 显示文字(如 '100'
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
highlight: boolean // 是否加粗高亮
}
// ── 工具函数 ────────────────────────────────────────────────
/**
* 根据速度值和进度百分比计算高光扫过时长。
*
* 速度 1 -> baseDur=5000ms最慢速度 100 -> baseDur=50ms最快
* 实际时长 = baseDur x (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
*/
export function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
const baseDur = 5000 - t * (5000 - 50)
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
/**
* 根据档位节点数组生成刻度数据(供 perf-progress-bar 组件渲染)。
*/
export function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: false,
}))
}
/**
* 从后端返回的 performance 对象中计算进度条所需的全部数据。
*
* 两个页面统一调用此函数,确保进度百分比、当前档位、刻度等计算逻辑一致。
*/
export function buildProgressBarData(perf: Record<string, any>) {
// 后端返回 snake_caseCamelModel 可能已转 camelCase兼容两种命名
const totalHours = perf.totalHours ?? perf.total_hours ?? 0
const tierNodes: number[] = perf.tierNodes ?? perf.tier_nodes ?? [0]
const currentTier = perf.currentTier ?? perf.current_tier ?? 0
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
const filledPct = maxHours > 0 ? Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10) : 0
const clampedSparkPct = Math.max(0, Math.min(100, filledPct))
const remainHours = Math.max(0, nextTierHours - totalHours)
const ticks = buildTicks(tierNodes, maxHours)
// 段内进度(当前档位内的百分比)
const segStart = tierNodes[currentTier] ?? 0
const segEnd = tierNodes[currentTier + 1] ?? maxHours
const tierProgress = segEnd > segStart
? Math.min(100, Math.round(((totalHours - segStart) / (segEnd - segStart)) * 100))
: 100
return {
filledPct,
clampedSparkPct,
currentTier,
ticks,
remainHours,
tierProgress,
shineDurMs: calcShineDur(filledPct),
sparkDurMs: SPARK_DUR_MS,
}
}

View File

@@ -53,12 +53,12 @@
"packOptions": {
"ignore": [
{
"type": "glob",
"value": "miniprogram/pages/task-detail-callback/**"
"value": "miniprogram/pages/task-detail-callback/**",
"type": "glob"
},
{
"type": "glob",
"value": "miniprogram/pages/task-detail-relationship/**"
"value": "miniprogram/pages/task-detail-relationship/**",
"type": "glob"
}
],
"include": []

View File

@@ -35,7 +35,7 @@
}
},
"mini-ios": {
"sdkVersion": "1.6.28",
"sdkVersion": "1.7.0",
"toolkitVersion": "0.0.9",
"useExtendedSdk": {
"WeAppOpenFuns": true,
@@ -65,4 +65,4 @@
},
"enableOpenUrlNavigate": true
}
}
}

View File

@@ -1,6 +1,6 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "NeoZQYY",
"projectname": "miniprogram",
"setting": {
"compileHotReLoad": true,
"urlCheck": false,

View File

@@ -41,7 +41,7 @@ db/
| `etl_feiqiu/schemas/meta.sql` | etl_feiqiu | meta | 3 表 |
| `etl_feiqiu/schemas/ods.sql` | etl_feiqiu | ods | 23 表 |
| `etl_feiqiu/schemas/dwd.sql` | etl_feiqiu | dwd | 42 表 |
| `etl_feiqiu/schemas/core.sql` | etl_feiqiu | core | 7 表 |
| `etl_feiqiu/schemas/core.sql` | etl_feiqiu | core | 7 表(跨平台统一维度层,由 CORE_DIM_SYNC 任务从 DWD 同步) |
| `etl_feiqiu/schemas/dws.sql` | etl_feiqiu | dws | 38 表 |
| `etl_feiqiu/schemas/app.sql` | etl_feiqiu | app | 仅视图 |
| `zqyy_app/schemas/public.sql` | zqyy_app | public | 12 表 |

View File

@@ -0,0 +1,213 @@
-- 迁移:为 3 张指数表添加 stat_date 列,改为日快照模式
-- 目的:支持历史指数回溯和任务推演
-- 回滚:见文件末尾
BEGIN;
-- ============================================================
-- 1a. dws.dws_member_assistant_relation_index
-- ============================================================
ALTER TABLE dws.dws_member_assistant_relation_index
ADD COLUMN stat_date DATE;
UPDATE dws.dws_member_assistant_relation_index
SET stat_date = (calc_time AT TIME ZONE 'Asia/Shanghai')::date;
ALTER TABLE dws.dws_member_assistant_relation_index
ALTER COLUMN stat_date SET NOT NULL,
ALTER COLUMN stat_date SET DEFAULT CURRENT_DATE;
-- 改唯一约束:(site_id, member_id, assistant_id) → (site_id, member_id, assistant_id, stat_date)
ALTER TABLE dws.dws_member_assistant_relation_index
DROP CONSTRAINT uk_dws_member_assistant_relation_index;
ALTER TABLE dws.dws_member_assistant_relation_index
ADD CONSTRAINT uk_dws_member_assistant_relation_index
UNIQUE (site_id, member_id, assistant_id, stat_date);
-- 索引优化:去掉 calc_time 索引,加 stat_date 索引
DROP INDEX IF EXISTS dws.idx_dws_relation_calc_time;
CREATE INDEX idx_dws_relation_stat_date
ON dws.dws_member_assistant_relation_index (site_id, stat_date);
-- ============================================================
-- 1b. dws.dws_member_winback_index
-- ============================================================
ALTER TABLE dws.dws_member_winback_index
ADD COLUMN stat_date DATE;
UPDATE dws.dws_member_winback_index
SET stat_date = (calc_time AT TIME ZONE 'Asia/Shanghai')::date;
ALTER TABLE dws.dws_member_winback_index
ALTER COLUMN stat_date SET NOT NULL,
ALTER COLUMN stat_date SET DEFAULT CURRENT_DATE;
-- 改唯一约束:(site_id, member_id) → (site_id, member_id, stat_date)
ALTER TABLE dws.dws_member_winback_index
DROP CONSTRAINT uk_dws_member_winback;
ALTER TABLE dws.dws_member_winback_index
ADD CONSTRAINT uk_dws_member_winback
UNIQUE (site_id, member_id, stat_date);
CREATE INDEX idx_dws_winback_stat_date
ON dws.dws_member_winback_index (site_id, stat_date);
-- ============================================================
-- 1c. dws.dws_member_newconv_index
-- ============================================================
ALTER TABLE dws.dws_member_newconv_index
ADD COLUMN stat_date DATE;
UPDATE dws.dws_member_newconv_index
SET stat_date = (calc_time AT TIME ZONE 'Asia/Shanghai')::date;
ALTER TABLE dws.dws_member_newconv_index
ALTER COLUMN stat_date SET NOT NULL,
ALTER COLUMN stat_date SET DEFAULT CURRENT_DATE;
-- 改唯一约束:(site_id, member_id) → (site_id, member_id, stat_date)
ALTER TABLE dws.dws_member_newconv_index
DROP CONSTRAINT uk_dws_member_newconv;
ALTER TABLE dws.dws_member_newconv_index
ADD CONSTRAINT uk_dws_member_newconv
UNIQUE (site_id, member_id, stat_date);
CREATE INDEX idx_dws_newconv_stat_date
ON dws.dws_member_newconv_index (site_id, stat_date);
-- ============================================================
-- 2. 视图改造DISTINCT ON 取最新快照,下游零改动)
-- ============================================================
-- 2a. app.v_dws_member_assistant_relation_index
CREATE OR REPLACE VIEW app.v_dws_member_assistant_relation_index AS
SELECT DISTINCT ON (member_id, assistant_id)
relation_id, site_id, tenant_id, member_id, assistant_id,
session_count, total_duration_minutes, basic_session_count, incentive_session_count,
days_since_last_session,
rs_f, rs_d, rs_r, rs_raw, rs_display,
os_share, os_label, os_rank,
ms_f_short, ms_f_long, ms_raw, ms_display,
ml_order_count, ml_allocated_amount, ml_raw, ml_display,
calc_time, created_at, updated_at,
recall_created_total, recall_completed_total,
follow_up_created_total, follow_up_completed_total,
total_created, total_completed,
stat_date
FROM dws.dws_member_assistant_relation_index
WHERE site_id = current_setting('app.current_site_id')::bigint
ORDER BY member_id, assistant_id, stat_date DESC;
-- 2b. app.v_dws_member_winback_index
CREATE OR REPLACE VIEW app.v_dws_member_winback_index AS
SELECT DISTINCT ON (member_id)
winback_id, site_id, tenant_id, member_id,
status, segment,
member_create_time, first_visit_time, last_visit_time, last_recharge_time,
t_v, t_r, t_a,
visits_14d, visits_30d, visits_60d, visits_total,
spend_30d, spend_180d, sv_balance, recharge_60d_amt,
interval_count,
overdue_old, drop_old, recharge_old, value_old,
raw_score, display_score,
last_wechat_touch_time, calc_time, calc_version,
created_at, updated_at,
overdue_cdf_p, ideal_interval_days, ideal_next_visit_date,
stat_date
FROM dws.dws_member_winback_index
WHERE site_id = current_setting('app.current_site_id')::bigint
ORDER BY member_id, stat_date DESC;
-- 2c. app.v_dws_member_newconv_index
CREATE OR REPLACE VIEW app.v_dws_member_newconv_index AS
SELECT DISTINCT ON (member_id)
newconv_id, site_id, tenant_id, member_id,
status, segment,
member_create_time, first_visit_time, last_visit_time, last_recharge_time,
t_v, t_r, t_a,
visits_14d, visits_30d, visits_60d, visits_total,
spend_30d, spend_180d, sv_balance, recharge_60d_amt,
interval_count,
need_new, salvage_new, recharge_new, value_new, welcome_new,
raw_score_welcome, raw_score_convert, raw_score,
display_score_welcome, display_score_convert, display_score,
last_wechat_touch_time, calc_time, calc_version,
created_at, updated_at,
stat_date
FROM dws.dws_member_newconv_index
WHERE site_id = current_setting('app.current_site_id')::bigint
ORDER BY member_id, stat_date DESC;
-- 2d. dws.v_member_recall_priorityUNION ALL 两支先取最新快照)
CREATE OR REPLACE VIEW dws.v_member_recall_priority AS
SELECT w.site_id, w.tenant_id, w.member_id,
'WBI'::varchar(10) AS index_type,
w.status, w.segment,
w.member_create_time, w.first_visit_time, w.last_visit_time, w.last_recharge_time,
w.t_v, w.t_r, w.t_a,
w.visits_14d, w.visits_30d, w.visits_60d, w.visits_total,
w.spend_30d, w.spend_180d, w.sv_balance, w.recharge_60d_amt,
NULL::numeric(10,4) AS need_new,
NULL::numeric(10,4) AS salvage_new,
NULL::numeric(10,4) AS recharge_new,
NULL::numeric(10,4) AS value_new,
NULL::numeric(10,4) AS welcome_new,
NULL::numeric(14,6) AS raw_score_welcome,
NULL::numeric(14,6) AS raw_score_convert,
w.raw_score,
NULL::numeric(4,2) AS display_score_welcome,
NULL::numeric(4,2) AS display_score_convert,
w.display_score,
w.last_wechat_touch_time,
w.calc_time
FROM (
SELECT DISTINCT ON (site_id, member_id) *
FROM dws.dws_member_winback_index
ORDER BY site_id, member_id, stat_date DESC
) w
UNION ALL
SELECT n.site_id, n.tenant_id, n.member_id,
'NCI'::varchar(10) AS index_type,
n.status, n.segment,
n.member_create_time, n.first_visit_time, n.last_visit_time, n.last_recharge_time,
n.t_v, n.t_r, n.t_a,
n.visits_14d, n.visits_30d, n.visits_60d, n.visits_total,
n.spend_30d, n.spend_180d, n.sv_balance, n.recharge_60d_amt,
n.need_new, n.salvage_new, n.recharge_new, n.value_new, n.welcome_new,
n.raw_score_welcome, n.raw_score_convert, n.raw_score,
n.display_score_welcome, n.display_score_convert, n.display_score,
n.last_wechat_touch_time,
n.calc_time
FROM (
SELECT DISTINCT ON (site_id, member_id) *
FROM dws.dws_member_newconv_index
ORDER BY site_id, member_id, stat_date DESC
) n;
COMMIT;
-- ============================================================
-- 回滚脚本(按需手动执行,不在 BEGIN/COMMIT 内)
-- ============================================================
-- 删除历史快照只保留最新
-- DELETE FROM dws.dws_member_assistant_relation_index WHERE stat_date != (SELECT MAX(stat_date) FROM dws.dws_member_assistant_relation_index);
-- DELETE FROM dws.dws_member_winback_index WHERE stat_date != (SELECT MAX(stat_date) FROM dws.dws_member_winback_index);
-- DELETE FROM dws.dws_member_newconv_index WHERE stat_date != (SELECT MAX(stat_date) FROM dws.dws_member_newconv_index);
-- 恢复原唯一约束
-- ALTER TABLE dws.dws_member_assistant_relation_index DROP CONSTRAINT uk_dws_member_assistant_relation_index;
-- ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id);
-- ALTER TABLE dws.dws_member_winback_index DROP CONSTRAINT uk_dws_member_winback;
-- ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id);
-- ALTER TABLE dws.dws_member_newconv_index DROP CONSTRAINT uk_dws_member_newconv;
-- ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id);
-- 删除 stat_date 列
-- ALTER TABLE dws.dws_member_assistant_relation_index DROP COLUMN stat_date;
-- ALTER TABLE dws.dws_member_winback_index DROP COLUMN stat_date;
-- ALTER TABLE dws.dws_member_newconv_index DROP COLUMN stat_date;
-- 恢复原视图(使用 gen_consolidated_ddl.py 重新生成)

View File

@@ -0,0 +1,23 @@
-- 迁移:客户项目标签改为按消费次数取数
-- 日期2026-04-11
-- 说明:
-- 1. cfg_area_category 补充美洲豹赛台 → BILLIARD
-- 2. 清理旧时间窗口数据LAST_30_DAYS / LAST_60_DAYS
-- 下次 ETL 跑完会写入新窗口 LAST_30_VISITS
BEGIN;
-- 1. 补充美洲豹赛台映射(幂等:冲突则跳过)
INSERT INTO dws.cfg_area_category
(source_area_name, source_table_name, category_code, category_name,
display_name, short_name, match_type, match_priority, is_active, sort_order, description)
VALUES
('美洲豹赛台', '美洲豹赛台', 'BILLIARD', '🎱 中式/追分',
'中式/追分', '🎱', 'EXACT', 10, true, 100, '美洲豹品牌赛台,归入中式台球')
ON CONFLICT (source_area_name, COALESCE(source_table_name, '')) DO NOTHING;
-- 2. 清理旧时间窗口数据ETL 重跑后会生成新的 LAST_30_VISITS 数据)
DELETE FROM dws.dws_member_project_tag
WHERE time_window IN ('LAST_30_DAYS', 'LAST_60_DAYS');
COMMIT;

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / appRLS 视图层)
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -439,10 +439,8 @@ SELECT table_fee_log_id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
-- CHANGE 2026-04-08 | DISTINCT ON 只取每对 (assistant, member) 最新快照
CREATE OR REPLACE VIEW app.v_dws_assistant_customer_stats AS
SELECT DISTINCT ON (assistant_id, member_id)
id,
SELECT DISTINCT ON (assistant_id, member_id) id,
site_id,
tenant_id,
assistant_id,
@@ -946,7 +944,7 @@ SELECT intimacy_id,
;
CREATE OR REPLACE VIEW app.v_dws_member_assistant_relation_index AS
SELECT relation_id,
SELECT DISTINCT ON (member_id, assistant_id) relation_id,
site_id,
tenant_id,
member_id,
@@ -974,15 +972,21 @@ SELECT relation_id,
ml_display,
calc_time,
created_at,
updated_at
updated_at,
recall_created_total,
recall_completed_total,
follow_up_created_total,
follow_up_completed_total,
total_created,
total_completed,
stat_date
FROM dws.dws_member_assistant_relation_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, assistant_id, stat_date DESC;
;
-- CHANGE 2026-04-08 | DISTINCT ON 只取每个会员最新快照,避免多 stat_date 行膨胀
CREATE OR REPLACE VIEW app.v_dws_member_consumption_summary AS
SELECT DISTINCT ON (member_id)
id,
SELECT DISTINCT ON (member_id) id,
site_id,
tenant_id,
member_id,
@@ -1034,7 +1038,7 @@ SELECT DISTINCT ON (member_id)
;
CREATE OR REPLACE VIEW app.v_dws_member_newconv_index AS
SELECT newconv_id,
SELECT DISTINCT ON (member_id) newconv_id,
site_id,
tenant_id,
member_id,
@@ -1071,9 +1075,11 @@ SELECT newconv_id,
calc_time,
calc_version,
created_at,
updated_at
updated_at,
stat_date
FROM dws.dws_member_newconv_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, stat_date DESC;
;
CREATE OR REPLACE VIEW app.v_dws_member_project_tag AS
@@ -1160,7 +1166,7 @@ SELECT id,
;
CREATE OR REPLACE VIEW app.v_dws_member_winback_index AS
SELECT winback_id,
SELECT DISTINCT ON (member_id) winback_id,
site_id,
tenant_id,
member_id,
@@ -1195,9 +1201,11 @@ SELECT winback_id,
updated_at,
overdue_cdf_p,
ideal_interval_days,
ideal_next_visit_date
ideal_next_visit_date,
stat_date
FROM dws.dws_member_winback_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, stat_date DESC;
;
CREATE OR REPLACE VIEW app.v_dws_order_summary AS

View File

@@ -1,7 +1,11 @@
-- =============================================================================
-- etl_feiqiu / core门店标准化维度/事实)
-- etl_feiqiu / core平台统一维度/事实
-- 生成日期2026-04-06
-- 来源:测试库(通过脚本自动导出)
--
-- 定位:屏蔽 ODS/DWD 多数据源差异,输出标准化维度和事实。
-- DWS/app 层只依赖 core不直接查 DWD。
-- 当前数据源飞球dwd.*),后续可接入美团、抖音等。
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS core;

View File

@@ -695,7 +695,13 @@ CREATE TABLE dwd.dwd_groupbuy_redemption (
ledger_name character varying(128),
create_time timestamp with time zone,
member_discount_money numeric(18,2),
coupon_sale_id bigint
coupon_sale_id bigint,
mt_settlement_price numeric(14,2),
mt_gross_income numeric(14,2),
mt_service_fee numeric(14,2),
mt_marketing_fee numeric(14,2),
mt_other_adjust numeric(14,2),
mt_import_time timestamp with time zone
);
CREATE TABLE dwd.dwd_groupbuy_redemption_ex (

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / dws汇总数据层
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -722,7 +722,8 @@ CREATE TABLE dws.dws_member_assistant_relation_index (
follow_up_created_total integer DEFAULT 0 NOT NULL,
follow_up_completed_total integer DEFAULT 0 NOT NULL,
total_created integer DEFAULT 0 NOT NULL,
total_completed integer DEFAULT 0 NOT NULL
total_completed integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_member_consumption_summary (
@@ -789,7 +790,6 @@ CREATE TABLE dws.dws_member_newconv_index (
t_r numeric(6,2),
t_a numeric(6,2),
visits_14d integer DEFAULT 0 NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
visits_60d integer DEFAULT 0 NOT NULL,
visits_total integer DEFAULT 0 NOT NULL,
spend_30d numeric(14,2) DEFAULT 0 NOT NULL,
@@ -812,7 +812,9 @@ CREATE TABLE dws.dws_member_newconv_index (
calc_time timestamp with time zone DEFAULT now() NOT NULL,
calc_version integer DEFAULT 1 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
updated_at timestamp with time zone DEFAULT now() NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_member_project_tag (
@@ -908,7 +910,6 @@ CREATE TABLE dws.dws_member_winback_index (
t_r numeric(6,2),
t_a numeric(6,2),
visits_14d integer DEFAULT 0 NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
visits_60d integer DEFAULT 0 NOT NULL,
visits_total integer DEFAULT 0 NOT NULL,
spend_30d numeric(14,2) DEFAULT 0 NOT NULL,
@@ -929,7 +930,9 @@ CREATE TABLE dws.dws_member_winback_index (
updated_at timestamp with time zone DEFAULT now() NOT NULL,
overdue_cdf_p numeric(10,4),
ideal_interval_days numeric(10,2),
ideal_next_visit_date date
ideal_next_visit_date date,
visits_30d integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_ml_manual_order_alloc (
@@ -1093,18 +1096,18 @@ ALTER TABLE dws.dws_index_percentile_history ADD CONSTRAINT uk_dws_index_percent
ALTER TABLE dws.dws_member_assistant_intimacy ADD CONSTRAINT dws_member_assistant_intimacy_pkey PRIMARY KEY (intimacy_id);
ALTER TABLE dws.dws_member_assistant_intimacy ADD CONSTRAINT uk_dws_member_assistant_intimacy UNIQUE (site_id, member_id, assistant_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT dws_member_assistant_relation_index_pkey PRIMARY KEY (relation_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id, stat_date);
ALTER TABLE dws.dws_member_consumption_summary ADD CONSTRAINT dws_member_consumption_summary_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_consumption_summary ADD CONSTRAINT uk_dws_member_consumption UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT dws_member_newconv_index_pkey PRIMARY KEY (newconv_id);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT pk_dws_member_project_tag PRIMARY KEY (id);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT uk_dws_member_project_tag UNIQUE (site_id, member_id, time_window, category_code);
ALTER TABLE dws.dws_member_spending_power_index ADD CONSTRAINT dws_member_spending_power_index_pkey PRIMARY KEY (spi_id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT dws_member_visit_detail_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT uk_dws_member_visit UNIQUE (site_id, member_id, order_settle_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT dws_member_winback_index_pkey PRIMARY KEY (winback_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_ml_manual_order_alloc ADD CONSTRAINT dws_ml_manual_order_alloc_pkey PRIMARY KEY (alloc_id);
ALTER TABLE dws.dws_ml_manual_order_alloc ADD CONSTRAINT uk_dws_ml_manual_order_alloc UNIQUE (site_id, external_id, assistant_id);
ALTER TABLE dws.dws_ml_manual_order_source ADD CONSTRAINT dws_ml_manual_order_source_pkey PRIMARY KEY (source_id);
@@ -1164,12 +1167,13 @@ CREATE INDEX idx_dws_percentile_history ON dws.dws_index_percentile_history USIN
CREATE INDEX idx_dws_intimacy_assistant ON dws.dws_member_assistant_intimacy USING btree (site_id, assistant_id, display_score DESC);
CREATE INDEX idx_dws_intimacy_member ON dws.dws_member_assistant_intimacy USING btree (site_id, member_id, display_score DESC);
CREATE INDEX idx_dws_relation_assistant ON dws.dws_member_assistant_relation_index USING btree (site_id, assistant_id, rs_display DESC);
CREATE INDEX idx_dws_relation_calc_time ON dws.dws_member_assistant_relation_index USING btree (calc_time);
CREATE INDEX idx_dws_relation_member ON dws.dws_member_assistant_relation_index USING btree (site_id, member_id, os_share DESC);
CREATE INDEX idx_dws_relation_stat_date ON dws.dws_member_assistant_relation_index USING btree (site_id, stat_date);
CREATE INDEX idx_dws_member_consumption_date ON dws.dws_member_consumption_summary USING btree (stat_date);
CREATE INDEX idx_dws_member_consumption_member ON dws.dws_member_consumption_summary USING btree (member_id, stat_date);
CREATE INDEX idx_dws_member_consumption_tier ON dws.dws_member_consumption_summary USING btree (customer_tier);
CREATE INDEX idx_dws_newconv_display ON dws.dws_member_newconv_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_dws_newconv_stat_date ON dws.dws_member_newconv_index USING btree (site_id, stat_date);
CREATE INDEX idx_mpt_site_window_tagged ON dws.dws_member_project_tag USING btree (site_id, time_window) WHERE (is_tagged = true);
CREATE INDEX idx_spi_display_score ON dws.dws_member_spending_power_index USING btree (site_id, display_score DESC);
CREATE UNIQUE INDEX idx_spi_site_member ON dws.dws_member_spending_power_index USING btree (site_id, member_id);
@@ -1177,6 +1181,7 @@ CREATE INDEX idx_dws_member_visit_date ON dws.dws_member_visit_detail USING btre
CREATE INDEX idx_dws_member_visit_member ON dws.dws_member_visit_detail USING btree (member_id, visit_date);
CREATE INDEX idx_dws_member_visit_order ON dws.dws_member_visit_detail USING btree (order_settle_id);
CREATE INDEX idx_dws_winback_display ON dws.dws_member_winback_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_dws_winback_stat_date ON dws.dws_member_winback_index USING btree (site_id, stat_date);
CREATE INDEX idx_dws_ml_alloc_member_assistant ON dws.dws_ml_manual_order_alloc USING btree (site_id, member_id, assistant_id);
CREATE INDEX idx_dws_ml_alloc_scope ON dws.dws_ml_manual_order_alloc USING btree (site_id, biz_date);
CREATE INDEX idx_dws_ml_source_external ON dws.dws_ml_manual_order_source USING btree (site_id, external_id);
@@ -1285,27 +1290,27 @@ SELECT id,
;
CREATE OR REPLACE VIEW dws.v_member_recall_priority AS
SELECT dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id,
dws_member_winback_index.member_id,
SELECT w.site_id,
w.tenant_id,
w.member_id,
'WBI'::character varying(10) AS index_type,
dws_member_winback_index.status,
dws_member_winback_index.segment,
dws_member_winback_index.member_create_time,
dws_member_winback_index.first_visit_time,
dws_member_winback_index.last_visit_time,
dws_member_winback_index.last_recharge_time,
dws_member_winback_index.t_v,
dws_member_winback_index.t_r,
dws_member_winback_index.t_a,
dws_member_winback_index.visits_14d,
dws_member_winback_index.visits_30d,
dws_member_winback_index.visits_60d,
dws_member_winback_index.visits_total,
dws_member_winback_index.spend_30d,
dws_member_winback_index.spend_180d,
dws_member_winback_index.sv_balance,
dws_member_winback_index.recharge_60d_amt,
w.status,
w.segment,
w.member_create_time,
w.first_visit_time,
w.last_visit_time,
w.last_recharge_time,
w.t_v,
w.t_r,
w.t_a,
w.visits_14d,
w.visits_30d,
w.visits_60d,
w.visits_total,
w.spend_30d,
w.spend_180d,
w.sv_balance,
w.recharge_60d_amt,
NULL::numeric(10,4) AS need_new,
NULL::numeric(10,4) AS salvage_new,
NULL::numeric(10,4) AS recharge_new,
@@ -1313,49 +1318,127 @@ SELECT dws_member_winback_index.site_id,
NULL::numeric(10,4) AS welcome_new,
NULL::numeric(14,6) AS raw_score_welcome,
NULL::numeric(14,6) AS raw_score_convert,
dws_member_winback_index.raw_score,
w.raw_score,
NULL::numeric(4,2) AS display_score_welcome,
NULL::numeric(4,2) AS display_score_convert,
dws_member_winback_index.display_score,
dws_member_winback_index.last_wechat_touch_time,
dws_member_winback_index.calc_time
FROM dws.dws_member_winback_index
w.display_score,
w.last_wechat_touch_time,
w.calc_time
FROM ( SELECT DISTINCT ON (dws_member_winback_index.site_id, dws_member_winback_index.member_id) dws_member_winback_index.winback_id,
dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id,
dws_member_winback_index.member_id,
dws_member_winback_index.status,
dws_member_winback_index.segment,
dws_member_winback_index.member_create_time,
dws_member_winback_index.first_visit_time,
dws_member_winback_index.last_visit_time,
dws_member_winback_index.last_recharge_time,
dws_member_winback_index.t_v,
dws_member_winback_index.t_r,
dws_member_winback_index.t_a,
dws_member_winback_index.visits_14d,
dws_member_winback_index.visits_60d,
dws_member_winback_index.visits_total,
dws_member_winback_index.spend_30d,
dws_member_winback_index.spend_180d,
dws_member_winback_index.sv_balance,
dws_member_winback_index.recharge_60d_amt,
dws_member_winback_index.interval_count,
dws_member_winback_index.overdue_old,
dws_member_winback_index.drop_old,
dws_member_winback_index.recharge_old,
dws_member_winback_index.value_old,
dws_member_winback_index.raw_score,
dws_member_winback_index.display_score,
dws_member_winback_index.last_wechat_touch_time,
dws_member_winback_index.calc_time,
dws_member_winback_index.calc_version,
dws_member_winback_index.created_at,
dws_member_winback_index.updated_at,
dws_member_winback_index.overdue_cdf_p,
dws_member_winback_index.ideal_interval_days,
dws_member_winback_index.ideal_next_visit_date,
dws_member_winback_index.visits_30d,
dws_member_winback_index.stat_date
FROM dws.dws_member_winback_index
ORDER BY dws_member_winback_index.site_id, dws_member_winback_index.member_id, dws_member_winback_index.stat_date DESC) w
UNION ALL
SELECT dws_member_newconv_index.site_id,
dws_member_newconv_index.tenant_id,
dws_member_newconv_index.member_id,
SELECT n.site_id,
n.tenant_id,
n.member_id,
'NCI'::character varying(10) AS index_type,
dws_member_newconv_index.status,
dws_member_newconv_index.segment,
dws_member_newconv_index.member_create_time,
dws_member_newconv_index.first_visit_time,
dws_member_newconv_index.last_visit_time,
dws_member_newconv_index.last_recharge_time,
dws_member_newconv_index.t_v,
dws_member_newconv_index.t_r,
dws_member_newconv_index.t_a,
dws_member_newconv_index.visits_14d,
dws_member_newconv_index.visits_30d,
dws_member_newconv_index.visits_60d,
dws_member_newconv_index.visits_total,
dws_member_newconv_index.spend_30d,
dws_member_newconv_index.spend_180d,
dws_member_newconv_index.sv_balance,
dws_member_newconv_index.recharge_60d_amt,
dws_member_newconv_index.need_new,
dws_member_newconv_index.salvage_new,
dws_member_newconv_index.recharge_new,
dws_member_newconv_index.value_new,
dws_member_newconv_index.welcome_new,
dws_member_newconv_index.raw_score_welcome,
dws_member_newconv_index.raw_score_convert,
dws_member_newconv_index.raw_score,
dws_member_newconv_index.display_score_welcome,
dws_member_newconv_index.display_score_convert,
dws_member_newconv_index.display_score,
dws_member_newconv_index.last_wechat_touch_time,
dws_member_newconv_index.calc_time
FROM dws.dws_member_newconv_index;
n.status,
n.segment,
n.member_create_time,
n.first_visit_time,
n.last_visit_time,
n.last_recharge_time,
n.t_v,
n.t_r,
n.t_a,
n.visits_14d,
n.visits_30d,
n.visits_60d,
n.visits_total,
n.spend_30d,
n.spend_180d,
n.sv_balance,
n.recharge_60d_amt,
n.need_new,
n.salvage_new,
n.recharge_new,
n.value_new,
n.welcome_new,
n.raw_score_welcome,
n.raw_score_convert,
n.raw_score,
n.display_score_welcome,
n.display_score_convert,
n.display_score,
n.last_wechat_touch_time,
n.calc_time
FROM ( SELECT DISTINCT ON (dws_member_newconv_index.site_id, dws_member_newconv_index.member_id) dws_member_newconv_index.newconv_id,
dws_member_newconv_index.site_id,
dws_member_newconv_index.tenant_id,
dws_member_newconv_index.member_id,
dws_member_newconv_index.status,
dws_member_newconv_index.segment,
dws_member_newconv_index.member_create_time,
dws_member_newconv_index.first_visit_time,
dws_member_newconv_index.last_visit_time,
dws_member_newconv_index.last_recharge_time,
dws_member_newconv_index.t_v,
dws_member_newconv_index.t_r,
dws_member_newconv_index.t_a,
dws_member_newconv_index.visits_14d,
dws_member_newconv_index.visits_60d,
dws_member_newconv_index.visits_total,
dws_member_newconv_index.spend_30d,
dws_member_newconv_index.spend_180d,
dws_member_newconv_index.sv_balance,
dws_member_newconv_index.recharge_60d_amt,
dws_member_newconv_index.interval_count,
dws_member_newconv_index.need_new,
dws_member_newconv_index.salvage_new,
dws_member_newconv_index.recharge_new,
dws_member_newconv_index.value_new,
dws_member_newconv_index.welcome_new,
dws_member_newconv_index.raw_score_welcome,
dws_member_newconv_index.raw_score_convert,
dws_member_newconv_index.raw_score,
dws_member_newconv_index.display_score_welcome,
dws_member_newconv_index.display_score_convert,
dws_member_newconv_index.display_score,
dws_member_newconv_index.last_wechat_touch_time,
dws_member_newconv_index.calc_time,
dws_member_newconv_index.calc_version,
dws_member_newconv_index.created_at,
dws_member_newconv_index.updated_at,
dws_member_newconv_index.visits_30d,
dws_member_newconv_index.stat_date
FROM dws.dws_member_newconv_index
ORDER BY dws_member_newconv_index.site_id, dws_member_newconv_index.member_id, dws_member_newconv_index.stat_date DESC) n;
;
-- 物化视图

View File

@@ -148,7 +148,7 @@ cd apps/etl/connectors/feiqiu && python -m cli.main --dry-run --tasks DWD_LOAD_F
# 测试
cd apps/etl/connectors/feiqiu && pytest tests/unit
cd C:\NeoZQYY && pytest tests/ -v
cd C:\Project\NeoZQYY && pytest tests/ -v
```
## 文件归属规则

View File

@@ -0,0 +1,702 @@
# 助教业务任务明细报告
> 导出日期2026-04-12
## 指标说明
| 指标 | 全称 | 含义 | 范围 |
|------|------|------|------|
| **WBI** | 流失回赢指数 | 老客流失风险,越高越需要召回 | 0-10 |
| **NCI** | 新客转化指数 | 新客沉默风险,越高越需要跟进 | 0-10 |
| **RS** | 关系强度指数 | 助教与客户亲密度(频次+时长+近期性) | 0-10 |
| **OS** | 归属份额标签 | 客户主要由谁服务 | MAIN / COMANAGE / POOL |
| **MS** | 升温动量 | 近期频率 vs 长期频率 | 0-10 |
| **ML** | 付费关联 | 人工台账充值关联强度 | 0-10 |
**OS**MAIN=主服务(>=70%) | COMANAGE=共管(>=30%) | POOL=池内
**任务判定**`max(WBI,NCI)>7` -> 高优先召回 | `>5` -> 优先召回 | `1<RS<6` -> 关系构建 | 客户到店 -> 完成召回 -> 生成回访(48h)
## 一、总览
| 状态 | 数量 |
|------|------|
| 进行中 | 245 |
| 已完成 | 40 |
| 已过期 | 37 |
| 任务类型 | 进行中 | 已完成 | 已过期 | 已关闭 |
|----------|--------|--------|--------|--------|
| 高优先召回 | 61 | 34 | 0 | 0 |
| 优先召回 | 55 | 6 | 0 | 0 |
| 客户回访 | 3 | 0 | 37 | 0 |
| 关系构建 | 126 | 0 | 0 | 0 |
## 二、助教任务详情
### 婉婉【在职】(进行中 42 / 已完成 9 / 共 58
**高优先召回**18
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 叶总 | 7.60 | 7.74 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-22 | 2025-09-14 | 2025-10-28 |
| 已完成 | 吴先生 | 7.33 | 7.15 | 老客 | 8.60 | MAIN | 10 | 18 | 2026-01-09 | 2026-03-15 | 2026-04-03 |
| 已完成 | 婉婉 | 7.09 | 6.61 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-07 | 2025-11-21 | 2025-11-21 |
| 已完成 | 邓飛 | 7.03 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-20 | 2025-09-06 | 2026-04-05 |
| 已完成 | 婉婉 | 7.01 | 6.61 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-24 | 2025-09-21 | 2025-11-21 |
| 进行中 | 刘哥 | 10.00 | 9.67 | 老客 | 0.03 | POOL | 1 | 89 | 2025-11-22 | 2026-01-11 | 2025-12-20 |
| 进行中 | 东哥 | 10.00 | - | 10.00 | 4.10 | COMANAGE | 2 | 20 | 2026-03-24 | 2026-04-01 | 2026-03-24 |
| 进行中 | 艾宇民 | 10.00 | 10.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-12 | 2026-04-12 | 2026-03-08 |
| 进行中 | 夏 | 8.50 | 10.00 | 老客 | 0.07 | POOL | 1 | 89 | 2025-08-17 | 2025-11-24 | 2025-11-09 |
| 进行中 | 陈腾鑫 | 8.22 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-09 | 2025-09-10 | 2026-04-05 |
| 进行中 | 常总 | 7.74 | 7.74 | 老客 | 0.00 | POOL | 1 | 89 | 2025-12-22 | 2026-04-12 | 2026-02-06 |
| 进行中 | 孙总 | 7.69 | 8.27 | 老客 | 0.31 | MAIN | 2 | 81 | 2026-01-16 | 2026-02-03 | 2026-01-26 |
| 进行中 | 公孙先生 | 7.59 | 9.51 | 老客 | 0.00 | POOL | 1 | 87 | 2026-01-16 | 2026-02-18 | 2026-02-01 |
| 进行中 | 羊 | 7.38 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-31 | 2025-12-10 | 2026-04-10 |
| 进行中 | 周先生 | 7.14 | 8.69 | 老客 | 0.00 | POOL | 1 | 89 | 2026-01-01 | 2026-02-06 | 2026-03-13 |
| 进行中 | 万先生 | 7.11 | - | 6.73 | 0.07 | POOL | 1 | 89 | 2025-11-10 | 2026-01-18 | 2025-11-10 |
| 进行中 | 潘先生 | 7.03 | 8.36 | 老客 | 3.19 | MAIN | 2 | 24 | 2026-03-09 | 2026-04-10 | 2026-03-20 |
| 进行中 | 老宋 | 7.01 | 9.48 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-07 | 2025-12-02 | 2025-12-27 |
**优先召回**17
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 君姐 | 6.93 | 0.00 | 老客 | 3.03 | POOL | 1 | 13 | 2025-11-27 | 2026-04-07 | 2026-04-12 |
| 已完成 | 歌神 | 5.39 | 4.09 | 老客 | 2.39 | MAIN | 1 | 23 | 2025-08-17 | 2025-10-12 | 2026-03-29 |
| 已完成 | 邓飛 | 5.38 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-16 | 2025-11-09 | 2026-04-05 |
| 已完成 | 邓飛 | 5.14 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-17 | 2025-10-01 | 2026-04-05 |
| 进行中 | 吴先生 | 7.15 | 7.15 | 老客 | 8.60 | MAIN | 10 | 18 | 2026-04-10 | 2026-04-12 | 2026-04-03 |
| 进行中 | 叶先生 | 6.95 | 6.44 | 老客 | 0.09 | POOL | 1 | 78 | 2025-10-31 | 2026-02-26 | 2026-03-14 |
| 进行中 | 婉婉 | 6.93 | 6.61 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-23 | 2026-01-15 | 2025-11-21 |
| 进行中 | 查先生 | 6.88 | - | 1.68 | 0.00 | POOL | 1 | 89 | 2025-08-27 | 2025-10-04 | 2025-09-08 |
| 进行中 | 候 | 6.86 | 5.98 | 老客 | 0.00 | POOL | 1 | 89 | 2025-12-14 | 2026-02-14 | 2025-12-18 |
| 进行中 | 杜先生 | 6.70 | - | 2.15 | 0.00 | POOL | 1 | 89 | 2025-09-04 | 2025-10-09 | 2025-09-04 |
| 进行中 | 王 | 6.59 | 6.59 | 老客 | 0.00 | POOL | 1 | 89 | 2026-01-03 | 2026-04-12 | 2026-02-08 |
| 进行中 | 邓飛 | 5.80 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-14 | 2025-12-16 | 2026-04-05 |
| 进行中 | 黄国磊 | 5.66 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-01 | 2025-09-25 | 2026-03-31 |
| 进行中 | 明哥 | 5.49 | 4.79 | 老客 | 0.15 | POOL | 1 | 77 | 2025-12-09 | 2026-02-16 | 2026-04-02 |
| 进行中 | 叶总 | 5.07 | 7.74 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-18 | 2025-10-19 | 2025-10-28 |
| 进行中 | 黎先生 | 5.07 | - | 0.00 | 0.00 | POOL | 1 | 89 | 2025-10-10 | 2025-10-22 | 2025-10-10 |
| 进行中 | 章先生 | 4.19 | - | 4.19 | 0.00 | POOL | 1 | 86 | 2026-01-17 | 2026-04-12 | 2026-02-06 |
**客户回访**9
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 进行中 | 歌神 | 2.39 | MAIN | 1 | 2025-10-12 | 2025-10-12 | 2026-03-29 | - |
| 进行中 | 婉婉 | 0.00 | POOL | 1 | 2025-11-21 | 2025-11-21 | 2025-11-21 | - |
| 已过期 | 叶总 | 0.00 | POOL | 1 | 2025-09-14 | 2025-09-17 | 2025-10-28 | - |
| 已过期 | 婉婉 | 0.00 | POOL | 1 | 2025-09-21 | 2025-09-24 | 2025-11-21 | - |
| 已过期 | 邓飛 | 0.00 | POOL | 1 | 2025-10-01 | 2025-10-04 | 2026-04-05 | - |
| 已过期 | 邓飛 | 0.00 | POOL | 1 | 2025-11-09 | 2025-11-11 | 2026-04-05 | - |
| 已过期 | 吴先生 | 8.60 | MAIN | 10 | 2026-03-15 | 2026-03-18 | 2026-04-03 | - |
| 已过期 | 君姐 | 3.03 | POOL | 1 | 2026-04-07 | 2026-04-10 | 2026-04-12 | - |
| 已过期 | 邓飛 | 0.00 | POOL | 1 | 2025-09-06 | 2025-09-08 | 2026-04-05 | - |
**关系构建**14
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | T | 4.67 | MAIN | 1 | 16 | 0.00 | 0.00 | 2026-03-28 | 2026-03-28 | 2026-04-01 |
| 进行中 | 张先生 | 0.03 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-18 | 2025-08-18 | 2026-04-10 |
| 进行中 | H | 5.02 | MAIN | 1 | 2 | 0.00 | 0.00 | 2025-10-15 | 2026-04-11 | 2026-04-12 |
| 进行中 | 轩哥 | 9.93 | POOL | 6 | 2 | 0.00 | 0.00 | 2025-08-15 | 2025-08-15 | 2026-04-11 |
| 进行中 | 蔡总 | 1.54 | POOL | 1 | 32 | 0.00 | 0.00 | 2025-11-06 | 2025-11-07 | 2026-04-11 |
| 进行中 | 蔡先生 | 3.68 | MAIN | 2 | 28 | 0.00 | 0.00 | 2026-03-20 | 2026-04-04 | 2026-04-04 |
| 进行中 | 歌神 | 2.39 | MAIN | 1 | 23 | 0.00 | 0.00 | 2025-10-14 | 2026-03-21 | 2026-03-29 |
| 进行中 | 罗先生 | 4.66 | MAIN | 2 | 11 | 0.00 | 0.00 | 2025-08-22 | 2026-03-19 | 2026-04-03 |
| 进行中 | 君姐 | 3.03 | POOL | 1 | 13 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 林先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-19 | 2025-12-28 | 2026-01-12 |
| 进行中 | 江先生 | 1.94 | POOL | 1 | 30 | 0.00 | 0.00 | 2025-09-02 | 2025-09-02 | 2026-04-01 |
| 进行中 | 林总 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-25 | 2025-12-19 | 2026-03-10 |
| 进行中 | 胡先生 | 0.01 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2025-12-01 |
| 进行中 | 葛先生 | 0.00 | POOL | 1 | 80 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
### 璇子【在职】(进行中 24 / 已完成 10 / 共 44
**高优先召回**18
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 夏 | 10.00 | 10.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-18 | 2025-08-24 | 2025-11-09 |
| 已完成 | 江先生 | 10.00 | 0.00 | 老客 | 9.20 | MAIN | 11 | 12 | 2025-08-25 | 2025-09-02 | 2026-04-01 |
| 已完成 | 轩哥 | 8.03 | 0.00 | 老客 | 10.00 | COMANAGE | 25 | 5 | 2026-02-27 | 2026-03-10 | 2026-04-11 |
| 已完成 | 蔡总 | 8.02 | 9.42 | 老客 | 6.16 | COMANAGE | 4 | 21 | 2026-02-03 | 2026-03-11 | 2026-04-11 |
| 已完成 | 江先生 | 7.91 | 0.00 | 老客 | 9.20 | MAIN | 11 | 12 | 2026-02-14 | 2026-03-07 | 2026-04-01 |
| 已完成 | 江先生 | 7.82 | 0.00 | 老客 | 9.20 | MAIN | 11 | 12 | 2025-12-28 | 2026-01-04 | 2026-04-01 |
| 已完成 | 蔡总 | 7.66 | 9.42 | 老客 | 6.16 | COMANAGE | 4 | 21 | 2025-08-28 | 2026-01-22 | 2026-04-11 |
| 已完成 | 叶总 | 7.60 | 7.74 | 老客 | 0.02 | POOL | 1 | 89 | 2025-08-21 | 2025-09-14 | 2025-10-28 |
| 进行中 | 胡先生 | 8.08 | 8.08 | 老客 | 5.77 | MAIN | 3 | 20 | 2025-09-05 | 2026-04-13 | 2026-03-29 |
| 进行中 | 蔡总 | 7.86 | 9.42 | 老客 | 6.16 | COMANAGE | 4 | 21 | 2026-04-08 | 2026-04-10 | 2026-04-11 |
| 进行中 | 孙总 | 7.69 | 8.27 | 老客 | 0.17 | POOL | 1 | 80 | 2026-01-23 | 2026-02-03 | 2026-01-26 |
| 进行中 | 林总 | 7.42 | 10.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-01 | 2025-09-19 | 2026-03-10 |
| 进行中 | 羊 | 7.38 | 0.00 | 老客 | 0.01 | POOL | 1 | 89 | 2025-11-11 | 2025-12-10 | 2026-04-10 |
| 进行中 | 叶总 | 7.29 | 7.74 | 老客 | 0.02 | POOL | 1 | 89 | 2025-11-13 | 2025-11-17 | 2025-10-28 |
| 进行中 | 周周 | 7.15 | 8.11 | 老客 | 0.00 | POOL | 1 | 89 | 2026-01-11 | 2026-02-13 | 2026-03-29 |
| 进行中 | 罗先生 | 7.07 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-26 | 2025-09-06 | 2026-04-03 |
| 进行中 | 歌神 | 7.05 | 4.09 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-17 | 2025-08-30 | 2026-03-29 |
| 进行中 | 林先生 | 7.05 | 9.56 | 老客 | 0.01 | POOL | 1 | 89 | 2025-12-10 | 2026-01-14 | 2025-12-29 |
**优先召回**5
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 江先生 | 5.54 | 0.00 | 老客 | 9.20 | MAIN | 11 | 12 | 2025-11-01 | 2025-11-02 | 2026-04-01 |
| 已完成 | 叶总 | 5.07 | 7.74 | 老客 | 0.02 | POOL | 1 | 89 | 2025-10-08 | 2025-10-20 | 2025-10-28 |
| 进行中 | 罗超 | 6.89 | 2.67 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-08 | 2025-11-25 | 2026-01-19 |
| 进行中 | 邓飛 | 5.14 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-20 | 2025-09-28 | 2026-04-05 |
| 进行中 | 清 | 5.14 | 5.68 | 老客 | 3.08 | MAIN | 1 | 23 | 2026-03-21 | 2026-04-10 | 2026-03-22 |
**客户回访**10
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 已过期 | 叶总 | 0.02 | POOL | 1 | 2025-09-14 | 2025-09-17 | 2025-10-28 | - |
| 已过期 | 叶总 | 0.02 | POOL | 1 | 2025-10-20 | 2025-10-23 | 2025-10-28 | - |
| 已过期 | 江先生 | 9.20 | MAIN | 11 | 2025-11-02 | 2025-11-05 | 2026-04-01 | - |
| 已过期 | 夏 | 0.00 | POOL | 1 | 2025-08-24 | 2025-08-27 | 2025-11-09 | - |
| 已过期 | 江先生 | 9.20 | MAIN | 11 | 2026-01-04 | 2026-01-07 | 2026-04-01 | - |
| 已过期 | 蔡总 | 6.16 | COMANAGE | 4 | 2026-01-22 | 2026-01-25 | 2026-04-11 | - |
| 已过期 | 江先生 | 9.20 | MAIN | 11 | 2026-03-07 | 2026-03-10 | 2026-04-01 | - |
| 已过期 | 蔡总 | 6.16 | COMANAGE | 4 | 2026-03-11 | 2026-03-13 | 2026-04-11 | - |
| 已过期 | 轩哥 | 10.00 | COMANAGE | 25 | 2026-03-10 | 2026-03-13 | 2026-04-11 | - |
| 已过期 | 江先生 | 9.20 | MAIN | 11 | 2025-09-02 | 2025-09-04 | 2026-04-01 | - |
**关系构建**11
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 10.00 | COMANAGE | 25 | 5 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 江先生 | 9.20 | MAIN | 11 | 12 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-01 |
| 进行中 | 夏 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-28 | 2025-08-28 | 2025-11-09 |
| 进行中 | 君姐 | 2.23 | POOL | 1 | 32 | 0.00 | 0.00 | 2026-03-12 | 2026-03-12 | 2026-04-12 |
| 进行中 | 张先生 | 1.47 | POOL | 2 | 41 | 0.00 | 0.00 | 2025-08-14 | 2026-03-03 | 2026-04-10 |
| 进行中 | H | 4.27 | MAIN | 1 | 9 | 0.00 | 0.00 | 2026-04-04 | 2026-04-04 | 2026-04-12 |
| 进行中 | 梅 | 1.65 | POOL | 1 | 36 | 0.00 | 0.00 | 2026-03-08 | 2026-03-08 | 2026-04-06 |
| 进行中 | 蔡先生 | 3.71 | MAIN | 1 | 9 | 0.00 | 0.00 | 2026-04-04 | 2026-04-04 | 2026-04-04 |
| 进行中 | 游 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-30 | 2025-11-30 | 2025-12-13 |
| 进行中 | 陈先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-21 | 2025-08-21 | 2025-10-18 |
| 进行中 | 罗先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-27 | 2025-08-27 | 2026-03-25 |
### 小柔【在职】(进行中 31 / 已完成 5 / 共 40
**高优先召回**12
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 夏 | 10.00 | 10.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-23 | 2025-08-24 | 2025-11-09 |
| 已完成 | 蔡总 | 8.02 | 9.42 | 老客 | 6.76 | COMANAGE | 6 | 22 | 2026-02-09 | 2026-03-13 | 2026-04-11 |
| 已完成 | 蔡总 | 7.66 | 9.42 | 老客 | 6.76 | COMANAGE | 6 | 22 | 2026-01-18 | 2026-01-22 | 2026-04-11 |
| 已完成 | 蔡总 | 7.63 | 9.42 | 老客 | 6.76 | COMANAGE | 6 | 22 | 2025-11-05 | 2025-11-07 | 2026-04-11 |
| 已完成 | 邓飛 | 7.03 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-19 | 2025-09-06 | 2026-04-05 |
| 进行中 | 歌神 | 10.00 | 4.09 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-16 | 2025-12-20 | 2026-03-29 |
| 进行中 | 陈德韩 | 8.67 | 8.32 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-17 | 2025-12-18 | 2025-12-22 |
| 进行中 | 昌哥 | 7.94 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-12-07 | 2026-02-01 | 2026-04-10 |
| 进行中 | 蔡总 | 7.86 | 9.42 | 老客 | 6.76 | COMANAGE | 6 | 22 | 2026-04-08 | 2026-04-10 | 2026-04-11 |
| 进行中 | 汪先生 | 7.43 | 8.44 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-20 | 2025-10-28 | 2025-10-24 |
| 进行中 | 羊 | 7.38 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-08 | 2025-12-10 | 2026-04-10 |
| 进行中 | H | 7.28 | 0.00 | 老客 | 0.01 | POOL | 1 | 89 | 2025-10-20 | 2025-11-01 | 2026-04-12 |
**优先召回**7
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 孟紫龙 | 6.92 | 6.92 | 老客 | 0.00 | POOL | 1 | 89 | 2025-12-21 | 2026-04-12 | 2025-12-28 |
| 进行中 | 万先生 | 6.73 | - | 6.73 | 0.07 | POOL | 1 | 89 | 2025-11-10 | 2026-04-12 | 2025-11-10 |
| 进行中 | 邓飛 | 5.80 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-07 | 2025-12-16 | 2026-04-05 |
| 进行中 | 郭先生 | 5.49 | 4.44 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-24 | 2025-09-11 | 2025-11-01 |
| 进行中 | 陶 | 5.25 | 4.43 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-27 | 2025-11-08 | 2025-10-27 |
| 进行中 | 蔡先生 | 5.01 | 0.00 | 老客 | 0.02 | POOL | 1 | 89 | 2025-09-08 | 2025-10-12 | 2026-04-04 |
| 进行中 | 明哥 | 4.79 | 4.79 | 老客 | 4.37 | MAIN | 3 | 27 | 2025-11-28 | 2026-04-12 | 2026-04-02 |
**客户回访**5
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 进行中 | 邓飛 | 0.00 | POOL | 1 | 2025-09-06 | 2025-09-06 | 2026-04-05 | - |
| 已过期 | 蔡总 | 6.76 | COMANAGE | 6 | 2025-11-07 | 2025-11-10 | 2026-04-11 | - |
| 已过期 | 夏 | 0.00 | POOL | 1 | 2025-08-24 | 2025-08-27 | 2025-11-09 | - |
| 已过期 | 蔡总 | 6.76 | COMANAGE | 6 | 2026-01-22 | 2026-01-25 | 2026-04-11 | - |
| 已过期 | 蔡总 | 6.76 | COMANAGE | 6 | 2026-03-13 | 2026-03-16 | 2026-04-11 | - |
**关系构建**16
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 8.51 | POOL | 6 | 4 | 0.00 | 0.00 | 2025-08-19 | 2025-08-19 | 2026-04-11 |
| 进行中 | T | 4.67 | MAIN | 1 | 16 | 0.00 | 0.00 | 2025-12-13 | 2026-03-28 | 2026-04-01 |
| 进行中 | 张先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-10 | 2025-09-10 | 2026-04-10 |
| 进行中 | 夏 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-31 | 2025-08-31 | 2025-11-09 |
| 进行中 | 葛先生 | 4.97 | POOL | 3 | 13 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 陈腾鑫 | 3.55 | COMANAGE | 1 | 13 | 0.00 | 0.00 | 2025-08-17 | 2025-09-23 | 2026-04-05 |
| 进行中 | 叶总 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-22 | 2025-10-20 | 2025-10-28 |
| 进行中 | 林先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-19 | 2025-11-19 | 2026-01-12 |
| 进行中 | 游 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-26 | 2025-11-26 | 2025-12-13 |
| 进行中 | 罗先生 | 1.80 | POOL | 1 | 25 | 0.00 | 0.00 | 2026-03-19 | 2026-03-19 | 2026-04-03 |
| 进行中 | 陈淑涛 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-17 | 2025-08-17 | 2026-03-26 |
| 进行中 | 胡先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-07 | 2025-11-07 | 2025-12-01 |
| 进行中 | 小燕 | 2.36 | POOL | 1 | 15 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 吴生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-26 | 2025-08-26 | 2026-04-11 |
| 进行中 | 罗先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-23 | 2025-08-23 | 2026-03-25 |
| 进行中 | 江先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-01 |
### 佳怡【在职】(进行中 29 / 已完成 5 / 共 39
**高优先召回**15
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 陈腾鑫 | 7.54 | 0.00 | 老客 | 3.19 | COMANAGE | 5 | 38 | 2026-02-24 | 2026-03-06 | 2026-04-05 |
| 已完成 | 罗先生 | 7.48 | 10.00 | 老客 | 7.69 | MAIN | 14 | 19 | 2025-08-15 | 2026-03-19 | 2026-03-25 |
| 已完成 | 陈先生 | 7.37 | 7.02 | 老客 | 6.39 | MAIN | 9 | 31 | 2025-10-10 | 2025-11-07 | 2026-03-13 |
| 已完成 | 陈先生 | 7.23 | 7.02 | 老客 | 6.39 | MAIN | 9 | 31 | 2025-11-24 | 2026-03-06 | 2026-03-13 |
| 已完成 | 陈腾鑫 | 7.11 | 0.00 | 老客 | 3.19 | COMANAGE | 5 | 38 | 2025-08-17 | 2026-01-06 | 2026-04-05 |
| 进行中 | 葛先生 | 10.00 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-22 | 2025-10-07 | 2026-04-12 |
| 进行中 | 林志铭 | 8.56 | 8.60 | 老客 | 0.10 | POOL | 1 | 79 | 2026-01-24 | 2026-03-13 | 2026-01-24 |
| 进行中 | 蔡总 | 7.86 | 9.42 | 老客 | 9.91 | MAIN | 9 | 20 | 2025-08-23 | 2026-04-10 | 2026-04-11 |
| 进行中 | 胡先生 | 7.52 | 8.08 | 老客 | 0.19 | POOL | 1 | 76 | 2026-01-27 | 2026-03-01 | 2026-03-29 |
| 进行中 | 罗先生 | 7.41 | 10.00 | 老客 | 7.69 | MAIN | 14 | 19 | 2026-04-08 | 2026-04-09 | 2026-03-25 |
| 进行中 | 陈德韩 | 7.32 | 8.32 | 老客 | 0.15 | POOL | 2 | 89 | 2025-08-14 | 2025-11-10 | 2025-12-22 |
| 进行中 | T | 7.23 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-24 | 2026-01-05 | 2026-04-01 |
| 进行中 | 周周 | 7.15 | 8.11 | 老客 | 0.00 | POOL | 1 | 89 | 2025-12-09 | 2026-02-13 | 2026-03-29 |
| 进行中 | 小熊 | 7.07 | 7.90 | 老客 | 0.01 | POOL | 1 | 86 | 2025-10-16 | 2026-02-05 | 2026-01-17 |
| 进行中 | 林先生 | 7.05 | 9.56 | 老客 | 0.02 | POOL | 1 | 89 | 2025-11-23 | 2026-01-14 | 2025-12-29 |
**优先召回**8
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 大G | 7.28 | 7.28 | 老客 | 0.02 | POOL | 1 | 89 | 2025-12-07 | 2026-04-12 | 2025-12-07 |
| 进行中 | 陈先生 | 7.02 | 7.02 | 老客 | 6.39 | MAIN | 9 | 31 | 2026-04-02 | 2026-04-12 | 2026-03-13 |
| 进行中 | 游 | 6.94 | 4.83 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-26 | 2026-01-13 | 2025-12-13 |
| 进行中 | 胡先生 | 6.80 | 5.06 | 老客 | 0.08 | POOL | 1 | 89 | 2025-11-07 | 2026-01-09 | 2025-12-01 |
| 进行中 | 陈先生 | 6.80 | - | 5.97 | 0.00 | POOL | 1 | 89 | 2025-09-23 | 2025-11-08 | 2026-03-04 |
| 进行中 | 贺斌 | 5.57 | 4.54 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-28 | 2025-11-28 | 2025-11-03 |
| 进行中 | 陶 | 5.25 | 4.43 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-20 | 2025-11-08 | 2025-10-27 |
| 进行中 | 彭先生 | 5.21 | - | 0.00 | 0.00 | POOL | 1 | 89 | 2026-01-04 | 2026-01-17 | 2026-01-07 |
**客户回访**5
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 已过期 | 陈先生 | 6.39 | MAIN | 9 | 2025-11-07 | 2025-11-09 | 2026-03-13 | - |
| 已过期 | 陈腾鑫 | 3.19 | COMANAGE | 5 | 2026-01-06 | 2026-01-08 | 2026-04-05 | - |
| 已过期 | 陈先生 | 6.39 | MAIN | 9 | 2026-03-06 | 2026-03-09 | 2026-03-13 | - |
| 已过期 | 陈腾鑫 | 3.19 | COMANAGE | 5 | 2026-03-06 | 2026-03-09 | 2026-04-05 | - |
| 已过期 | 罗先生 | 7.69 | MAIN | 14 | 2026-03-19 | 2026-03-21 | 2026-03-25 | - |
**关系构建**11
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 陈腾鑫 | 3.19 | COMANAGE | 5 | 38 | 0.00 | 0.00 | 2026-03-22 | 2026-03-22 | 2026-04-05 |
| 进行中 | 轩哥 | 7.05 | POOL | 6 | 20 | 0.00 | 0.00 | 2025-08-15 | 2025-08-15 | 2026-04-11 |
| 进行中 | 张先生 | 0.02 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-09 | 2025-09-09 | 2026-04-10 |
| 进行中 | H | 4.27 | MAIN | 1 | 9 | 0.00 | 0.00 | 2025-10-16 | 2026-04-04 | 2026-04-12 |
| 进行中 | 蔡先生 | 3.86 | MAIN | 1 | 9 | 0.00 | 0.00 | 2025-08-22 | 2026-04-04 | 2026-04-04 |
| 进行中 | 歌神 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-10-13 | 2025-10-13 | 2026-03-29 |
| 进行中 | 邓飛 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-07 | 2025-09-07 | 2026-04-05 |
| 进行中 | 君姐 | 1.60 | POOL | 1 | 20 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 叶先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-03-14 |
| 进行中 | 夏 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2025-11-09 |
| 进行中 | 江先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-01 |
### 涛涛【在职】(进行中 27 / 已完成 4 / 共 35
**高优先召回**8
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 江先生 | 10.00 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-17 | 2025-11-02 | 2026-04-01 |
| 已完成 | 蔡总 | 8.02 | 9.42 | 老客 | 6.30 | COMANAGE | 7 | 27 | 2026-02-14 | 2026-03-11 | 2026-04-11 |
| 已完成 | 蔡总 | 7.66 | 9.42 | 老客 | 6.30 | COMANAGE | 7 | 27 | 2025-11-20 | 2026-01-22 | 2026-04-11 |
| 已完成 | 蔡总 | 7.63 | 9.42 | 老客 | 6.30 | COMANAGE | 7 | 27 | 2025-11-05 | 2025-11-07 | 2026-04-11 |
| 进行中 | 罗先生 | 10.00 | 10.00 | 老客 | 0.01 | POOL | 1 | 89 | 2025-08-27 | 2026-04-12 | 2026-03-25 |
| 进行中 | 胡先生 | 10.00 | 8.08 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-31 | 2025-09-13 | 2026-03-29 |
| 进行中 | 蔡总 | 7.86 | 9.42 | 老客 | 6.30 | COMANAGE | 7 | 27 | 2026-04-08 | 2026-04-10 | 2026-04-11 |
| 进行中 | 孙总 | 7.69 | 8.27 | 老客 | 0.00 | POOL | 1 | 78 | 2026-01-25 | 2026-02-03 | 2026-01-26 |
**优先召回**10
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 叶先生 | 6.95 | 6.44 | 老客 | 0.09 | POOL | 1 | 78 | 2025-08-19 | 2026-02-26 | 2026-03-14 |
| 进行中 | 冯先生 | 6.87 | - | 0.09 | 0.00 | POOL | 1 | 89 | 2025-11-01 | 2025-12-04 | 2025-11-01 |
| 进行中 | 候 | 6.86 | 5.98 | 老客 | 0.00 | POOL | 1 | 89 | 2025-12-13 | 2026-02-14 | 2025-12-18 |
| 进行中 | 符先生 | 6.72 | - | 2.80 | 0.00 | POOL | 1 | 89 | 2025-09-03 | 2025-10-10 | 2025-09-03 |
| 进行中 | 李先生 | 6.47 | 6.47 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-06 | 2026-04-12 | 2026-01-01 |
| 进行中 | 胡总 | 5.21 | 6.14 | 老客 | 0.01 | POOL | 1 | 89 | 2025-12-21 | 2026-01-15 | 2025-12-28 |
| 进行中 | 周先生 | 5.01 | 6.86 | 老客 | 0.03 | POOL | 1 | 89 | 2025-08-31 | 2025-10-14 | 2025-10-25 |
| 进行中 | 蔡先生 | 5.01 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-22 | 2025-10-21 | 2026-04-04 |
| 进行中 | 柳先生 | 4.50 | 4.50 | 老客 | 2.34 | MAIN | 1 | 22 | 2026-03-22 | 2026-04-12 | 2026-03-22 |
| 进行中 | 阿亮 | 4.06 | 4.06 | 老客 | 2.00 | COMANAGE | 1 | 20 | 2025-11-26 | 2026-04-12 | 2026-03-28 |
**客户回访**4
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 已过期 | 江先生 | 0.00 | POOL | 1 | 2025-11-02 | 2025-11-04 | 2026-04-01 | - |
| 已过期 | 蔡总 | 6.30 | COMANAGE | 7 | 2025-11-07 | 2025-11-10 | 2026-04-11 | - |
| 已过期 | 蔡总 | 6.30 | COMANAGE | 7 | 2026-01-22 | 2026-01-25 | 2026-04-11 | - |
| 已过期 | 蔡总 | 6.30 | COMANAGE | 7 | 2026-03-11 | 2026-03-13 | 2026-04-11 | - |
**关系构建**13
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 8.46 | POOL | 9 | 14 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 张先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-17 | 2025-09-17 | 2026-04-10 |
| 进行中 | 夏 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-08-15 | 2025-08-24 | 2025-11-09 |
| 进行中 | 陈淑涛 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-21 | 2025-11-16 | 2026-03-26 |
| 进行中 | 梅 | 3.55 | MAIN | 1 | 9 | 0.00 | 0.00 | 2026-04-04 | 2026-04-04 | 2026-04-06 |
| 进行中 | 吴生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-10 | 2025-11-10 | 2026-04-11 |
| 进行中 | 明哥 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-28 | 2025-11-28 | 2026-04-02 |
| 进行中 | 罗先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-28 | 2025-09-28 | 2026-04-03 |
| 进行中 | 马先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-07 | 2025-10-31 | 2025-09-10 |
| 进行中 | 小燕 | 0.39 | POOL | 2 | 72 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 葛先生 | 0.03 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 江先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-01 |
| 进行中 | 胡先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2025-12-01 |
### 七七【在职】(进行中 29 / 已完成 3 / 共 35
**高优先召回**12
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 轩哥 | 8.03 | 0.00 | 老客 | 10.00 | MAIN | 40 | 2 | 2026-02-27 | 2026-03-10 | 2026-04-11 |
| 已完成 | 蔡总 | 8.02 | 9.42 | 老客 | 5.84 | COMANAGE | 4 | 26 | 2026-02-05 | 2026-03-11 | 2026-04-11 |
| 已完成 | 蔡总 | 7.66 | 9.42 | 老客 | 5.84 | COMANAGE | 4 | 26 | 2026-01-18 | 2026-01-22 | 2026-04-11 |
| 进行中 | 胡先生 | 8.08 | 8.08 | 老客 | 5.96 | MAIN | 5 | 20 | 2026-01-16 | 2026-04-13 | 2026-03-29 |
| 进行中 | 七七 | 8.08 | 10.00 | 老客 | 0.06 | POOL | 1 | 89 | 2025-09-09 | 2025-09-09 | 2025-08-26 |
| 进行中 | 小熊 | 7.87 | 7.90 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-04 | 2025-11-27 | 2026-01-17 |
| 进行中 | 蔡总 | 7.86 | 9.42 | 老客 | 5.84 | COMANAGE | 4 | 26 | 2026-04-08 | 2026-04-10 | 2026-04-11 |
| 进行中 | 林总 | 7.85 | 10.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-02 | 2026-01-29 | 2026-03-10 |
| 进行中 | 孙总 | 7.69 | 8.27 | 老客 | 0.17 | POOL | 1 | 80 | 2026-01-23 | 2026-02-03 | 2026-01-26 |
| 进行中 | T | 7.42 | 0.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-25 | 2025-11-09 | 2026-04-01 |
| 进行中 | 高冷 | 7.29 | 9.23 | 老客 | 0.01 | POOL | 1 | 89 | 2025-08-30 | 2025-09-14 | 2025-08-30 |
| 进行中 | 林先生 | 7.05 | 9.56 | 老客 | 0.01 | POOL | 1 | 89 | 2025-11-25 | 2026-01-14 | 2025-12-29 |
**优先召回**9
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 陈先生 | 7.02 | 7.02 | 老客 | 0.00 | POOL | 1 | 89 | 2026-04-12 | 2026-04-12 | 2026-03-13 |
| 进行中 | 许先生 | 6.85 | 6.48 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-17 | 2025-10-01 | 2025-08-29 |
| 进行中 | 胡先生 | 6.80 | 5.06 | 老客 | 0.01 | POOL | 1 | 89 | 2025-11-26 | 2026-01-09 | 2025-12-01 |
| 进行中 | 黄先生 | 5.28 | 6.00 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-19 | 2025-11-14 | 2026-03-21 |
| 进行中 | 陶 | 5.25 | 4.43 | 老客 | 0.00 | POOL | 1 | 89 | 2025-10-19 | 2025-11-08 | 2025-10-27 |
| 进行中 | 清 | 5.14 | 5.68 | 老客 | 2.84 | MAIN | 1 | 23 | 2026-03-21 | 2026-04-10 | 2026-03-22 |
| 进行中 | 邓飛 | 5.14 | 4.82 | 老客 | 0.00 | POOL | 1 | 89 | 2025-08-21 | 2025-09-28 | 2026-04-05 |
| 进行中 | 陈先生 | 5.10 | 5.10 | 老客 | 0.00 | POOL | 1 | 89 | 2025-09-03 | 2026-04-12 | 2025-10-18 |
| 进行中 | 游 | 4.83 | 4.83 | 老客 | 0.00 | POOL | 1 | 89 | 2025-11-29 | 2026-04-12 | 2025-12-13 |
**客户回访**3
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 已过期 | 蔡总 | 5.84 | COMANAGE | 4 | 2026-01-22 | 2026-01-25 | 2026-04-11 | - |
| 已过期 | 蔡总 | 5.84 | COMANAGE | 4 | 2026-03-11 | 2026-03-13 | 2026-04-11 | - |
| 已过期 | 轩哥 | 10.00 | MAIN | 40 | 2026-03-10 | 2026-03-13 | 2026-04-11 | - |
**关系构建**11
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 10.00 | MAIN | 40 | 2 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 夏 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-30 | 2025-09-30 | 2025-11-09 |
| 进行中 | 蔡先生 | 3.86 | MAIN | 1 | 9 | 0.00 | 0.00 | 2026-04-04 | 2026-04-04 | 2026-04-04 |
| 进行中 | 张先生 | 2.75 | POOL | 2 | 21 | 0.00 | 0.00 | 2025-12-15 | 2026-03-23 | 2026-04-10 |
| 进行中 | 君姐 | 6.64 | MAIN | 5 | 19 | 0.00 | 0.00 | 2026-03-09 | 2026-03-09 | 2026-04-12 |
| 进行中 | 罗超 | 0.00 | POOL | 1 | 84 | 0.00 | 0.00 | 2026-01-19 | 2026-01-19 | 2026-01-19 |
| 进行中 | 江先生 | 0.15 | POOL | 2 | 83 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-01 |
| 进行中 | 小燕 | 0.13 | POOL | 1 | 73 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 罗先生 | 0.01 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-03-25 |
| 进行中 | 葛先生 | 0.00 | POOL | 1 | 84 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 叶总 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2025-10-28 |
### 小燕【在职】(进行中 6 / 已完成 3 / 共 12
**高优先召回**3
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 葛先生 | 8.62 | 0.00 | 老客 | 10.00 | MAIN | 44 | 2 | 2025-11-15 | 2026-03-05 | 2026-04-12 |
| 已完成 | 小燕 | 7.66 | 0.00 | 老客 | 9.35 | MAIN | 15 | 19 | 2025-11-17 | 2026-01-28 | 2026-04-12 |
| 已完成 | 小燕 | 7.05 | 0.00 | 老客 | 9.35 | MAIN | 15 | 19 | 2026-02-27 | 2026-03-21 | 2026-04-12 |
**客户回访**3
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 已过期 | 小燕 | 9.35 | MAIN | 15 | 2026-01-28 | 2026-01-31 | 2026-04-12 | - |
| 已过期 | 葛先生 | 10.00 | MAIN | 44 | 2026-03-05 | 2026-03-07 | 2026-04-12 | - |
| 已过期 | 小燕 | 9.35 | MAIN | 15 | 2026-03-21 | 2026-03-24 | 2026-04-12 | - |
**关系构建**6
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 葛先生 | 10.00 | MAIN | 44 | 2 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 小燕 | 9.35 | MAIN | 15 | 19 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 梅 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-11-22 | 2025-11-22 | 2026-04-06 |
| 进行中 | 轩哥 | 0.98 | POOL | 1 | 38 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 罗先生 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-03-25 |
| 进行中 | 蔡总 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
### 盈盈【在职】(进行中 11 / 已完成 0 / 共 11
**高优先召回**3
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 东哥 | 10.00 | - | 10.00 | 4.30 | COMANAGE | 1 | 20 | 2026-03-23 | 2026-04-01 | 2026-03-24 |
| 进行中 | 林总 | 8.51 | 10.00 | 老客 | 1.35 | MAIN | 1 | 34 | 2026-03-10 | 2026-03-25 | 2026-03-10 |
| 进行中 | 蔡总 | 7.86 | 9.42 | 老客 | 5.75 | COMANAGE | 3 | 21 | 2026-04-07 | 2026-04-10 | 2026-04-11 |
**优先召回**2
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 黄先生 | 4.70 | - | 4.70 | 2.59 | MAIN | 1 | 18 | 2026-03-26 | 2026-04-12 | 2026-03-26 |
| 进行中 | 歌神 | 4.09 | 4.09 | 老客 | 2.49 | MAIN | 1 | 22 | 2026-03-22 | 2026-04-12 | 2026-03-29 |
**关系构建**6
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 8.34 | POOL | 5 | 12 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 陈腾鑫 | 4.36 | MAIN | 3 | 28 | 0.00 | 0.00 | 2026-03-14 | 2026-03-14 | 2026-04-05 |
| 进行中 | 孙先生 | 2.73 | MAIN | 1 | 20 | 0.00 | 0.00 | 2026-03-24 | 2026-03-24 | 2026-04-11 |
| 进行中 | 胡先生 | 1.41 | POOL | 1 | 32 | 0.00 | 0.00 | 2026-03-12 | 2026-03-12 | 2026-03-29 |
| 进行中 | 小燕 | 2.07 | POOL | 1 | 22 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 葛先生 | 1.52 | POOL | 1 | 27 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
### 北北【在职】(进行中 9 / 已完成 0 / 共 9
**优先召回**1
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 阿亮 | 4.06 | 4.06 | 老客 | 3.65 | MAIN | 2 | 22 | 2026-03-20 | 2026-04-12 | 2026-03-28 |
**关系构建**8
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 林先生 | 2.08 | MAIN | 1 | 27 | 0.00 | 0.00 | 2026-03-17 | 2026-03-17 | 2026-03-17 |
| 进行中 | 陈腾鑫 | 5.06 | MAIN | 3 | 18 | 0.00 | 0.00 | 2026-03-16 | 2026-03-16 | 2026-04-05 |
| 进行中 | 昌哥 | 3.92 | COMANAGE | 1 | 3 | 0.00 | 0.00 | 2026-04-10 | 2026-04-10 | 2026-04-10 |
| 进行中 | 张先生 | 7.94 | MAIN | 4 | 3 | 0.00 | 0.00 | 2026-03-21 | 2026-03-21 | 2026-04-10 |
| 进行中 | 罗先生 | 2.87 | COMANAGE | 1 | 11 | 0.00 | 0.00 | 2026-04-02 | 2026-04-02 | 2026-04-03 |
| 进行中 | 李先生 | 5.84 | MAIN | 2 | 2 | 0.00 | 0.00 | 2026-03-17 | 2026-03-17 | 2026-04-11 |
| 进行中 | 歌神 | 1.57 | COMANAGE | 1 | 23 | 0.00 | 0.00 | 2026-03-21 | 2026-03-21 | 2026-03-29 |
| 进行中 | 葛先生 | 2.29 | POOL | 1 | 25 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
### 喵喵【在职】(进行中 7 / 已完成 0 / 共 7
**优先召回**1
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 小宇 | 6.88 | 6.88 | 老客 | 3.61 | MAIN | 1 | 4 | 2026-04-09 | 2026-04-12 | 2026-04-12 |
**关系构建**6
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 6.65 | POOL | 2 | 3 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 羊 | 4.99 | MAIN | 1 | 3 | 0.00 | 0.00 | 2026-04-10 | 2026-04-10 | 2026-04-10 |
| 进行中 | H | 5.02 | MAIN | 1 | 2 | 0.00 | 0.00 | 2026-04-11 | 2026-04-11 | 2026-04-12 |
| 进行中 | 梅 | 4.38 | MAIN | 1 | 7 | 0.00 | 0.00 | 2026-04-06 | 2026-04-06 | 2026-04-06 |
| 进行中 | 陈腾鑫 | 3.79 | COMANAGE | 1 | 8 | 0.00 | 0.00 | 2026-04-05 | 2026-04-05 | 2026-04-05 |
| 进行中 | 李先生 | 3.08 | COMANAGE | 1 | 12 | 0.00 | 0.00 | 2026-04-01 | 2026-04-01 | 2026-04-11 |
### 琪琪【在职】(进行中 5 / 已完成 0 / 共 5
**高优先召回**1
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 潘先生 | 7.03 | 8.36 | 老客 | 2.08 | COMANAGE | 1 | 24 | 2026-03-20 | 2026-04-10 | 2026-03-20 |
**关系构建**4
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 葛先生 | 4.26 | POOL | 2 | 21 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 林先生 | 1.56 | MAIN | 1 | 34 | 0.00 | 0.00 | 2026-03-10 | 2026-03-10 | 2026-03-17 |
| 进行中 | 张先生 | 3.45 | POOL | 2 | 24 | 0.00 | 0.00 | 2026-03-15 | 2026-03-15 | 2026-04-10 |
| 进行中 | 小燕 | 2.58 | POOL | 1 | 21 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
### 小贝【在职】(进行中 3 / 已完成 1 / 共 5
**高优先召回**1
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 已完成 | 蔡总 | 10.00 | 9.42 | 老客 | 1.52 | POOL | 1 | 34 | 2026-03-10 | 2026-03-11 | 2026-04-11 |
**客户回访**1
| 状态 | 客户 | RS | OS | 服务次数 | 创建 | 更新 | 最后到店 | 完成/过期 |
|------|------|----|-----|----------|------|------|----------|-----------|
| 已过期 | 蔡总 | 1.52 | POOL | 1 | 2026-03-11 | 2026-03-13 | 2026-04-11 | - |
**关系构建**3
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 蔡总 | 1.52 | POOL | 1 | 34 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 轩哥 | 1.46 | POOL | 1 | 38 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 陈腾鑫 | 0.88 | POOL | 1 | 38 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-05 |
### 西西【在职】(进行中 4 / 已完成 0 / 共 4
**高优先召回**3
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 东哥 | 10.00 | - | 10.00 | 6.96 | MAIN | 4 | 20 | 2026-03-15 | 2026-04-01 | 2026-03-24 |
| 进行中 | 蔡总 | 9.42 | 9.42 | 老客 | 1.64 | POOL | 1 | 28 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 胡先生 | 8.08 | 8.08 | 老客 | 2.20 | POOL | 1 | 28 | 2026-03-16 | 2026-04-12 | 2026-03-29 |
**关系构建**1
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 君姐 | 3.27 | COMANAGE | 1 | 12 | 0.00 | 0.00 | 2026-04-01 | 2026-04-01 | 2026-04-12 |
### 小柳【在职】(进行中 3 / 已完成 0 / 共 3
**关系构建**3
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 昌哥 | 5.79 | MAIN | 2 | 3 | 0.00 | 0.00 | 2026-03-27 | 2026-03-27 | 2026-04-10 |
| 进行中 | 艾宇民 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2025-09-12 | 2025-09-12 | 2026-03-08 |
| 进行中 | 轩哥 | 0.00 | POOL | 1 | 89 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
### 点点【在职】(进行中 3 / 已完成 0 / 共 3
**关系构建**3
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | H | 5.02 | MAIN | 1 | 2 | 0.00 | 0.00 | 2026-04-11 | 2026-04-11 | 2026-04-12 |
| 进行中 | 轩哥 | 4.00 | POOL | 1 | 3 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | 张先生 | 5.13 | COMANAGE | 2 | 10 | 0.00 | 0.00 | 2026-04-01 | 2026-04-01 | 2026-04-10 |
### 闹闹【在职】(进行中 3 / 已完成 0 / 共 3
**高优先召回**1
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 潘先生 | 7.03 | 8.36 | 老客 | 2.08 | COMANAGE | 1 | 24 | 2026-03-20 | 2026-04-10 | 2026-03-20 |
**关系构建**2
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 葛先生 | 4.77 | POOL | 1 | 6 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
| 进行中 | 轩哥 | 1.84 | POOL | 1 | 25 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
### 一一【在职】(进行中 3 / 已完成 0 / 共 3
**优先召回**1
| 状态 | 客户 | 优先级 | WBI | NCI | RS | OS | 服务次数 | 末次距今 | 创建 | 更新 | 最后到店 |
|------|------|--------|-----|-----|----|-----|----------|----------|------|------|----------|
| 进行中 | 小宇 | 6.88 | 6.88 | 老客 | 4.09 | MAIN | 1 | 4 | 2026-04-09 | 2026-04-12 | 2026-04-12 |
**关系构建**2
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 羊 | 4.76 | MAIN | 1 | 3 | 0.00 | 0.00 | 2026-04-10 | 2026-04-10 | 2026-04-10 |
| 进行中 | 葛先生 | 4.88 | POOL | 1 | 5 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
### 吱吱【在职】(进行中 2 / 已完成 0 / 共 2
**关系构建**2
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 4.03 | POOL | 1 | 3 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
| 进行中 | H | 3.53 | COMANAGE | 1 | 7 | 0.00 | 0.00 | 2026-04-06 | 2026-04-06 | 2026-04-12 |
### 小野【在职】(进行中 2 / 已完成 0 / 共 2
**关系构建**2
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 小燕 | 5.78 | COMANAGE | 2 | 6 | 0.00 | 0.00 | 2026-04-08 | 2026-04-08 | 2026-04-12 |
| 进行中 | 葛先生 | 5.90 | POOL | 2 | 4 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-12 |
### 饱饱【在职】(进行中 1 / 已完成 0 / 共 1
**关系构建**1
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 2.02 | POOL | 1 | 29 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
### 翠翠【在职】(进行中 1 / 已完成 0 / 共 1
**关系构建**1
| 状态 | 客户 | RS | OS | 服务次数 | 末次距今 | MS | ML | 创建 | 更新 | 最后到店 |
|------|------|----|-----|----------|----------|-----|-----|------|------|----------|
| 进行中 | 轩哥 | 2.10 | POOL | 1 | 14 | 0.00 | 0.00 | 2026-04-12 | 2026-04-12 | 2026-04-11 |
## 三、趋势分析
### 3.1 月度创建/完成/过期
| 月份 | 创建 | 完成 | 过期 |
|------|------|------|------|
| 2025-08-01 | 51 | 13 | 2 |
| 2025-09-01 | 33 | 1 | 5 |
| 2025-10-01 | 23 | 4 | 2 |
| 2025-11-01 | 45 | 8 | 6 |
| 2025-12-01 | 13 | 1 | 0 |
| 2026-01-01 | 24 | 3 | 7 |
| 2026-02-01 | 9 | 9 | 0 |
| 2026-03-01 | 47 | 1 | 14 |
| 2026-04-01 | 77 | 0 | 1 |
### 3.2 召回完成率
- 召回任务总数156
- 已完成40
- 完成率:**25.6%**
### 3.3 助教任务量排行(进行中)
| 排名 | 助教 | 高优先 | 优先 | 回访 | 关系 | 合计 |
|------|------|--------|------|------|------|------|
| 1 | 婉婉 | 13 | 13 | 2 | 14 | 42 |
| 2 | 小柔 | 7 | 7 | 1 | 16 | 31 |
| 3 | 佳怡 | 10 | 8 | 0 | 11 | 29 |
| 4 | 七七 | 9 | 9 | 0 | 11 | 29 |
| 5 | 涛涛 | 4 | 10 | 0 | 13 | 27 |
| 6 | 璇子 | 10 | 3 | 0 | 11 | 24 |
| 7 | 盈盈 | 3 | 2 | 0 | 6 | 11 |
| 8 | 北北 | 0 | 1 | 0 | 8 | 9 |
| 9 | 喵喵 | 0 | 1 | 0 | 6 | 7 |
| 10 | 小燕 | 0 | 0 | 0 | 6 | 6 |
| 11 | 琪琪 | 1 | 0 | 0 | 4 | 5 |
| 12 | 西西 | 3 | 0 | 0 | 1 | 4 |
| 13 | 小柳 | 0 | 0 | 0 | 3 | 3 |
| 14 | 点点 | 0 | 0 | 0 | 3 | 3 |
| 15 | 闹闹 | 1 | 0 | 0 | 2 | 3 |
| 16 | 一一 | 0 | 1 | 0 | 2 | 3 |
| 17 | 小贝 | 0 | 0 | 0 | 3 | 3 |
| 18 | 吱吱 | 0 | 0 | 0 | 2 | 2 |
| 19 | 小野 | 0 | 0 | 0 | 2 | 2 |
| 20 | 饱饱 | 0 | 0 | 0 | 1 | 1 |
| 21 | 翠翠 | 0 | 0 | 0 | 1 | 1 |

View File

@@ -146,7 +146,7 @@ cd apps/admin-web && pnpm exec vitest run
cd apps/admin-web && pnpm exec tsc --noEmit
# 属性测试
cd C:\NeoZQYY && pytest tests/ -v
cd C:\Project\NeoZQYY && pytest tests/ -v
```
## 内联注释决策

View File

@@ -7,7 +7,7 @@
## 变更概述
将单一 ETL 仓库C:\ZQYY\FQ-ETL迁移为 Monorepo 单体仓库C:\NeoZQYY整合 ETL、后端、小程序、GUI 等子项目。一次性搬迁策略,不保留 Git 历史。
将单一 ETL 仓库C:\ZQYY\FQ-ETL迁移为 Monorepo 单体仓库C:\Project\NeoZQYY整合 ETL、后端、小程序、GUI 等子项目。一次性搬迁策略,不保留 Git 历史。
## 变更清单
@@ -76,7 +76,7 @@
## 后续建议
1. 在 C:\NeoZQYY\apps\etl\pipelines\feiqiu\ 下运行 `pytest tests/unit` 确认测试通过
1. 在 C:\Project\NeoZQYY\apps\etl\pipelines\feiqiu\ 下运行 `pytest tests/unit` 确认测试通过
2. 修复源仓库已有的 5 个失败测试
3. DB Schema DDL 在测试环境验证后再应用到生产
4. 首次 `git add . && git commit` 建立基线

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / appRLS 视图层)
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -439,10 +439,8 @@ SELECT table_fee_log_id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
-- CHANGE 2026-04-08 | DISTINCT ON 只取每对 (assistant, member) 最新快照
CREATE OR REPLACE VIEW app.v_dws_assistant_customer_stats AS
SELECT DISTINCT ON (assistant_id, member_id)
id,
SELECT DISTINCT ON (assistant_id, member_id) id,
site_id,
tenant_id,
assistant_id,
@@ -946,7 +944,7 @@ SELECT intimacy_id,
;
CREATE OR REPLACE VIEW app.v_dws_member_assistant_relation_index AS
SELECT relation_id,
SELECT DISTINCT ON (member_id, assistant_id) relation_id,
site_id,
tenant_id,
member_id,
@@ -974,15 +972,21 @@ SELECT relation_id,
ml_display,
calc_time,
created_at,
updated_at
updated_at,
recall_created_total,
recall_completed_total,
follow_up_created_total,
follow_up_completed_total,
total_created,
total_completed,
stat_date
FROM dws.dws_member_assistant_relation_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, assistant_id, stat_date DESC;
;
-- CHANGE 2026-04-08 | DISTINCT ON 只取每个会员最新快照,避免多 stat_date 行膨胀
CREATE OR REPLACE VIEW app.v_dws_member_consumption_summary AS
SELECT DISTINCT ON (member_id)
id,
SELECT DISTINCT ON (member_id) id,
site_id,
tenant_id,
member_id,
@@ -1034,7 +1038,7 @@ SELECT DISTINCT ON (member_id)
;
CREATE OR REPLACE VIEW app.v_dws_member_newconv_index AS
SELECT newconv_id,
SELECT DISTINCT ON (member_id) newconv_id,
site_id,
tenant_id,
member_id,
@@ -1071,9 +1075,11 @@ SELECT newconv_id,
calc_time,
calc_version,
created_at,
updated_at
updated_at,
stat_date
FROM dws.dws_member_newconv_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, stat_date DESC;
;
CREATE OR REPLACE VIEW app.v_dws_member_project_tag AS
@@ -1160,7 +1166,7 @@ SELECT id,
;
CREATE OR REPLACE VIEW app.v_dws_member_winback_index AS
SELECT winback_id,
SELECT DISTINCT ON (member_id) winback_id,
site_id,
tenant_id,
member_id,
@@ -1195,9 +1201,11 @@ SELECT winback_id,
updated_at,
overdue_cdf_p,
ideal_interval_days,
ideal_next_visit_date
ideal_next_visit_date,
stat_date
FROM dws.dws_member_winback_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint)
ORDER BY member_id, stat_date DESC;
;
CREATE OR REPLACE VIEW app.v_dws_order_summary AS

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / core跨门店标准化维度/事实)
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / dwd明细数据层
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / dws汇总数据层
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -722,7 +722,8 @@ CREATE TABLE dws.dws_member_assistant_relation_index (
follow_up_created_total integer DEFAULT 0 NOT NULL,
follow_up_completed_total integer DEFAULT 0 NOT NULL,
total_created integer DEFAULT 0 NOT NULL,
total_completed integer DEFAULT 0 NOT NULL
total_completed integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_member_consumption_summary (
@@ -789,7 +790,6 @@ CREATE TABLE dws.dws_member_newconv_index (
t_r numeric(6,2),
t_a numeric(6,2),
visits_14d integer DEFAULT 0 NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
visits_60d integer DEFAULT 0 NOT NULL,
visits_total integer DEFAULT 0 NOT NULL,
spend_30d numeric(14,2) DEFAULT 0 NOT NULL,
@@ -812,7 +812,9 @@ CREATE TABLE dws.dws_member_newconv_index (
calc_time timestamp with time zone DEFAULT now() NOT NULL,
calc_version integer DEFAULT 1 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
updated_at timestamp with time zone DEFAULT now() NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_member_project_tag (
@@ -908,7 +910,6 @@ CREATE TABLE dws.dws_member_winback_index (
t_r numeric(6,2),
t_a numeric(6,2),
visits_14d integer DEFAULT 0 NOT NULL,
visits_30d integer DEFAULT 0 NOT NULL,
visits_60d integer DEFAULT 0 NOT NULL,
visits_total integer DEFAULT 0 NOT NULL,
spend_30d numeric(14,2) DEFAULT 0 NOT NULL,
@@ -929,7 +930,9 @@ CREATE TABLE dws.dws_member_winback_index (
updated_at timestamp with time zone DEFAULT now() NOT NULL,
overdue_cdf_p numeric(10,4),
ideal_interval_days numeric(10,2),
ideal_next_visit_date date
ideal_next_visit_date date,
visits_30d integer DEFAULT 0 NOT NULL,
stat_date date DEFAULT CURRENT_DATE NOT NULL
);
CREATE TABLE dws.dws_ml_manual_order_alloc (
@@ -1093,18 +1096,18 @@ ALTER TABLE dws.dws_index_percentile_history ADD CONSTRAINT uk_dws_index_percent
ALTER TABLE dws.dws_member_assistant_intimacy ADD CONSTRAINT dws_member_assistant_intimacy_pkey PRIMARY KEY (intimacy_id);
ALTER TABLE dws.dws_member_assistant_intimacy ADD CONSTRAINT uk_dws_member_assistant_intimacy UNIQUE (site_id, member_id, assistant_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT dws_member_assistant_relation_index_pkey PRIMARY KEY (relation_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id);
ALTER TABLE dws.dws_member_assistant_relation_index ADD CONSTRAINT uk_dws_member_assistant_relation_index UNIQUE (site_id, member_id, assistant_id, stat_date);
ALTER TABLE dws.dws_member_consumption_summary ADD CONSTRAINT dws_member_consumption_summary_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_consumption_summary ADD CONSTRAINT uk_dws_member_consumption UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT dws_member_newconv_index_pkey PRIMARY KEY (newconv_id);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_newconv_index ADD CONSTRAINT uk_dws_member_newconv UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT pk_dws_member_project_tag PRIMARY KEY (id);
ALTER TABLE dws.dws_member_project_tag ADD CONSTRAINT uk_dws_member_project_tag UNIQUE (site_id, member_id, time_window, category_code);
ALTER TABLE dws.dws_member_spending_power_index ADD CONSTRAINT dws_member_spending_power_index_pkey PRIMARY KEY (spi_id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT dws_member_visit_detail_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_member_visit_detail ADD CONSTRAINT uk_dws_member_visit UNIQUE (site_id, member_id, order_settle_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT dws_member_winback_index_pkey PRIMARY KEY (winback_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id);
ALTER TABLE dws.dws_member_winback_index ADD CONSTRAINT uk_dws_member_winback UNIQUE (site_id, member_id, stat_date);
ALTER TABLE dws.dws_ml_manual_order_alloc ADD CONSTRAINT dws_ml_manual_order_alloc_pkey PRIMARY KEY (alloc_id);
ALTER TABLE dws.dws_ml_manual_order_alloc ADD CONSTRAINT uk_dws_ml_manual_order_alloc UNIQUE (site_id, external_id, assistant_id);
ALTER TABLE dws.dws_ml_manual_order_source ADD CONSTRAINT dws_ml_manual_order_source_pkey PRIMARY KEY (source_id);
@@ -1164,12 +1167,13 @@ CREATE INDEX idx_dws_percentile_history ON dws.dws_index_percentile_history USIN
CREATE INDEX idx_dws_intimacy_assistant ON dws.dws_member_assistant_intimacy USING btree (site_id, assistant_id, display_score DESC);
CREATE INDEX idx_dws_intimacy_member ON dws.dws_member_assistant_intimacy USING btree (site_id, member_id, display_score DESC);
CREATE INDEX idx_dws_relation_assistant ON dws.dws_member_assistant_relation_index USING btree (site_id, assistant_id, rs_display DESC);
CREATE INDEX idx_dws_relation_calc_time ON dws.dws_member_assistant_relation_index USING btree (calc_time);
CREATE INDEX idx_dws_relation_member ON dws.dws_member_assistant_relation_index USING btree (site_id, member_id, os_share DESC);
CREATE INDEX idx_dws_relation_stat_date ON dws.dws_member_assistant_relation_index USING btree (site_id, stat_date);
CREATE INDEX idx_dws_member_consumption_date ON dws.dws_member_consumption_summary USING btree (stat_date);
CREATE INDEX idx_dws_member_consumption_member ON dws.dws_member_consumption_summary USING btree (member_id, stat_date);
CREATE INDEX idx_dws_member_consumption_tier ON dws.dws_member_consumption_summary USING btree (customer_tier);
CREATE INDEX idx_dws_newconv_display ON dws.dws_member_newconv_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_dws_newconv_stat_date ON dws.dws_member_newconv_index USING btree (site_id, stat_date);
CREATE INDEX idx_mpt_site_window_tagged ON dws.dws_member_project_tag USING btree (site_id, time_window) WHERE (is_tagged = true);
CREATE INDEX idx_spi_display_score ON dws.dws_member_spending_power_index USING btree (site_id, display_score DESC);
CREATE UNIQUE INDEX idx_spi_site_member ON dws.dws_member_spending_power_index USING btree (site_id, member_id);
@@ -1177,6 +1181,7 @@ CREATE INDEX idx_dws_member_visit_date ON dws.dws_member_visit_detail USING btre
CREATE INDEX idx_dws_member_visit_member ON dws.dws_member_visit_detail USING btree (member_id, visit_date);
CREATE INDEX idx_dws_member_visit_order ON dws.dws_member_visit_detail USING btree (order_settle_id);
CREATE INDEX idx_dws_winback_display ON dws.dws_member_winback_index USING btree (site_id, display_score DESC);
CREATE INDEX idx_dws_winback_stat_date ON dws.dws_member_winback_index USING btree (site_id, stat_date);
CREATE INDEX idx_dws_ml_alloc_member_assistant ON dws.dws_ml_manual_order_alloc USING btree (site_id, member_id, assistant_id);
CREATE INDEX idx_dws_ml_alloc_scope ON dws.dws_ml_manual_order_alloc USING btree (site_id, biz_date);
CREATE INDEX idx_dws_ml_source_external ON dws.dws_ml_manual_order_source USING btree (site_id, external_id);
@@ -1285,27 +1290,27 @@ SELECT id,
;
CREATE OR REPLACE VIEW dws.v_member_recall_priority AS
SELECT dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id,
dws_member_winback_index.member_id,
SELECT w.site_id,
w.tenant_id,
w.member_id,
'WBI'::character varying(10) AS index_type,
dws_member_winback_index.status,
dws_member_winback_index.segment,
dws_member_winback_index.member_create_time,
dws_member_winback_index.first_visit_time,
dws_member_winback_index.last_visit_time,
dws_member_winback_index.last_recharge_time,
dws_member_winback_index.t_v,
dws_member_winback_index.t_r,
dws_member_winback_index.t_a,
dws_member_winback_index.visits_14d,
dws_member_winback_index.visits_30d,
dws_member_winback_index.visits_60d,
dws_member_winback_index.visits_total,
dws_member_winback_index.spend_30d,
dws_member_winback_index.spend_180d,
dws_member_winback_index.sv_balance,
dws_member_winback_index.recharge_60d_amt,
w.status,
w.segment,
w.member_create_time,
w.first_visit_time,
w.last_visit_time,
w.last_recharge_time,
w.t_v,
w.t_r,
w.t_a,
w.visits_14d,
w.visits_30d,
w.visits_60d,
w.visits_total,
w.spend_30d,
w.spend_180d,
w.sv_balance,
w.recharge_60d_amt,
NULL::numeric(10,4) AS need_new,
NULL::numeric(10,4) AS salvage_new,
NULL::numeric(10,4) AS recharge_new,
@@ -1313,49 +1318,127 @@ SELECT dws_member_winback_index.site_id,
NULL::numeric(10,4) AS welcome_new,
NULL::numeric(14,6) AS raw_score_welcome,
NULL::numeric(14,6) AS raw_score_convert,
dws_member_winback_index.raw_score,
w.raw_score,
NULL::numeric(4,2) AS display_score_welcome,
NULL::numeric(4,2) AS display_score_convert,
dws_member_winback_index.display_score,
dws_member_winback_index.last_wechat_touch_time,
dws_member_winback_index.calc_time
FROM dws.dws_member_winback_index
w.display_score,
w.last_wechat_touch_time,
w.calc_time
FROM ( SELECT DISTINCT ON (dws_member_winback_index.site_id, dws_member_winback_index.member_id) dws_member_winback_index.winback_id,
dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id,
dws_member_winback_index.member_id,
dws_member_winback_index.status,
dws_member_winback_index.segment,
dws_member_winback_index.member_create_time,
dws_member_winback_index.first_visit_time,
dws_member_winback_index.last_visit_time,
dws_member_winback_index.last_recharge_time,
dws_member_winback_index.t_v,
dws_member_winback_index.t_r,
dws_member_winback_index.t_a,
dws_member_winback_index.visits_14d,
dws_member_winback_index.visits_60d,
dws_member_winback_index.visits_total,
dws_member_winback_index.spend_30d,
dws_member_winback_index.spend_180d,
dws_member_winback_index.sv_balance,
dws_member_winback_index.recharge_60d_amt,
dws_member_winback_index.interval_count,
dws_member_winback_index.overdue_old,
dws_member_winback_index.drop_old,
dws_member_winback_index.recharge_old,
dws_member_winback_index.value_old,
dws_member_winback_index.raw_score,
dws_member_winback_index.display_score,
dws_member_winback_index.last_wechat_touch_time,
dws_member_winback_index.calc_time,
dws_member_winback_index.calc_version,
dws_member_winback_index.created_at,
dws_member_winback_index.updated_at,
dws_member_winback_index.overdue_cdf_p,
dws_member_winback_index.ideal_interval_days,
dws_member_winback_index.ideal_next_visit_date,
dws_member_winback_index.visits_30d,
dws_member_winback_index.stat_date
FROM dws.dws_member_winback_index
ORDER BY dws_member_winback_index.site_id, dws_member_winback_index.member_id, dws_member_winback_index.stat_date DESC) w
UNION ALL
SELECT dws_member_newconv_index.site_id,
dws_member_newconv_index.tenant_id,
dws_member_newconv_index.member_id,
SELECT n.site_id,
n.tenant_id,
n.member_id,
'NCI'::character varying(10) AS index_type,
dws_member_newconv_index.status,
dws_member_newconv_index.segment,
dws_member_newconv_index.member_create_time,
dws_member_newconv_index.first_visit_time,
dws_member_newconv_index.last_visit_time,
dws_member_newconv_index.last_recharge_time,
dws_member_newconv_index.t_v,
dws_member_newconv_index.t_r,
dws_member_newconv_index.t_a,
dws_member_newconv_index.visits_14d,
dws_member_newconv_index.visits_30d,
dws_member_newconv_index.visits_60d,
dws_member_newconv_index.visits_total,
dws_member_newconv_index.spend_30d,
dws_member_newconv_index.spend_180d,
dws_member_newconv_index.sv_balance,
dws_member_newconv_index.recharge_60d_amt,
dws_member_newconv_index.need_new,
dws_member_newconv_index.salvage_new,
dws_member_newconv_index.recharge_new,
dws_member_newconv_index.value_new,
dws_member_newconv_index.welcome_new,
dws_member_newconv_index.raw_score_welcome,
dws_member_newconv_index.raw_score_convert,
dws_member_newconv_index.raw_score,
dws_member_newconv_index.display_score_welcome,
dws_member_newconv_index.display_score_convert,
dws_member_newconv_index.display_score,
dws_member_newconv_index.last_wechat_touch_time,
dws_member_newconv_index.calc_time
FROM dws.dws_member_newconv_index;
n.status,
n.segment,
n.member_create_time,
n.first_visit_time,
n.last_visit_time,
n.last_recharge_time,
n.t_v,
n.t_r,
n.t_a,
n.visits_14d,
n.visits_30d,
n.visits_60d,
n.visits_total,
n.spend_30d,
n.spend_180d,
n.sv_balance,
n.recharge_60d_amt,
n.need_new,
n.salvage_new,
n.recharge_new,
n.value_new,
n.welcome_new,
n.raw_score_welcome,
n.raw_score_convert,
n.raw_score,
n.display_score_welcome,
n.display_score_convert,
n.display_score,
n.last_wechat_touch_time,
n.calc_time
FROM ( SELECT DISTINCT ON (dws_member_newconv_index.site_id, dws_member_newconv_index.member_id) dws_member_newconv_index.newconv_id,
dws_member_newconv_index.site_id,
dws_member_newconv_index.tenant_id,
dws_member_newconv_index.member_id,
dws_member_newconv_index.status,
dws_member_newconv_index.segment,
dws_member_newconv_index.member_create_time,
dws_member_newconv_index.first_visit_time,
dws_member_newconv_index.last_visit_time,
dws_member_newconv_index.last_recharge_time,
dws_member_newconv_index.t_v,
dws_member_newconv_index.t_r,
dws_member_newconv_index.t_a,
dws_member_newconv_index.visits_14d,
dws_member_newconv_index.visits_60d,
dws_member_newconv_index.visits_total,
dws_member_newconv_index.spend_30d,
dws_member_newconv_index.spend_180d,
dws_member_newconv_index.sv_balance,
dws_member_newconv_index.recharge_60d_amt,
dws_member_newconv_index.interval_count,
dws_member_newconv_index.need_new,
dws_member_newconv_index.salvage_new,
dws_member_newconv_index.recharge_new,
dws_member_newconv_index.value_new,
dws_member_newconv_index.welcome_new,
dws_member_newconv_index.raw_score_welcome,
dws_member_newconv_index.raw_score_convert,
dws_member_newconv_index.raw_score,
dws_member_newconv_index.display_score_welcome,
dws_member_newconv_index.display_score_convert,
dws_member_newconv_index.display_score,
dws_member_newconv_index.last_wechat_touch_time,
dws_member_newconv_index.calc_time,
dws_member_newconv_index.calc_version,
dws_member_newconv_index.created_at,
dws_member_newconv_index.updated_at,
dws_member_newconv_index.visits_30d,
dws_member_newconv_index.stat_date
FROM dws.dws_member_newconv_index
ORDER BY dws_member_newconv_index.site_id, dws_member_newconv_index.member_id, dws_member_newconv_index.stat_date DESC) n;
;
-- 物化视图

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / metaETL 调度元数据)
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- etl_feiqiu / ods原始数据层
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- FDW 跨库映射(在 zqyy_app 中执行)
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源db/fdw/setup_fdw.sql
-- =============================================================================

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- zqyy_app / auth用户认证与权限
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- zqyy_app / biz核心业务表任务/备注/触发器))
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================
@@ -16,11 +16,11 @@ CREATE SEQUENCE IF NOT EXISTS biz.cfg_task_generator_params_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.coach_task_history_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.coach_task_transfer_log_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.coach_tasks_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.recall_events_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.connectors_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS biz.dws_assistant_task_monthly_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.excel_upload_log_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.notes_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.recall_events_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.salary_adjustments_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.site_code_history_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS biz.sites_id_seq AS integer;
@@ -154,24 +154,13 @@ CREATE TABLE biz.coach_tasks (
abandon_reason text,
completed_at timestamp with time zone,
completed_task_type character varying(50),
completion_type character varying(10),
parent_task_id bigint,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
transfer_count integer DEFAULT 0 NOT NULL,
transferred_from bigint,
transferred_at timestamp with time zone
);
CREATE TABLE biz.recall_events (
id bigint DEFAULT nextval('biz.recall_events_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
assistant_id bigint NOT NULL,
member_id bigint NOT NULL,
pay_time timestamp with time zone NOT NULL,
task_id bigint,
task_type character varying(50),
created_at timestamp with time zone DEFAULT now()
transferred_at timestamp with time zone,
completion_type character varying(10)
);
CREATE TABLE biz.connectors (
@@ -232,6 +221,17 @@ CREATE TABLE biz.notes (
score smallint
);
CREATE TABLE biz.recall_events (
id bigint DEFAULT nextval('biz.recall_events_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
assistant_id bigint NOT NULL,
member_id bigint NOT NULL,
pay_time timestamp with time zone NOT NULL,
task_id bigint,
task_type character varying(50),
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE biz.salary_adjustments (
id bigint DEFAULT nextval('biz.salary_adjustments_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
@@ -354,8 +354,8 @@ ALTER TABLE biz.dws_assistant_task_monthly ADD CONSTRAINT dws_assistant_task_mon
ALTER TABLE biz.excel_upload_log ADD CONSTRAINT excel_upload_log_pkey PRIMARY KEY (id);
ALTER TABLE biz.notes ADD CONSTRAINT notes_task_id_fkey FOREIGN KEY (task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.notes ADD CONSTRAINT notes_pkey PRIMARY KEY (id);
ALTER TABLE biz.recall_events ADD CONSTRAINT recall_events_pkey PRIMARY KEY (id);
ALTER TABLE biz.recall_events ADD CONSTRAINT recall_events_task_id_fkey FOREIGN KEY (task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.recall_events ADD CONSTRAINT recall_events_pkey PRIMARY KEY (id);
ALTER TABLE biz.salary_adjustments ADD CONSTRAINT salary_adjustments_upload_batch_id_fkey FOREIGN KEY (upload_batch_id) REFERENCES biz.excel_upload_log(id);
ALTER TABLE biz.salary_adjustments ADD CONSTRAINT salary_adjustments_pkey PRIMARY KEY (id);
ALTER TABLE biz.site_code_history ADD CONSTRAINT site_code_history_pkey PRIMARY KEY (id);
@@ -400,8 +400,8 @@ CREATE INDEX idx_task_monthly_assistant ON biz.dws_assistant_task_monthly USING
CREATE INDEX idx_task_monthly_site_month ON biz.dws_assistant_task_monthly USING btree (site_id, stat_month DESC);
CREATE INDEX idx_excel_log_site ON biz.excel_upload_log USING btree (site_id, created_at DESC);
CREATE INDEX idx_notes_target ON biz.notes USING btree (site_id, target_type, target_id);
CREATE INDEX idx_salary_adj_assistant_month ON biz.salary_adjustments USING btree (assistant_id, salary_month);
CREATE UNIQUE INDEX idx_recall_events_site_assistant_member_day ON biz.recall_events USING btree (site_id, assistant_id, member_id, (date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')));
CREATE INDEX idx_recall_events_assistant_pay ON biz.recall_events USING btree (site_id, assistant_id, pay_time);
CREATE UNIQUE INDEX idx_recall_events_site_assistant_member_day ON biz.recall_events USING btree (site_id, assistant_id, member_id, date_trunc('day'::text, (pay_time AT TIME ZONE 'Asia/Shanghai'::text)));
CREATE INDEX idx_salary_adj_assistant_month ON biz.salary_adjustments USING btree (assistant_id, salary_month);
CREATE INDEX idx_salary_adj_site_month ON biz.salary_adjustments USING btree (site_id, salary_month);

View File

@@ -1,6 +1,6 @@
-- =============================================================================
-- zqyy_app / public小程序业务表
-- 生成日期2026-04-06
-- 生成日期2026-04-12
-- 来源:测试库(通过脚本自动导出)
-- =============================================================================

View File

@@ -38,18 +38,18 @@ export/
| 环境变量 | 默认值(开发机) | 对应目录 | 说明 |
|----------|------------------|----------|------|
| `EXPORT_ROOT` | `C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON` | `ETL-Connectors/feiqiu/JSON/` | ODS 抓取 JSON 落盘根目录 |
| `LOG_ROOT` | `C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS` | `ETL-Connectors/feiqiu/LOGS/` | ETL 运行日志 |
| `FETCH_ROOT` | `C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON` | `ETL-Connectors/feiqiu/JSON/` | FETCH_ONLY 模式 JSON 输出(通常与 EXPORT_ROOT 相同) |
| `ETL_REPORT_ROOT` | `C:/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS` | `ETL-Connectors/feiqiu/REPORTS/` | ETL 质检/完整性报告 |
| `SYSTEM_ANALYZE_ROOT` | `C:/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis` | `SYSTEM/REPORTS/dataflow_analysis/` | 数据流结构分析报告 |
| `FIELD_AUDIT_ROOT` | `C:/NeoZQYY/export/SYSTEM/REPORTS/field_audit` | `SYSTEM/REPORTS/field_audit/` | 字段排查报告 |
| `FULL_DATAFLOW_DOC_ROOT` | `C:/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc` | `SYSTEM/REPORTS/full_dataflow_doc/` | 全链路数据流文档 |
| `API_SAMPLE_CACHE_ROOT` | `C:/NeoZQYY/export/SYSTEM/CACHE/api_samples` | `SYSTEM/CACHE/api_samples/` | API 样本缓存 |
| `SYSTEM_LOG_ROOT` | `C:/NeoZQYY/export/SYSTEM/LOGS` | `SYSTEM/LOGS/` | 系统级运维日志 |
| `BACKEND_LOG_ROOT` | `C:/NeoZQYY/export/BACKEND/LOGS` | `BACKEND/LOGS/` | 后端结构化日志 |
| `EXPORT_ROOT` | `C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON` | `ETL-Connectors/feiqiu/JSON/` | ODS 抓取 JSON 落盘根目录 |
| `LOG_ROOT` | `C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS` | `ETL-Connectors/feiqiu/LOGS/` | ETL 运行日志 |
| `FETCH_ROOT` | `C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON` | `ETL-Connectors/feiqiu/JSON/` | FETCH_ONLY 模式 JSON 输出(通常与 EXPORT_ROOT 相同) |
| `ETL_REPORT_ROOT` | `C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS` | `ETL-Connectors/feiqiu/REPORTS/` | ETL 质检/完整性报告 |
| `SYSTEM_ANALYZE_ROOT` | `C:/Project/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis` | `SYSTEM/REPORTS/dataflow_analysis/` | 数据流结构分析报告 |
| `FIELD_AUDIT_ROOT` | `C:/Project/NeoZQYY/export/SYSTEM/REPORTS/field_audit` | `SYSTEM/REPORTS/field_audit/` | 字段排查报告 |
| `FULL_DATAFLOW_DOC_ROOT` | `C:/Project/NeoZQYY/export/SYSTEM/REPORTS/full_dataflow_doc` | `SYSTEM/REPORTS/full_dataflow_doc/` | 全链路数据流文档 |
| `API_SAMPLE_CACHE_ROOT` | `C:/Project/NeoZQYY/export/SYSTEM/CACHE/api_samples` | `SYSTEM/CACHE/api_samples/` | API 样本缓存 |
| `SYSTEM_LOG_ROOT` | `C:/Project/NeoZQYY/export/SYSTEM/LOGS` | `SYSTEM/LOGS/` | 系统级运维日志 |
| `BACKEND_LOG_ROOT` | `C:/Project/NeoZQYY/export/BACKEND/LOGS` | `BACKEND/LOGS/` | 后端结构化日志 |
| `DEV_TRACE_LOG_DIR` | `export/dev-trace-logs` | `dev-trace-logs/` | 开发调试全链路日志DevTrace 模块) |
| `AVATAR_EXPORT_PATH` | `C:/NeoZQYY/export/BACKEND/avatars` | `BACKEND/avatars/` | 用户头像文件存储目录 |
| `AVATAR_EXPORT_PATH` | `C:/Project/NeoZQYY/export/BACKEND/avatars` | `BACKEND/avatars/` | 用户头像文件存储目录 |
---
@@ -315,15 +315,15 @@ ETL 模块的路径变量通过 `env_parser.py` 的 `ENV_MAP` 映射到 `AppConf
## 服务器环境配置示例
开发机(`C:\NeoZQYY\.env`
开发机(`C:\Project\NeoZQYY\.env`
```env
EXPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
ETL_REPORT_ROOT=C:/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
SYSTEM_ANALYZE_ROOT=C:/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
AVATAR_EXPORT_PATH=C:/NeoZQYY/export/BACKEND/avatars
EXPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
LOG_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/LOGS
FETCH_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/JSON
ETL_REPORT_ROOT=C:/Project/NeoZQYY/export/ETL-Connectors/feiqiu/REPORTS
SYSTEM_ANALYZE_ROOT=C:/Project/NeoZQYY/export/SYSTEM/REPORTS/dataflow_analysis
BACKEND_LOG_ROOT=C:/Project/NeoZQYY/export/BACKEND/LOGS
AVATAR_EXPORT_PATH=C:/Project/NeoZQYY/export/BACKEND/avatars
```
服务器测试环境(`D:\NeoZQYY\test\repo\.env`

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
|------|-----|
| 开发者工具路径 | `C:\dev\WechatDevtools\微信开发者工具.exe` |
| CLI 路径 | `C:\dev\WechatDevtools\cli.bat` |
| 小程序项目路径 | `C:\NeoZQYY\apps\miniprogram` |
| 小程序项目路径 | `C:\Project\NeoZQYY\apps\miniprogram` |
| 自动化端口 | `9420`(固定) |
| AppID | `wx7c07793d82732921` |
@@ -23,7 +23,7 @@
### 第 2 步:在终端启动自动化端口
```powershell
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\NeoZQYY\apps\miniprogram" --auto-port 9420
& "C:\dev\WechatDevtools\cli.bat" auto --project "C:\Project\NeoZQYY\apps\miniprogram" --auto-port 9420
```
成功输出:
@@ -40,7 +40,7 @@
```
strategy: wsEndpoint
wsEndpoint: ws://127.0.0.1:9420
projectPath: C:\NeoZQYY\apps\miniprogram
projectPath: C:\Project\NeoZQYY\apps\miniprogram
healthCheck: true
```
@@ -67,7 +67,7 @@ healthCheck: true
"args": ["-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"],
"env": {
"WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat",
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
"WECHAT_DEVTOOLS_PROJECT": "C:\\Project\\NeoZQYY\\apps\\miniprogram"
}
}
```

View File

@@ -15,7 +15,7 @@
### 1.2 迁移目标
将所有项目整合为 NeoZQYY Monorepo`C:\NeoZQYY\`),实现:
将所有项目整合为 NeoZQYY Monorepo`C:\Project\NeoZQYY\`),实现:
- 清晰的模块边界apps/packages/db/docs 分离)
- uv workspace 统一依赖管理
- 六层数据库 Schema 架构meta/ods/dwd/core/dws/app
@@ -27,7 +27,7 @@
## 2. Monorepo 最终结构
```
C:\NeoZQYY\
C:\Project\NeoZQYY\
├── apps/
│ ├── etl/pipelines/feiqiu/ # 飞球 Connector数据源连接器从 FQ-ETL 平移)
│ ├── backend/ # FastAPI 后端(新建骨架)
@@ -292,7 +292,7 @@ C:\NeoZQYY\
```bash
# 安装依赖
cd C:\NeoZQYY
cd C:\Project\NeoZQYY
uv sync
# ETL 开发
@@ -308,7 +308,7 @@ cd apps/etl/pipelines/feiqiu
pytest tests/unit
# 运行属性测试
cd C:\NeoZQYY
cd C:\Project\NeoZQYY
pytest tests/ -v
```

View File

@@ -199,7 +199,7 @@ audit-writer 子代理(独立执行)
| 服务器名 | 命令 | 用途 | 状态 | 自动批准 |
|---|---|---|---|---|
| `filesystem` | `npx @modelcontextprotocol/server-filesystem` | 文件系统读写(作用域:`C:\NeoZQYY` | 启用 | 全部 |
| `filesystem` | `npx @modelcontextprotocol/server-filesystem` | 文件系统读写(作用域:`C:\Project\NeoZQYY` | 启用 | 全部 |
| `git` | `uvx mcp-server-git@2025.12.18` | Git 操作status/diff/commit/log 等) | 启用 | `git_status` |
| `postgres` | `uvx postgres-mcp --access-mode=unrestricted` | PostgreSQL 数据库操作(查询/DDL/健康检查/索引分析) | 启用 | 全部 |
| `playwright` | `npx @playwright/mcp@latest` | 浏览器自动化(截图/快照/点击/表单填写) | 启用 | 无 |

View File

@@ -46,7 +46,7 @@
### 基本用法(创建店铺管理员)
```bash
cd C:\NeoZQYY
cd C:\Project\NeoZQYY
python scripts/ops/init_test_user.py
```

View File

@@ -96,7 +96,7 @@
- [x] 6. Final checkpoint — 全量验证
- 运行验证脚本 `python scripts/ops/validate_p1_db_foundation.py`,确认所有检查项通过
- 运行属性测试 `cd C:\NeoZQYY && pytest tests/ -v -k p1`,确认所有属性测试通过
- 运行属性测试 `cd C:\Project\NeoZQYY && pytest tests/ -v -k p1`,确认所有属性测试通过
- 如有问题请告知用户
## 说明

Some files were not shown because too many files have changed in this diff Show More