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