diff --git a/.claude/settings.json b/.claude/settings.json index 31134bd..9440b24 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": { diff --git a/.env b/.env index d1d0dcf..adede62 100644 --- a/.env +++ b/.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 === # 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭) diff --git a/.env.template b/.env.template index 0fcb016..afdf239 100644 --- a/.env.template +++ b/.env.template @@ -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 === # 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭) diff --git a/.mcp.json b/.mcp.json index 126aef8..926296a 100644 --- a/.mcp.json +++ b/.mcp.json @@ -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": { diff --git a/_DEL/MIGRATION-PLAYBOOK.md b/_DEL/MIGRATION-PLAYBOOK.md index 698c460..ddf0365 100644 --- a/_DEL/MIGRATION-PLAYBOOK.md +++ b/_DEL/MIGRATION-PLAYBOOK.md @@ -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 ``` 连接规范: diff --git a/_DEL/miniprogram-h5-conversion/steering/action-manual.md b/_DEL/miniprogram-h5-conversion/steering/action-manual.md index 34274e0..ac3557d 100644 --- a/_DEL/miniprogram-h5-conversion/steering/action-manual.md +++ b/_DEL/miniprogram-h5-conversion/steering/action-manual.md @@ -17,7 +17,7 @@ async (page) => { deviceScaleFactor: 1.5 }); const p = await ctx.newPage(); - await p.goto('file:///C:/NeoZQYY/docs/h5_ui/pages/.html', + await p.goto('file:///C:/Project/NeoZQYY/docs/h5_ui/pages/.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), ); await p.waitForTimeout(300); await p.screenshot({ - path: 'C:/NeoZQYY/docs/h5_ui/compare//h5--step-.png', + path: 'C:/Project/NeoZQYY/docs/h5_ui/compare//h5--step-.png', type: 'png', scale: 'device' }); await ctx.close(); diff --git a/_DEL/weixin-devtools-mcp.md b/_DEL/weixin-devtools-mcp.md index e50a50e..39c1159 100644 --- a/_DEL/weixin-devtools-mcp.md +++ b/_DEL/weixin-devtools-mcp.md @@ -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` ## 禁止事项 diff --git a/_DEL/weixin-devtools/steering/workflow.md b/_DEL/weixin-devtools/steering/workflow.md index 22d02bc..a0f0810 100644 --- a/_DEL/weixin-devtools/steering/workflow.md +++ b/_DEL/weixin-devtools/steering/workflow.md @@ -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 做视觉对比 diff --git a/apps/backend/.env.local b/apps/backend/.env.local index 8b644a2..922eeda 100644 --- a/apps/backend/.env.local +++ b/apps/backend/.env.local @@ -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 diff --git a/apps/backend/app/config.py b/apps/backend/app/config.py index 9e7193b..04c0dfd 100644 --- a/apps/backend/app/config.py +++ b/apps/backend/app/config.py @@ -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__ 推算(最后手段) diff --git a/apps/backend/app/routers/xcx_coaches.py b/apps/backend/app/routers/xcx_coaches.py index ac0a111..589001a 100644 --- a/apps/backend/app/routers/xcx_coaches.py +++ b/apps/backend/app/routers/xcx_coaches.py @@ -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( diff --git a/apps/backend/app/routers/xcx_performance.py b/apps/backend/app/routers/xcx_performance.py index 3792c45..3061c1d 100644 --- a/apps/backend/app/routers/xcx_performance.py +++ b/apps/backend/app/routers/xcx_performance.py @@ -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, ) diff --git a/apps/backend/app/schemas/xcx_coaches.py b/apps/backend/app/schemas/xcx_coaches.py index 05e3091..793cdb6 100644 --- a/apps/backend/app/schemas/xcx_coaches.py +++ b/apps/backend/app/schemas/xcx_coaches.py @@ -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] = [] diff --git a/apps/backend/app/schemas/xcx_customers.py b/apps/backend/app/schemas/xcx_customers.py index f91d0e7..b4c7fce 100644 --- a/apps/backend/app/schemas/xcx_customers.py +++ b/apps/backend/app/schemas/xcx_customers.py @@ -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 diff --git a/apps/backend/app/schemas/xcx_performance.py b/apps/backend/app/schemas/xcx_performance.py index aa7ff4c..d04201c 100644 --- a/apps/backend/app/schemas/xcx_performance.py +++ b/apps/backend/app/schemas/xcx_performance.py @@ -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 diff --git a/apps/backend/app/services/board_service.py b/apps/backend/app/services/board_service.py index 1ab31a3..fe8337a 100644 --- a/apps/backend/app/services/board_service.py +++ b/apps/backend/app/services/board_service.py @@ -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: diff --git a/apps/backend/app/services/coach_service.py b/apps/backend/app/services/coach_service.py index 8767931..fa4bf63 100644 --- a/apps/backend/app/services/coach_service.py +++ b/apps/backend/app/services/coach_service.py @@ -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,), ) diff --git a/apps/backend/app/services/customer_service.py b/apps/backend/app/services/customer_service.py index e9e6dea..593c72b 100644 --- a/apps/backend/app/services/customer_service.py +++ b/apps/backend/app/services/customer_service.py @@ -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( diff --git a/apps/backend/app/services/fdw_queries.py b/apps/backend/app/services/fdw_queries.py index 2e8fe1d..ce87f96 100644 --- a/apps/backend/app/services/fdw_queries.py +++ b/apps/backend/app/services/fdw_queries.py @@ -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 [ diff --git a/apps/backend/app/services/performance_service.py b/apps/backend/app/services/performance_service.py index f205b9b..17534b7 100644 --- a/apps/backend/app/services/performance_service.py +++ b/apps/backend/app/services/performance_service.py @@ -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")}) diff --git a/apps/backend/app/services/recall_detector.py b/apps/backend/app/services/recall_detector.py index 7141def..09d0e9c 100644 --- a/apps/backend/app/services/recall_detector.py +++ b/apps/backend/app/services/recall_detector.py @@ -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( diff --git a/apps/backend/app/services/task_generator.py b/apps/backend/app/services/task_generator.py index e6479e4..a274987 100644 --- a/apps/backend/app/services/task_generator.py +++ b/apps/backend/app/services/task_generator.py @@ -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 """, diff --git a/apps/backend/app/services/task_manager.py b/apps/backend/app/services/task_manager.py index aa5d6db..26af2c4 100644 --- a/apps/backend/app/services/task_manager.py +++ b/apps/backend/app/services/task_manager.py @@ -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 diff --git a/apps/backend/app/services/task_registry.py b/apps/backend/app/services/task_registry.py index 9cdd650..bcc9c8e 100644 --- a/apps/backend/app/services/task_registry.py +++ b/apps/backend/app/services/task_registry.py @@ -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), ] # ── 工具类任务定义 ──────────────────────────────────────────── diff --git a/apps/backend/auth_only.txt b/apps/backend/auth_only.txt index a0472f8..1850a30 100644 --- a/apps/backend/auth_only.txt +++ b/apps/backend/auth_only.txt @@ -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 diff --git a/apps/backend/auth_only_results.txt b/apps/backend/auth_only_results.txt index 643534e..44e8ac4 100644 --- a/apps/backend/auth_only_results.txt +++ b/apps/backend/auth_only_results.txt @@ -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 diff --git a/apps/backend/auth_test_results.txt b/apps/backend/auth_test_results.txt index 4d6bdae..d035221 100644 --- a/apps/backend/auth_test_results.txt +++ b/apps/backend/auth_test_results.txt @@ -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 diff --git a/apps/demo-miniprogram/project.miniapp.json b/apps/demo-miniprogram/project.miniapp.json index 0aa2cbd..053ab33 100644 --- a/apps/demo-miniprogram/project.miniapp.json +++ b/apps/demo-miniprogram/project.miniapp.json @@ -35,7 +35,7 @@ } }, "mini-ios": { - "sdkVersion": "1.6.28", + "sdkVersion": "1.6.29", "toolkitVersion": "0.0.9", "useExtendedSdk": { "WeAppOpenFuns": true, diff --git a/apps/demo-miniprogram/project.private.config.json b/apps/demo-miniprogram/project.private.config.json index 10e23d9..3a4a7e7 100644 --- a/apps/demo-miniprogram/project.private.config.json +++ b/apps/demo-miniprogram/project.private.config.json @@ -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, diff --git a/apps/etl/connectors/feiqiu/.env b/apps/etl/connectors/feiqiu/.env index 4536f13..3b6c7ae 100644 --- a/apps/etl/connectors/feiqiu/.env +++ b/apps/etl/connectors/feiqiu/.env @@ -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 # ------------------------------------------------------------------------------ diff --git a/apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dwd_groupbuy_redemption.md b/apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dwd_groupbuy_redemption.md index 4e4fa9c..104b719 100644 --- a/apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dwd_groupbuy_redemption.md +++ b/apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dwd_groupbuy_redemption.md @@ -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 | | 美团结算数据导入时间 | ## 使用说明 diff --git a/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_cfg_area_category.md b/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_cfg_area_category.md index 16d1aaf..5fb4a9d 100644 --- a/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_cfg_area_category.md +++ b/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_cfg_area_category.md @@ -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 ``` diff --git a/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_member_project_tag.md b/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_member_project_tag.md index 0623573..f5733a6 100644 --- a/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_member_project_tag.md +++ b/apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_member_project_tag.md @@ -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 diff --git a/apps/etl/connectors/feiqiu/docs/etl_tasks/README.md b/apps/etl/connectors/feiqiu/docs/etl_tasks/README.md index 1c9011e..016e448 100644 --- a/apps/etl/connectors/feiqiu/docs/etl_tasks/README.md +++ b/apps/etl/connectors/feiqiu/docs/etl_tasks/README.md @@ -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 层(数据服务) #### 助教业绩域 diff --git a/apps/etl/connectors/feiqiu/orchestration/task_registry.py b/apps/etl/connectors/feiqiu/orchestration/task_registry.py index c9f476f..7e87f4e 100644 --- a/apps/etl/connectors/feiqiu/orchestration/task_registry.py +++ b/apps/etl/connectors/feiqiu/orchestration/task_registry.py @@ -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"]) diff --git a/apps/etl/connectors/feiqiu/scripts/research_coupon_details.py b/apps/etl/connectors/feiqiu/scripts/research_coupon_details.py index f0a2983..5cf2252 100644 --- a/apps/etl/connectors/feiqiu/scripts/research_coupon_details.py +++ b/apps/etl/connectors/feiqiu/scripts/research_coupon_details.py @@ -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 流程: diff --git a/apps/etl/connectors/feiqiu/tasks/dws/__init__.py b/apps/etl/connectors/feiqiu/tasks/dws/__init__.py index 56aa20a..ed9c96d 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/__init__.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/__init__.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", diff --git a/apps/etl/connectors/feiqiu/tasks/dws/core_dim_sync_task.py b/apps/etl/connectors/feiqiu/tasks/dws/core_dim_sync_task.py new file mode 100644 index 0000000..c116b49 --- /dev/null +++ b/apps/etl/connectors/feiqiu/tasks/dws/core_dim_sync_task.py @@ -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)}, + } diff --git a/apps/etl/connectors/feiqiu/tasks/dws/index/member_index_base.py b/apps/etl/connectors/feiqiu/tasks/dws/index/member_index_base.py index 337894d..90eab05 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/index/member_index_base.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/index/member_index_base.py @@ -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)) diff --git a/apps/etl/connectors/feiqiu/tasks/dws/index/newconv_index_task.py b/apps/etl/connectors/feiqiu/tasks/dws/index/newconv_index_task.py index 8d9e267..80288e4 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/index/newconv_index_task.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/index/newconv_index_task.py @@ -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 diff --git a/apps/etl/connectors/feiqiu/tasks/dws/index/relation_index_task.py b/apps/etl/connectors/feiqiu/tasks/dws/index/relation_index_task.py index c63a97a..f0580d6 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/index/relation_index_task.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/index/relation_index_task.py @@ -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 diff --git a/apps/etl/connectors/feiqiu/tasks/dws/index/winback_index_task.py b/apps/etl/connectors/feiqiu/tasks/dws/index/winback_index_task.py index 01551fa..4d64136 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/index/winback_index_task.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/index/winback_index_task.py @@ -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 diff --git a/apps/etl/connectors/feiqiu/tasks/dws/member_project_tag_task.py b/apps/etl/connectors/feiqiu/tasks/dws/member_project_tag_task.py index 12b330e..2442322 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/member_project_tag_task.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/member_project_tag_task.py @@ -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 diff --git a/apps/etl/connectors/feiqiu/tasks/dws/task_engine.py b/apps/etl/connectors/feiqiu/tasks/dws/task_engine.py index fa0d878..7705ae6 100644 --- a/apps/etl/connectors/feiqiu/tasks/dws/task_engine.py +++ b/apps/etl/connectors/feiqiu/tasks/dws/task_engine.py @@ -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 — 标��超时任务 + 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 模式���按顺序执行的后端任务 _JOB_SEQUENCE = [ "recall_completion_check", "task_expiry_check", "task_generator", ] +# 推演模式:导�� 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 ��式辅助 ── 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. 任务生成(混���冲突策略) + 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 diff --git a/apps/etl/connectors/feiqiu/tasks/utility/index_backfill_task.py b/apps/etl/connectors/feiqiu/tasks/utility/index_backfill_task.py new file mode 100644 index 0000000..62b02b0 --- /dev/null +++ b/apps/etl/connectors/feiqiu/tasks/utility/index_backfill_task.py @@ -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, + ) diff --git a/apps/etl/connectors/feiqiu/tasks/utility/task_simulation_task.py b/apps/etl/connectors/feiqiu/tasks/utility/task_simulation_task.py new file mode 100644 index 0000000..b0fe748 --- /dev/null +++ b/apps/etl/connectors/feiqiu/tasks/utility/task_simulation_task.py @@ -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, + ) diff --git a/apps/miniprogram/miniprogram/app.json b/apps/miniprogram/miniprogram/app.json index 39d300b..d893ddb 100644 --- a/apps/miniprogram/miniprogram/app.json +++ b/apps/miniprogram/miniprogram/app.json @@ -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" diff --git a/apps/miniprogram/miniprogram/components/perf-progress-bar/perf-progress-bar.wxss b/apps/miniprogram/miniprogram/components/perf-progress-bar/perf-progress-bar.wxss index 10aa2e1..4ae6503 100644 --- a/apps/miniprogram/miniprogram/components/perf-progress-bar/perf-progress-bar.wxss +++ b/apps/miniprogram/miniprogram/components/perf-progress-bar/perf-progress-bar.wxss @@ -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; } diff --git a/apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts b/apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts index 0c8f3ae..e2627b8 100644 --- a/apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts +++ b/apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts @@ -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 重复警告 diff --git a/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts b/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts index a2b811d..30dd94b 100644 --- a/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts +++ b/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts @@ -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 = { + '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' }), }) }, diff --git a/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml b/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml index 511d73a..10d5627 100644 --- a/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml +++ b/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml @@ -25,7 +25,7 @@ {{fmt.safe(detail.name)}} - + {{item}} @@ -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;" /> @@ -117,14 +117,14 @@ 任务执行 本月完成 - 回访{{fmt.count(taskStats.callback, '个')}} - 召回{{fmt.count(taskStats.recall, '个')}} + 回访{{taskStats.callback}}个 + 召回{{taskStats.recall}}个 + bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover"> {{item.typeLabel}} {{item.customerName}} @@ -137,7 +137,7 @@ + bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover"> {{item.typeLabel}} {{item.customerName}} @@ -177,7 +177,7 @@ - {{item.name}} + {{item.name}} {{item.heartEmoji}} {{fmt.safe(item.score)}} @@ -211,14 +211,14 @@ - {{item.customerName}} + {{item.customerName}} {{item.type}} {{item.date}} - {{item.table}} + {{item.table}} {{item.duration}} 定档绩效:{{item.perfHours}} diff --git a/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxss b/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxss index 0966379..d2b2894 100644 --- a/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxss +++ b/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxss @@ -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; } diff --git a/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.json b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.json new file mode 100644 index 0000000..716f6e0 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.json @@ -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" + } +} diff --git a/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts new file mode 100644 index 0000000..6c6f6c2 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts @@ -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 = { + '陪打': '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) { + 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 = { + 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() + }, +}) diff --git a/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.wxml b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.wxml new file mode 100644 index 0000000..119b89a --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.wxml @@ -0,0 +1,123 @@ + + + + + 加载失败,请点击重试 + + 重试 + + + + + + + + + + + + + + {{monthLabel}} + + + + + + + + + 总记录 + {{totalCountLabel}} + + + + 总业绩时长 + {{totalHoursLabel}} + 预估 + 折前 {{totalHoursRawLabel}} + + + + {{isCurrentMonth ? '预估收入' : '收入'}} + {{totalIncomeLabel}} + 预估 + + + + + + + 暂无数据 + + + + + + + + + {{fmt.safe(item.date)}}  — + {{fmt.safe(item.totalHours)}}小时 · {{isCurrentMonth ? '预估 ' : ''}}¥{{fmt.safe(item.totalIncome)}}   + + + + + + + {{rec.avatarChar}} + + + + + {{fmt.safe(rec.customerName)}} + + {{fmt.safe(rec.timeRange)}} + + {{fmt.safe(rec.hours)}}小时 + + + + {{fmt.safe(rec.courseType)}} + {{fmt.safe(rec.location)}} + + {{isCurrentMonth ? '助教预估收入' : '助教收入'}} ¥{{fmt.safe(rec.income)}} + + + + + + + + — 已加载全部记录 — + + + + + + + + + diff --git a/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.wxss b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.wxss new file mode 100644 index 0000000..18f4bf6 --- /dev/null +++ b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.wxss @@ -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; +} diff --git a/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts b/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts index 1d83aec..d6f0d59 100644 --- a/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts +++ b/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts @@ -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: () => { diff --git a/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml b/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml index 9444a35..8f8066d 100644 --- a/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml +++ b/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml @@ -27,7 +27,7 @@ {{detail.name}} - {{phoneVisible ? detail.phone : '138****5678'}} + {{phoneVisible ? detail.phoneFull : detail.phone}} {{phoneVisible ? '复制' : '查看'}} @@ -221,7 +221,7 @@ - 🍷 食品酒水 + 🍷 {{item.foodDetail || '食品酒水'}} {{fmt.money(item.foodAmount)}} {{fmt.money(item.foodOrigPrice)}} @@ -261,7 +261,7 @@ - 🍷 食品酒水 + 🍷 {{item.foodDetail || '食品酒水'}} {{fmt.money(item.foodAmount)}} @@ -292,7 +292,7 @@ - {{fmt.safe(item.tagLabel)}} + {{item.creatorName || item.tagLabel}}{{item.creatorRole ? ' · ' + item.creatorRole : ''}} {{fmt.safe(item.createdAt)}} diff --git a/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss b/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss index 5de19f9..b3625b0 100644 --- a/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss +++ b/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss @@ -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 { diff --git a/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxml b/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxml index eb6022d..86332f1 100644 --- a/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxml +++ b/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxml @@ -131,7 +131,7 @@ - 🍷 食品酒水 + 🍷 {{item.foodDetail || '食品酒水'}} {{fmt.money(item.foodAmount)}} {{fmt.money(item.foodOrigPrice)}} @@ -156,7 +156,7 @@ {{fmt.safe(item.date)}} - 🍷 食品酒水 + 🍷 {{item.foodDetail || '食品酒水'}} {{fmt.money(item.foodAmount)}} diff --git a/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxss b/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxss index 9c7b955..8441c31 100644 --- a/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxss +++ b/apps/miniprogram/miniprogram/pages/customer-records/customer-records.wxss @@ -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; diff --git a/apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts b/apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts index 4c57274..0c71a0b 100644 --- a/apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts +++ b/apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts @@ -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' diff --git a/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml b/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml index 857fc69..fe207b0 100644 --- a/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml +++ b/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml @@ -92,7 +92,7 @@ - {{fmt.safe(rec.customerName)}} + {{fmt.safe(rec.customerName)}} {{fmt.safe(rec.timeRange)}} diff --git a/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxss b/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxss index 5f8b10f..18f4bf6 100644 --- a/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxss +++ b/apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxss @@ -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; diff --git a/apps/miniprogram/miniprogram/pages/task-list/task-list.ts b/apps/miniprogram/miniprogram/pages/task-list/task-list.ts index 3c4dece..bf6357e 100644 --- a/apps/miniprogram/miniprogram/pages/task-list/task-list.ts +++ b/apps/miniprogram/miniprogram/pages/task-list/task-list.ts @@ -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: 当月预估判断 diff --git a/apps/miniprogram/miniprogram/pages/task-list/task-list.wxml b/apps/miniprogram/miniprogram/pages/task-list/task-list.wxml index 6541962..b81515d 100644 --- a/apps/miniprogram/miniprogram/pages/task-list/task-list.wxml +++ b/apps/miniprogram/miniprogram/pages/task-list/task-list.wxml @@ -169,7 +169,7 @@ 逾期{{-item.expectedDays}}天 - 到店:{{fmt.days(item.lastVisitDays)}}前 · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}} + 到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}} {{item.deadlineLabel}} @@ -213,7 +213,7 @@ 逾期{{-item.expectedDays}}天 - 到店:{{fmt.days(item.lastVisitDays)}}前 · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}} + 到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}} {{item.deadlineLabel}} @@ -255,7 +255,7 @@ - 到店:{{fmt.days(item.lastVisitDays)}}前 · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}} + 到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}} 放弃原因:{{fmt.safe(item.abandonReason)}} diff --git a/apps/miniprogram/miniprogram/services/api.ts b/apps/miniprogram/miniprogram/services/api.ts index 7cafcdc..7a50c10 100644 --- a/apps/miniprogram/miniprogram/services/api.ts +++ b/apps/miniprogram/miniprogram/services/api.ts @@ -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 = { + 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 { return request({ diff --git a/apps/miniprogram/miniprogram/utils/format.wxs b/apps/miniprogram/miniprogram/utils/format.wxs index 5f15938..2c58d4d 100644 --- a/apps/miniprogram/miniprogram/utils/format.wxs +++ b/apps/miniprogram/miniprogram/utils/format.wxs @@ -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, diff --git a/apps/miniprogram/miniprogram/utils/perf-progress.ts b/apps/miniprogram/miniprogram/utils/perf-progress.ts new file mode 100644 index 0000000..8c14f51 --- /dev/null +++ b/apps/miniprogram/miniprogram/utils/perf-progress.ts @@ -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) { + // 后端返回 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, + } +} diff --git a/apps/miniprogram/project.config.json b/apps/miniprogram/project.config.json index d9f48f0..b1bcf3e 100644 --- a/apps/miniprogram/project.config.json +++ b/apps/miniprogram/project.config.json @@ -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": [] diff --git a/apps/miniprogram/project.miniapp.json b/apps/miniprogram/project.miniapp.json index 0aa2cbd..476a809 100644 --- a/apps/miniprogram/project.miniapp.json +++ b/apps/miniprogram/project.miniapp.json @@ -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 } -} \ No newline at end of file +} diff --git a/apps/miniprogram/project.private.config.json b/apps/miniprogram/project.private.config.json index ee2bd7f..d69d606 100644 --- a/apps/miniprogram/project.private.config.json +++ b/apps/miniprogram/project.private.config.json @@ -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, diff --git a/db/README.md b/db/README.md index 8aef291..398e30e 100644 --- a/db/README.md +++ b/db/README.md @@ -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 表 | diff --git a/db/etl_feiqiu/migrations/2026-04-12__add_stat_date_to_index_tables.sql b/db/etl_feiqiu/migrations/2026-04-12__add_stat_date_to_index_tables.sql new file mode 100644 index 0000000..db4bf12 --- /dev/null +++ b/db/etl_feiqiu/migrations/2026-04-12__add_stat_date_to_index_tables.sql @@ -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 重新生成) diff --git a/db/etl_feiqiu/migrations/20260411_member_project_tag_visit_based.sql b/db/etl_feiqiu/migrations/20260411_member_project_tag_visit_based.sql new file mode 100644 index 0000000..dfbdc8c --- /dev/null +++ b/db/etl_feiqiu/migrations/20260411_member_project_tag_visit_based.sql @@ -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; diff --git a/db/etl_feiqiu/schemas/app.sql b/db/etl_feiqiu/schemas/app.sql index 1af6a83..d596c11 100644 --- a/db/etl_feiqiu/schemas/app.sql +++ b/db/etl_feiqiu/schemas/app.sql @@ -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 diff --git a/db/etl_feiqiu/schemas/core.sql b/db/etl_feiqiu/schemas/core.sql index 5bb022d..9eb41df 100644 --- a/db/etl_feiqiu/schemas/core.sql +++ b/db/etl_feiqiu/schemas/core.sql @@ -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; diff --git a/db/etl_feiqiu/schemas/dwd.sql b/db/etl_feiqiu/schemas/dwd.sql index 4bad79a..4a37686 100644 --- a/db/etl_feiqiu/schemas/dwd.sql +++ b/db/etl_feiqiu/schemas/dwd.sql @@ -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 ( diff --git a/db/etl_feiqiu/schemas/dws.sql b/db/etl_feiqiu/schemas/dws.sql index 9b7f3c6..43d0e98 100644 --- a/db/etl_feiqiu/schemas/dws.sql +++ b/db/etl_feiqiu/schemas/dws.sql @@ -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; ; -- 物化视图 diff --git a/docs/README.md b/docs/README.md index e9c5347..285f22f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 ``` ## 文件归属规则 diff --git a/docs/assistant-task-detail-report.md b/docs/assistant-task-detail-report.md new file mode 100644 index 0000000..8773f8c --- /dev/null +++ b/docs/assistant-task-detail-report.md @@ -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 关系构建 | 客户到店 -> 完成召回 -> 生成回访(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 | diff --git a/docs/audit/changes/2026-02-15__admin-web-console-db-migration-audit-reorg.md b/docs/audit/changes/2026-02-15__admin-web-console-db-migration-audit-reorg.md index e05fa93..7568885 100644 --- a/docs/audit/changes/2026-02-15__admin-web-console-db-migration-audit-reorg.md +++ b/docs/audit/changes/2026-02-15__admin-web-console-db-migration-audit-reorg.md @@ -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 ``` ## 内联注释决策 diff --git a/docs/audit/changes/2026-02-15__monorepo-migration-phase1-8.md b/docs/audit/changes/2026-02-15__monorepo-migration-phase1-8.md index 0f862da..932c612 100644 --- a/docs/audit/changes/2026-02-15__monorepo-migration-phase1-8.md +++ b/docs/audit/changes/2026-02-15__monorepo-migration-phase1-8.md @@ -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` 建立基线 \ No newline at end of file diff --git a/docs/database/ddl/etl_feiqiu__app.sql b/docs/database/ddl/etl_feiqiu__app.sql index 1af6a83..d596c11 100644 --- a/docs/database/ddl/etl_feiqiu__app.sql +++ b/docs/database/ddl/etl_feiqiu__app.sql @@ -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 diff --git a/docs/database/ddl/etl_feiqiu__core.sql b/docs/database/ddl/etl_feiqiu__core.sql index 5bb022d..bc162c2 100644 --- a/docs/database/ddl/etl_feiqiu__core.sql +++ b/docs/database/ddl/etl_feiqiu__core.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- etl_feiqiu / core(跨门店标准化维度/事实) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:测试库(通过脚本自动导出) -- ============================================================================= diff --git a/docs/database/ddl/etl_feiqiu__dwd.sql b/docs/database/ddl/etl_feiqiu__dwd.sql index 4bad79a..4aa2762 100644 --- a/docs/database/ddl/etl_feiqiu__dwd.sql +++ b/docs/database/ddl/etl_feiqiu__dwd.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- etl_feiqiu / dwd(明细数据层) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:测试库(通过脚本自动导出) -- ============================================================================= diff --git a/docs/database/ddl/etl_feiqiu__dws.sql b/docs/database/ddl/etl_feiqiu__dws.sql index 9b7f3c6..43d0e98 100644 --- a/docs/database/ddl/etl_feiqiu__dws.sql +++ b/docs/database/ddl/etl_feiqiu__dws.sql @@ -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; ; -- 物化视图 diff --git a/docs/database/ddl/etl_feiqiu__meta.sql b/docs/database/ddl/etl_feiqiu__meta.sql index 3e95e4f..dcbce84 100644 --- a/docs/database/ddl/etl_feiqiu__meta.sql +++ b/docs/database/ddl/etl_feiqiu__meta.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- etl_feiqiu / meta(ETL 调度元数据) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:测试库(通过脚本自动导出) -- ============================================================================= diff --git a/docs/database/ddl/etl_feiqiu__ods.sql b/docs/database/ddl/etl_feiqiu__ods.sql index 102d98a..d598eac 100644 --- a/docs/database/ddl/etl_feiqiu__ods.sql +++ b/docs/database/ddl/etl_feiqiu__ods.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- etl_feiqiu / ods(原始数据层) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:测试库(通过脚本自动导出) -- ============================================================================= diff --git a/docs/database/ddl/fdw.sql b/docs/database/ddl/fdw.sql index 6373cde..cfa1c69 100644 --- a/docs/database/ddl/fdw.sql +++ b/docs/database/ddl/fdw.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- FDW 跨库映射(在 zqyy_app 中执行) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:db/fdw/setup_fdw.sql -- ============================================================================= diff --git a/docs/database/ddl/zqyy_app__auth.sql b/docs/database/ddl/zqyy_app__auth.sql index edd008c..fcc360c 100644 --- a/docs/database/ddl/zqyy_app__auth.sql +++ b/docs/database/ddl/zqyy_app__auth.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- zqyy_app / auth(用户认证与权限) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:测试库(通过脚本自动导出) -- ============================================================================= diff --git a/docs/database/ddl/zqyy_app__biz.sql b/docs/database/ddl/zqyy_app__biz.sql index a4b00e1..3e17c34 100644 --- a/docs/database/ddl/zqyy_app__biz.sql +++ b/docs/database/ddl/zqyy_app__biz.sql @@ -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); diff --git a/docs/database/ddl/zqyy_app__public.sql b/docs/database/ddl/zqyy_app__public.sql index 18a53b0..a5247a2 100644 --- a/docs/database/ddl/zqyy_app__public.sql +++ b/docs/database/ddl/zqyy_app__public.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- zqyy_app / public(小程序业务表) --- 生成日期:2026-04-06 +-- 生成日期:2026-04-12 -- 来源:测试库(通过脚本自动导出) -- ============================================================================= diff --git a/docs/deployment/EXPORT-PATHS.md b/docs/deployment/EXPORT-PATHS.md index d770083..940250b 100644 --- a/docs/deployment/EXPORT-PATHS.md +++ b/docs/deployment/EXPORT-PATHS.md @@ -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`): diff --git a/docs/deployment/LAUNCH-CHECKLIST.md b/docs/deployment/LAUNCH-CHECKLIST.md index 45ead5c..a23f898 100644 --- a/docs/deployment/LAUNCH-CHECKLIST.md +++ b/docs/deployment/LAUNCH-CHECKLIST.md @@ -1,731 +1,731 @@ -# 微信小程序上线清单 - -> 最后更新:2026-02-20 -> 本文档合并自 ENV-MANAGEMENT.md、MINIPROGRAM-RELEASE.md、PRE-TEST-VERIFICATION.md 及补充建议。 -> 按优先级从高到低排列,同时兼顾依赖关系(后续步骤依赖前置步骤完成)。 -> 每项完成后在状态栏标注完成日期。 - ---- - -## 阅读指南 - -- 状态标记:待办 = 空框,已完成 = 日期 -- 优先级:P0 = 不做就上不了线,P1 = 上线前必须做,P2 = 可上线后迭代 -- 依赖关系用箭头标注,如 "依赖 1.1" 表示需要先完成第 1.1 项 - ---- - -## 第一阶段:基础设施(P0 - 一切的前提) - -所有后续步骤都建立在"三个环境能跑起来"的基础上。 - -### 1.1 服务器环境初始化 - -| 状态 | 项目 | -|------|------| -| 完成 20260220 | 在 Windows Server 上创建目录结构 | -| 完成 20260220 | 克隆仓库并切换分支 | -| 完成 20260224 | 配置环境变量文件 | -| 完成 20260224 | 安装 Python 依赖 | -| 完成 20260224 | 运行 `setup-server-git.py` 配置 Git 排除规则 | -| 完成 20260224 | 运行 `init-server-env.py` 删除排除文件 + 创建 export 目录 | - -在 Windows Server 上执行: - -```powershell -# 创建目录(日志统一放在 repo/export/ 下,随 .env 配置走) -New-Item -ItemType Directory -Path D:\NeoZQYY\test\repo -Force -New-Item -ItemType Directory -Path D:\NeoZQYY\prod\repo -Force -New-Item -ItemType Directory -Path D:\NeoZQYY\scripts -Force -``` - -> 旧方案在 repo 外单独建 `logs/` 目录,现已废弃。 -> 所有运行时输出(日志、JSON 导出、报告)统一放在 `repo/export/` 下, -> 路径由 `.env` 中的 `LOG_ROOT`、`EXPORT_ROOT` 等变量控制。 -> 详见 [`docs/deployment/EXPORT-PATHS.md`](EXPORT-PATHS.md)。 - -```powershell -# 克隆仓库 -cd D:\NeoZQYY\test -git clone https://git.langlangzhuoqiu.cn/root/Neo-ZQYY.git repo -cd repo -git checkout test - -cd D:\NeoZQYY\prod -git clone https://git.langlangzhuoqiu.cn/root/Neo-ZQYY.git repo -cd repo -git checkout master -``` - -环境变量文件(不从 Git 同步,手动创建): - -测试环境 `D:\NeoZQYY\test\repo\.env`: -```env -DB_HOST=100.64.0.4 -DB_PORT=5432 -DB_USER=local-Python -DB_PASSWORD=<密码> -APP_DB_NAME=test_zqyy_app -ETL_DB_NAME=test_etl_feiqiu -PG_DSN=postgresql://<用户>:<密码>@<主机>:5432/test_etl_feiqiu -APP_DB_DSN=postgresql://<用户>:<密码>@<主机>:5432/test_zqyy_app -LOG_LEVEL=DEBUG - -# 输出路径(统一放在 repo/export/ 下) -EXPORT_ROOT=D:/NeoZQYY/test/repo/export/ETL-Connectors/feiqiu/JSON -LOG_ROOT=D:/NeoZQYY/test/repo/export/ETL-Connectors/feiqiu/LOGS -FETCH_ROOT=D:/NeoZQYY/test/repo/export/ETL-Connectors/feiqiu/JSON -ETL_REPORT_ROOT=D:/NeoZQYY/test/repo/export/ETL-Connectors/feiqiu/REPORTS -SYSTEM_ANALYZE_ROOT=D:/NeoZQYY/test/repo/export/SYSTEM/REPORTS/dataflow_analysis -BACKEND_LOG_ROOT=D:/NeoZQYY/test/repo/export/BACKEND/LOGS -``` - -正式环境 `D:\NeoZQYY\prod\repo\.env`: -```env -DB_HOST=100.64.0.4 -DB_PORT=5432 -DB_USER=prod-Python -DB_PASSWORD=<正式密码> -APP_DB_NAME=zqyy_app -ETL_DB_NAME=etl_feiqiu -PG_DSN=postgresql://<用户>:<密码>@<主机>:5432/etl_feiqiu -APP_DB_DSN=postgresql://<用户>:<密码>@<主机>:5432/zqyy_app -LOG_LEVEL=INFO - -# 输出路径(统一放在 repo/export/ 下) -EXPORT_ROOT=D:/NeoZQYY/prod/repo/export/ETL-Connectors/feiqiu/JSON -LOG_ROOT=D:/NeoZQYY/prod/repo/export/ETL-Connectors/feiqiu/LOGS -FETCH_ROOT=D:/NeoZQYY/prod/repo/export/ETL-Connectors/feiqiu/JSON -ETL_REPORT_ROOT=D:/NeoZQYY/prod/repo/export/ETL-Connectors/feiqiu/REPORTS -SYSTEM_ANALYZE_ROOT=D:/NeoZQYY/prod/repo/export/SYSTEM/REPORTS/dataflow_analysis -BACKEND_LOG_ROOT=D:/NeoZQYY/prod/repo/export/BACKEND/LOGS -``` - -> 正式环境建议使用独立的数据库用户(如 `prod-Python`),权限最小化。 - -```powershell -# 安装依赖(每个环境各自执行) -cd D:\NeoZQYY\test\repo -uv sync --all-packages - -cd D:\NeoZQYY\prod\repo -uv sync --all-packages -``` - -```powershell -# 配置服务器 Git 排除规则(每个环境各执行一次) -# 跳过 export/、.env 等开发机留存文件,避免占用服务器磁盘 -cd D:\NeoZQYY\test\repo -python scripts/server/setup-server-git.py - -cd D:\NeoZQYY\prod\repo -python scripts/server/setup-server-git.py -``` - -```powershell -# 删除排除文件 + 创建 export 目录树(test + prod 一次搞定) -cd D:\NeoZQYY -python test\repo\scripts\server\init-server-env.py - -# 也可以只初始化单个环境 -python test\repo\scripts\server\init-server-env.py --envs test -python prod\repo\scripts\server\init-server-env.py --envs prod -``` - -Git 排除方案说明(统一 .gitignore + skip-worktree): - -三个分支(dev / test / master)共用同一份 `.gitignore`(宽松版,允许 `.env`、`export/` 等留存文件提交)。 -服务器上通过 `setup-server-git.py` 一次性配置,不需要每个分支维护不同的 `.gitignore`。 - -工作原理: -1. 脚本将 `scripts/server/server-exclude.txt` 复制到 `.git/info/exclude`(本地排除,不影响仓库) -2. 对已 track 但服务器不需要的文件设置 `git update-index --skip-worktree` -3. 后续 `git pull` 不会还原这些文件,可安全删除释放磁盘空间 - -被排除的内容(完整列表见 `scripts/server/server-exclude.txt`): -- `.env` / `.env.local` -- 服务器有自己的环境配置 -- `export/` -- ETL 导出数据(仅开发机留存) -- `docs/` -- 全部文档(部署、PRD、H5 原型、审计、架构等) -- `apps/miniprogram/` -- 小程序源码(服务器不编译小程序) -- `apps/admin-web/src/` -- 管理后台源码(保留 dist/) -- `tests/`、`.hypothesis/` -- 测试相关 -- `samples/`、`infra/` -- 示例数据和基础设施文档 -- `scripts/ops/`、`scripts/audit/`、`scripts/migrate/` -- 开发用脚本 -- `.kiro/` -- Kiro 配置 -- 根目录截图(`*.png`)、`.code-workspace` 等 - -优点: -- merge 零冲突(三个分支 `.gitignore` 完全一致) -- 服务器首次 clone 后运行一次脚本即可,后续 `git pull` 正常工作 -- 开发机的留存文件正常提交到 Git,不受影响 - -环境总览: - -| 环境 | 位置 | Git 分支 | 数据库 | 用途 | -|------|------|----------|--------|------| -| 开发 | 本机 `C:\NeoZQYY` | `dev` | `test_etl_feiqiu` / `test_zqyy_app` | 日常开发 | -| 测试 | 服务器 `D:\NeoZQYY\test\repo` | `test` | `test_etl_feiqiu` / `test_zqyy_app` | 集成测试 + 小程序体验版 | -| 正式 | 服务器 `D:\NeoZQYY\prod\repo` | `master` | `etl_feiqiu` / `zqyy_app` | 生产环境 + 小程序正式版 | - - -### 1.2 后端服务管理 - bat 脚本(依赖 1.1) - -| 状态 | 项目 | -|------|------| -| 完成 20260224 | 将 bat 脚本放到服务器 `D:\NeoZQYY\scripts\` | -| 完成 20260224 | 登录服务器手动运行对应脚本启动服务 | - -> 后续将由监控系统(见 7.2)统一管理所有服务的启停和状态监控。 -> 在监控系统上线之前,登录 Windows Server 手动双击 bat 脚本启动。 - -端口分配: - -| 服务 | 测试环境 | 正式环境 | -|------|----------|----------| -| FastAPI 后端 | 8001 | 8000 | - -启动脚本 `D:\NeoZQYY\scripts\start-test-api.bat`: -```bat -@echo off -title NeoZQYY Test API (port 8001) -cd /d D:\NeoZQYY\test\repo\apps\backend -D:\NeoZQYY\test\repo\.venv\Scripts\uvicorn.exe app.main:app --host 0.0.0.0 --port 8001 -pause -``` - -启动脚本 `D:\NeoZQYY\scripts\start-prod-api.bat`: -```bat -@echo off -title NeoZQYY Prod API (port 8000) -cd /d D:\NeoZQYY\prod\repo\apps\backend -D:\NeoZQYY\prod\repo\.venv\Scripts\uvicorn.exe app.main:app --host 0.0.0.0 --port 8000 -pause -``` - -一键全部启动 `D:\NeoZQYY\scripts\start-all.bat`: -```bat -@echo off -echo 启动测试环境后端... -start "NeoZQYY Test API" cmd /c "D:\NeoZQYY\scripts\start-test-api.bat" -echo 启动正式环境后端... -start "NeoZQYY Prod API" cmd /c "D:\NeoZQYY\scripts\start-prod-api.bat" -echo 全部已启动。 -pause -``` - -> 每个 bat 会打开一个独立的 cmd 窗口,窗口标题显示服务名称,方便识别。 -> 关闭窗口即停止服务。服务器重启后需要重新手动运行。 - -### 1.3 跳板机 Nginx 反代(依赖 1.2) - -| 状态 | 项目 | -|------|------| -| 已完成 | 跳板机已配置好(用户确认) | -| 已完成 | Tailscale 内网已配置(DB_HOST=100.64.0.4) | -| 完成 20260224 | 确认 Nginx 将 `api.langlangzhuoqiu.cn` 反代到 Tailscale IP:8000(正式) | -| 完成 20260224 | 确认 Nginx 将测试环境反代到 Tailscale IP:8001(如需区分域名) | -| 完成 20260224 | 确认 SSL 证书有效且自动续期 | - -> 跳板机本身已配好,这里只需确认反代规则指向了正确的后端端口。 -> 如果测试和正式共用 `api.langlangzhuoqiu.cn`,则体验版和正式版会打到同一个后端。 -> 建议至少在初期区分:`test-api.langlangzhuoqiu.cn` 指向 8001,`api.langlangzhuoqiu.cn` 指向 8000。 - -### 1.4 数据库备份方案(依赖 1.1) - -| 状态 | 项目 | -|------|------| -| | 编写 pg_dump 备份脚本 | -| | 配置 Windows 计划任务定时执行 | -| | 执行一次恢复演练验证备份可用 | - -> 你的 Windows Server 是单点,正式库 `etl_feiqiu` 和 `zqyy_app` 丢了不可逆。 -> 建议每天凌晨自动 pg_dump,保留最近 7 天。备份文件可以同步到跳板机或其他位置做异地冗余。 - -示例备份脚本(放 `D:\NeoZQYY\scripts\backup-db.ps1`): - -```powershell -$date = Get-Date -Format "yyyy-MM-dd" -$backupDir = "D:\NeoZQYY\backups" -New-Item -ItemType Directory -Path $backupDir -Force - -# 正式 ETL 库 -pg_dump -h 100.64.0.4 -U prod-Python -d etl_feiqiu -F c -f "$backupDir\etl_feiqiu_$date.dump" - -# 正式业务库 -pg_dump -h 100.64.0.4 -U prod-Python -d zqyy_app -F c -f "$backupDir\zqyy_app_$date.dump" - -# 清理 7 天前的备份 -Get-ChildItem $backupDir -Filter "*.dump" | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } | Remove-Item -``` - -用 Windows 计划任务每天凌晨 3:00 执行此脚本。 - ---- - -## 第二阶段:微信侧配置(P0 - 小程序能跑的前提) - -这些是微信平台的硬性要求,缺任何一项小程序都无法在真机上正常运行或通过审核。 - -### 2.1 合法域名 + HTTPS - -| 状态 | 项目 | -|------|------| -| 已完成 | request 合法域名:`https://api.langlangzhuoqiu.cn` | -| 已完成 | socket 合法域名:`wss://socket.langlangzhuoqiu.cn` | -| 已完成 | uploadFile 合法域名:`https://file.langlangzhuoqiu.cn` | -| 已完成 | downloadFile 合法域名:`https://file.langlangzhuoqiu.cn` | - -> 已在微信公众平台后台配置完成。 - -### 2.2 消息推送配置(依赖 1.2 + 1.3) - -| 状态 | 项目 | -|------|------| -| 已完成 | 后端接口 `GET/POST /api/wx/callback` 已实现(`wx_callback.py`) | -| 已完成 | 在 `apps/backend/.env.local` 中配置 `WX_CALLBACK_TOKEN` | -| 已完成 | 服务器上部署最新代码并重启后端 | -| 未完成 | 微信后台填写消息推送配置并提交验证 | - -> 消息推送配置必须在服务器后端已启动、跳板机反代已就绪之后才能操作。 -> 微信会向你的 URL 发 GET 请求验签,后端必须在线才能通过。 - -微信后台配置(开发 - 开发管理 - 消息推送): - -| 字段 | 值 | -|------|------| -| URL | `https://api.langlangzhuoqiu.cn/api/wx/callback` | -| Token | `LLZQwx2026push`(和 .env.local 里一致,可自定义) | -| EncodingAESKey | 点"随机生成" | -| 消息加解密方式 | 先选"明文模式"(跑通后再切安全模式) | -| 数据格式 | JSON | - -点"提交"后微信发 GET 验证。如果失败,最常见原因: -- 服务器后端未启动 -- Nginx 反代未指向正确端口 -- Token 两边不一致 - -**需要支持加密模式,见增补文档路径下的文档** - -### 2.3 隐私协议 / 用户隐私保护指引 - -| 状态 | 项目 | -|------|------| -| | 在微信后台填写用户隐私保护指引 | -| | 小程序端实现隐私授权弹窗组件 | - -操作路径:微信后台 → 账号设置 → 服务内容声明 → 用户隐私保护指引 → 去完善 -(也可在提交审核时填写,入口:管理 → 版本管理 → 提交代码审核) - -根据你实际使用的隐私接口声明,对照官方列表: -https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/miniprogram-intro.html - -当前项目可能涉及的声明项: -- 收集你的手机号(如果用了 `