feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本

包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -0,0 +1,244 @@
# P13小程序前端 — 联调补齐与格式统一 — miniapp-fe-polish
> 优先级P13依赖 P6~P9 前端页面 + P3 认证 + P4 核心业务 + 后端接口)
> 预估工作量:中
> 生成日期2026-03-20
---
## 背景
小程序前端页面已完成 H5 原型还原P6~P9但在 MOCK 数据排查中发现多处功能点未对接真实数据或逻辑缺失。本 SPEC 统一收敛这些遗漏项,确保每个页面在联调后数据展示完整、格式统一。
---
## 一、通用规则(跨页面)
### G1微信头像与用户信息
**现状**`fetchMe()` 接口已定义但返回空 mock`task-list` 声明了 `avatarUrl` 但未赋值;`performance`/`performance-records``avatarUrl` 字段。
**需求**
- 登录成功后,从后端获取用户信息(微信头像 URL、微信昵称、角色、门店名称
- 后端接口 `GET /api/xcx/me` 返回 `{ avatarUrl, nickName, role, storeName }`
- 头像来源:微信登录时由后端通过 `code2Session` + `getUserInfo` 获取并存储,前端不直接调用 `wx.getUserProfile`
- 所有含 banner 的页面task-list、performance、performance-records统一从全局用户信息读取 `avatarUrl`
**验收标准**
- AC-G1.1:三个 banner 页面展示微信头像,无头像时显示默认占位图
- AC-G1.2:用户昵称、角色、门店名称正确展示
### G2当月预估判断
**现状**WXML 硬编码"预估"文案TS 无当月判断逻辑。
**需求**
- 当查看的数据月份 = 当前自然月时,标题显示"我的预估收入",金额旁显示"预估"标签
- 当查看的数据月份 < 当前自然月时,标题显示"我的收入",无"预估"标签
- 判断逻辑:`isCurrentMonth = (year === nowYear && month === nowMonth)`
- 影响页面performance、performance-records、board-finance
**验收标准**
- AC-G2.1:本月数据显示"预估"标签,历史月份不显示
- AC-G2.2board-finance 本月时间筛选时,经营一览标题含"预估"字样
### G3绩效折算折前/折后)
**现状**`performance-records` 接口定义了 `hoursRaw`(折前)和 `hours`折后WXML 有条件展示逻辑,但 `totalHoursRawLabel` 始终为空。
**需求**
- "绩效折前"/"折前"指 DWS 层定义的绩效惩罚规则计算出的折算前课时
- 后端接口返回 `hours`(折后)和 `hoursRaw`(折前),当两者不同时前端展示"折前 Xh"
- 汇总统计同理:`totalHoursRaw``totalHours` 时展示"折前 Xh"
**验收标准**
- AC-G3.1:存在折算差异时,课时旁显示"折前 Xh"灰色小字
- AC-G3.2:无折算差异时不显示"折前"
### G4储值等级显示规则
**现状**`task-detail` 声明了 `storageLevel` 但无计算逻辑。
**需求**
- 根据客户储值余额balance计算等级文案
- `= 0` → "无"
- `< 200` → "少"
- `< 500` → "一般"
- `< 1500` → "多"
- `≥ 1500` → "非常多"
- 计算在前端完成(后端返回 balance 数值),工具函数放 `utils/storage-level.ts`
**验收标准**
- AC-G4.1task-detail 储值区域根据 balance 正确显示等级文案
- AC-G4.2balance 为 0 时显示"无"
---
## 二、各页面功能点
### P1task-list任务列表
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T1.1 | banner 头像 | `avatarUrl` 声明但未赋值 | 见 G1 |
| T1.2 | 比同期数据 | PerfData 有 `incomeTrend`/`incomeTrendDir`,但值为空 | 后端返回与上月同期的差值(数值,非百分比),如 `+¥1,200` / `-¥800`;前端展示数值 + ↑/↓ 箭头 |
| T1.3 | 放弃原因 | `abandonReason` 硬编码空字符串 | 从后端 task 对象的 `abandonReason` 字段获取,展示放弃时填写的备注文本 |
| T1.4 | 盖戳动画 | ✅ 已实现 | 确认:任何情况下页面加载后盖戳动画都会播放(当前仅 `tierCompleted` 时触发),需改为始终播放 |
**验收标准**
- AC-T1.2:比同期显示为数值差(如 `+¥1,200`),非百分比
- AC-T1.3:已放弃任务卡片展示放弃备注
- AC-T1.4:页面加载后盖戳动画始终播放,不依赖 `tierCompleted`
### P2performance绩效总览
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T2.1 | banner 头像 | 无 `avatarUrl` 字段 | 见 G1 |
| T2.2 | 预估/实际收入 | 硬编码"预估" | 见 G2 |
### P3performance-records绩效明细
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T3.1 | banner 头像 | 无 `avatarUrl` 字段 | 见 G1 |
| T3.2 | 预估/实际收入 | 硬编码"预估" | 见 G2 |
| T3.3 | 总笔数 | 前端 `records.length` 计算 | 后端接口返回 `totalCount` 字段,前端直接使用(分页场景下前端计数不准确) |
| T3.4 | 绩效折前 | 接口有字段但 label 始终为空 | 见 G3 |
### P4task-detail任务详情
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T4.1 | 手机号码 | `onCopyPhone``phone = ''` 硬编码 | 从 `this.data.detail` 获取客户手机号(后端 task 详情接口返回 `customerPhone` |
| T4.2 | 储值显示规则 | `storageLevel` 无计算逻辑 | 见 G4从 detail 的 balance 字段计算 |
| T4.3 | 行动建议 | 仅有"问问助手"跳转 chat | 后端 task 详情接口返回 `actionSuggestions: string[]`AI 生成的行动建议列表),前端在维客线索下方展示为卡片列表 |
| T4.4 | 备注打星 | ✅ 已实现 | star-rating 组件 + note-modal 爱心/台球双维度评分已完整 |
### P5customer-service-records客户服务记录
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T5.1 | 客户名称 | ✅ 已实现 | 从 fetchCustomerDetail 获取 |
| T5.2 | 本月服务次数 | 从 detail 取 `totalServiceCount`,类型断言 `as any` | 后端 `GET /api/xcx/customers/:id` 返回 `totalServiceCount` 字段,前端类型定义补齐 |
| T5.3 | 课程标签 | getTypeLabel 基于 includes 硬编码匹配 | 后端返回 `courseType` 枚举basic/vip/incentive/recharge/snooker/group前端直接映射不再 includes 猜测 |
### P6board-finance财务看板
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T6.1 | AI 智能洞察 | WXML 硬编码 3 行文案 | 后端接口返回 `aiInsights: Array<{ icon: string; text: string }>`,前端动态渲染 |
| T6.2 | 环比箭头 | ✅ 已实现 | compareEnabled 开关 + ↑/↓ 箭头 + 颜色区分 |
| T6.3 | 本月"预估" | 无预估标记 | 见 G2 |
### P7board-customer客户看板
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T7.1 | 爱心 icon | ✅ 已实现 | heart-icon 组件 + getHeartEmoji 映射 |
### P8customer-detail客户详情
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T8.1 | 电话 | ✅ 已实现 | onTogglePhone + onCopyPhone 完整 |
### P9board-coach助教看板
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T9.1 | 级别 Icon | ✅ 已实现 | coach-level-tag 组件 + LEVEL_CLASS 映射 |
### P10coach-detail助教详情
| 编号 | 功能点 | 现状 | 需求 |
|------|--------|------|------|
| T10.1 | 任务执行数量统计 | `taskStats` 硬编码 `{ recall: 24, callback: 14 }` | 后端接口返回 `taskStats: { recall: number; callback: number }`,前端从 API 获取 |
---
## 三、数据格式统一标准
### 已有格式化工具函数(但并没有进行全部调用)
| 类型 | TS 函数 | WXS 函数 | 格式示例 | 缺省值 |
|------|---------|----------|----------|--------|
| 金额 | `formatMoney(value)` | `money(value)` | ¥12,680 / -¥368 / ¥0 | -- |
| 计数 | `formatCount(value, unit)` | `count(value, unit)` | 18笔 / 3次 / 12人 | -- |
| 百分比 | `formatPercent(value)` | `percent(value)` | 12.5% / 0% | -- |
| 课时 | `formatHours(hours)` | `hours(value)` | 86h / 72.5h / 0h | -- |
| 相对时间 | `formatRelativeTime(value)` | — | 刚刚 / 3分钟前 / 2天前 / 03-10 | -- |
| 截止日期 | `formatDeadline(deadline)` | — | 逾期3天 / 今天到期 / 还剩5天 / 03-15 | -- |
| IM 时间 | `formatIMTime(value)` | — | 14:30 / 03-10 14:30 | (空字符串) |
| 爱心 | `getHeartEmoji(score)` | — | 💖 / 🧡 / 💛 / 💙 | 💙 |
| 星级 | `scoreToHalfStar(score)` | — | 4.5 / 3.0 | 0 |
| 空值兜底 | — | `safe(val)` | (原值) | -- |
### 需要补充的格式化
| 类型 | 建议函数名 | 格式示例 | 缺省值 | 说明 |
|------|-----------|----------|--------|------|
| 日期(短) | `formatDateShort(date)` | 3月15日 / 03-15 | -- | 用于服务记录、充值日期等 |
| 日期(完整) | `formatDateFull(date)` | 2026-03-15 | -- | 用于历史数据、导出 |
| 天数 | `formatDays(days)` | 3天 / 15天 | -- | 用于"N天前到店"、逾期天数 |
| 储值等级 | `formatStorageLevel(balance)` | 无/少/一般/多/非常多 | 无 | 见 G4 |
| 同比差值 | `formatTrendValue(value)` | +¥1,200 / -¥800 | -- | 用于"比同期"展示 |
### 接口端格式对齐原则
1. 后端返回原始数值number前端负责格式化展示
2. 金额单位统一为"元"(整数),前端加 ¥ 前缀和千分位
3. 课时单位统一为"小时"number前端加 h 后缀
4. 日期统一 ISO 8601 格式(`YYYY-MM-DDTHH:mm:ss`),前端按场景格式化
5. 百分比后端返回 0-100 的 number前端加 % 后缀
6. 所有 null/undefined/0 值,前端统一展示为 `--`(通过 format 函数兜底)
---
## 四、实施优先级
### 第一批(阻塞联调)
- G1 微信头像(影响 3 个页面 banner
- T1.3 放弃原因(后端字段直通)
- T4.1 手机号码(后端字段直通)
- T3.3 总笔数(后端字段直通)
- T10.1 任务执行统计(后端字段直通)
### 第二批(业务逻辑)
- G2 当月预估判断(纯前端逻辑)
- G3 绩效折算展示(前端条件渲染)
- G4 储值等级规则(前端计算)
- T1.2 比同期数据(需后端新增字段)
- T1.4 盖戳动画始终播放(前端逻辑调整)
- T5.3 课程标签枚举化(前后端对齐)
### 第三批(增强体验)
- T4.3 行动建议(需后端 AI 接口)
- T6.1 AI 智能洞察(需后端 AI 接口)
- 格式化工具函数补充
---
## 五、涉及文件清单
### 前端
- `apps/miniprogram/miniprogram/services/api.ts`
- `apps/miniprogram/miniprogram/utils/money.ts`
- `apps/miniprogram/miniprogram/utils/time.ts`
- `apps/miniprogram/miniprogram/utils/storage-level.ts`(新建)
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/miniprogram/miniprogram/pages/performance/performance.ts`
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts`
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts`
### 后端(接口变更)
- `GET /api/xcx/me` — 补充 avatarUrl 字段
- `GET /api/xcx/tasks` — performance 补充 `trendValue`(同比差值)
- `GET /api/xcx/tasks/:id` — 补充 `customerPhone``actionSuggestions`
- `GET /api/xcx/customers/:id` — 类型定义补充 `totalServiceCount`
- `GET /api/xcx/performance/records` — 补充 `totalCount``hoursRaw`
- `GET /api/xcx/coaches/:id` — 补充 `taskStats`
- `GET /api/xcx/board/finance` — 补充 `aiInsights``isEstimated`

View File

@@ -0,0 +1,511 @@
# P14AI 模块改造 — DashScope 迁移 + 调度器完善
> 状态Draft | 创建日期2026-03-21 | 依赖P5AI 集成层、RNS1.4CHAT 对齐)
> 后续P15监控后台 + 测试重建 + 回填)
---
## 一、执行摘要
当前 AI 模块使用 `openai` SDK 的通用模型 API`chat.completions.create`),但项目的 8 个 App 均为百炼控制台创建的智能体应用(各有独立 `app_id`)。通用模型 API 不接受 `app_id`,等于绕过了百炼控制台配置的 System Prompt、MCP 工具等全部能力。
本 PRD 将 SDK 从 `openai` 切换到 `dashscope`Application API一步到位完成迁移同时修复调度器、事件触发、熔断限流等核心问题。
### 问题来源
- `docs/reports/2026-03-21__ai_module_issues.md`18 个问题4 P0 / 6 P1 / 5 P2 / 3 P3
- AI 全链路测试报告 + 86 项 Gap 审查
### 改动范围
| 模块 | 改动内容 |
|------|---------|
| `apps/backend/app/ai/` | SDK 替换、客户端重写、调度器修复 |
| `apps/backend/app/services/ai/` | 缓存服务、对话服务适配 |
| `apps/backend/app/routers/` | 内部触发 API、SSE 端点适配 |
| `apps/etl/connectors/feiqiu/` | DWS 完成后 HTTP 触发 |
| `db/zqyy_app/` | DDL 迁移(新增表、字段) |
| `.env` / `.env.template` | 环境变量统一 |
---
## 二、技术方案
### 2.1 SDK 替换openai → dashscope Application API
**当前**`openai.AsyncOpenAI` + `chat.completions.create`
**目标**`dashscope.Application.call` + `asyncio.to_thread()` 包装
#### 调用方式对比
```python
# ===== 当前(错误)=====
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=key, base_url=bailian_url)
response = await client.chat.completions.create(
model=model, messages=messages, response_format={"type": "json_object"}
)
# ===== 目标(正确)=====
import dashscope
from dashscope import Application
# App1 流式调用
response = Application.call(
app_id=app_id,
prompt=user_message,
session_id=session_id, # 云端对话管理
biz_params={"user_prompt_params": {"User_ID": uid, "Role": role, "Nickname": name}},
stream=True,
incremental_output=True,
)
# App2~8 单轮调用
response = Application.call(
app_id=app_id,
prompt=data_json, # 后端拼好的完整数据 JSON
)
```
#### async 包装
`dashscope.Application.call()` 是同步方法,当前后端全 async。采用 `asyncio.to_thread()` 包装:
```python
import asyncio
async def call_application(app_id: str, prompt: str, **kwargs) -> dict:
return await asyncio.to_thread(
Application.call,
app_id=app_id,
prompt=prompt,
**kwargs
)
```
流式调用App1需特殊处理`Application.call(stream=True)` 返回同步迭代器,需在线程中消费后通过 `asyncio.Queue` 桥接到 async generator。
### 2.2 环境变量统一
**废弃**(本轮直接删除,不保留兼容):
- `BAILIAN_API_KEY`
- `BAILIAN_BASE_URL`
- `BAILIAN_MODEL`
**新增**
- `DASHSCOPE_API_KEY` — DashScope API Key
- `DASHSCOPE_WORKSPACE_ID` — 百炼工作空间 ID可选
**保留不变**(仅改前缀注释):
| 旧变量 | 新变量 | 说明 |
|--------|--------|------|
| `BAILIAN_APP_ID_1_CHAT` | `DASHSCOPE_APP_ID_1_CHAT` | App1 通用对话 |
| `BAILIAN_APP_ID_2_FINANCE` | `DASHSCOPE_APP_ID_2_FINANCE` | App2 财务洞察 |
| `BAILIAN_APP_ID_3_CLUE` | `DASHSCOPE_APP_ID_3_CLUE` | App3 维客线索 |
| `BAILIAN_APP_ID_4_ANALYSIS` | `DASHSCOPE_APP_ID_4_ANALYSIS` | App4 关系分析 |
| `BAILIAN_APP_ID_5_TACTICS` | `DASHSCOPE_APP_ID_5_TACTICS` | App5 话术参考 |
| `BAILIAN_APP_ID_6_NOTE` | `DASHSCOPE_APP_ID_6_NOTE` | App6 备注分析 |
| `BAILIAN_APP_ID_7_CUSTOMER` | `DASHSCOPE_APP_ID_7_CUSTOMER` | App7 客户分析 |
| `BAILIAN_APP_ID_8_CONSOLIDATE` | `DASHSCOPE_APP_ID_8_CONSOLIDATE` | App8 维客线索整理 |
### 2.3 App1 对话管理session_id 云端 + 本地双轨
**策略**
1. 每次 App1 调用携带 `session_id`(百炼云端管理上下文)
2. 同时将消息写入本地 `ai_messages` 表(持久化)
3. `session_id` 过期1 小时无请求)时,从本地 `ai_messages` 重建 `messages` 数组传给百炼
4. 对话复用规则不变task 入口无时限复用、customer/coach 入口 3 天时限、general 入口始终新建
**session_id 生成规则**
- 格式:`conv_{conversation_id}_{created_timestamp}`
- 存储在 `ai_conversations.session_id` 字段(新增)
**过期重建流程**
```
用户发消息 → 查 ai_conversations 获取 session_id
→ 尝试用 session_id 调用百炼
→ 成功 → 正常返回
→ 失败session 过期)→ 从 ai_messages 加载历史
→ 用 messages 数组(不带 session_id调用百炼
→ 百炼返回新 session_id → 更新本地
```
### 2.4 App2~8 单轮调用
所有非对话类应用统一为单轮 `prompt` 调用:
- 后端用 `build_prompt()` 拼好完整数据 JSON
- 通过 `prompt` 参数传入 `Application.call`
- 百炼侧 System Prompt 已在控制台配置,代码不再维护
**JSON 兜底策略**:百炼返回非合法 JSON 时,纯重试,最大 3 次。不做本地解析修复。
### 2.5 App1 参数传递
App1 使用 `biz_params.user_prompt_params` 传模板变量:
- `User_ID`:用户 ID
- `Role`角色member / assistant / admin
- `Nickname`:昵称
同时用 `prompt` 传页面上下文source_page、page_context、screen_content
App2~8 不使用 `biz_params`,数据 JSON 直接作为 `prompt` 传入。
---
## 三、熔断 / 限流 / 降级
### 3.1 熔断器
```
连续 5 次失败 → 熔断 60 秒(所有请求直接返回降级响应)
→ 60 秒后进入半开状态 → 放行 1 个请求
→ 成功 → 关闭熔断
→ 失败 → 重新熔断 60 秒
```
熔断粒度:按 `app_id` 独立计数。App1 熔断不影响 App2~8。
### 3.2 Token 预算控制
| 维度 | 默认上限 | 说明 |
|------|---------|------|
| 日预算 | 100,000 tokens | 所有 App 合计 |
| 月预算 | 2,000,000 tokens | 所有 App 合计 |
超预算时:
- App1用户对话返回友好提示"AI 服务今日额度已用完,请明天再试"
- App2~8后台任务跳过执行记录 `budget_exceeded` 状态
预算数据来源:`ai_run_logs.tokens_used` 按日/月聚合。
### 3.3 限流
- App1每用户每分钟 10 次
- App2~8每门店每小时 100 次(合计)
---
## 四、调度器修复
### 4.1 dispatcher.py asyncio 修复
当前问题:`dispatcher.py` 中存在 `asyncio.run()` 嵌套调用,在已有事件循环的 FastAPI 环境中会报错。
修复方案:
- 移除所有 `asyncio.run()` 调用
- 所有调度入口改为 `async def`
- 使用 `asyncio.create_task()` 发起异步任务
- 超时控制用 `asyncio.wait_for()`
### 4.2 事件触发打通
#### 消费事件ETL 侧发射)
```
ETL DWS 任务完成
→ HTTP POST /api/internal/ai/trigger
→ payload: { event_type: "consumption", connector_type: "feiqiu", site_id, member_id, settlement_id }
→ 后端写 ai_trigger_jobs 记录
→ 异步执行调用链App3 → App8 → App7+ App4 → App5 如有助教)
```
内部 API 设计为连接器无关接口,`connector_type` 字段标识来源,为多平台扩展预留。
#### 备注事件(后端 API 路由发射)
```
小程序助教提交备注
→ 后端 API 路由 fire_event
→ event_type: "note_created"
→ 调用链App6 → App8
```
#### 任务分配事件task_manager 自动触发)
```
task_manager 自动分配任务
→ fire_event: "task_assigned"
→ 调用链App4 → App5
```
### 4.3 App2 预生成
- 触发时机DWS 完成后ETL 通过内部 API 触发
- 门店范围:当前写死 `2790685415443269`(多门店支持记入 BACKLOG
- 时间维度8 个(今日/昨日/本周/上周/本月/上月/本季/上季)
- 每日调用量1 门店 × 8 维度 = 8 次
### 4.4 幂等与去重
| 场景 | 策略 |
|------|------|
| 自动触发 | 按 `(event_type, member_id, site_id, date)` 去重,重复事件跳过 |
| 手动重跑 | 允许强制执行,`ai_trigger_jobs.is_forced = true`,后台明显标记 "forced" |
| App8 | 强幂等DELETE + INSERT `member_retention_clue`,同一 member 同一天只执行一次 |
---
## 五、缓存策略
### 5.1 过期时间(按 App 分开)
| App | cache_type | expires_at 策略 |
|-----|-----------|----------------|
| App2 | app2_finance | 当日 23:59:59每日刷新 |
| App3 | app3_clue | 7 天 |
| App4 | app4_analysis | 7 天 |
| App5 | app5_tactics | 7 天 |
| App6 | app6_note_analysis | 30 天 |
| App7 | app7_customer_analysis | 7 天 |
| App8 | app8_clue_consolidated | 7 天 |
### 5.2 ai_cache 新增字段
```sql
ALTER TABLE biz.ai_cache ADD COLUMN status VARCHAR(20) DEFAULT 'valid'
CHECK (status IN ('valid', 'expired', 'invalidated', 'generating'));
```
- `valid`:有效缓存
- `expired`:已过期(定时任务标记)
- `invalidated`手动失效admin-web 操作)
- `generating`:正在生成中(防并发)
### 5.3 数据保留上限
| App | 保留策略 |
|-----|---------|
| App1 | 不自动删除(用户对话记录) |
| App2~8 | 每个 App 保留最新 20,000 条 `ai_cache` 记录 |
---
## 六、数据库变更
所有新表放在 `biz` schema。
### 6.1 新增表ai_run_logsAI 运行记录)
```sql
CREATE TABLE biz.ai_run_logs (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
app_type VARCHAR(30) NOT NULL, -- app1_chat / app2_finance / ...
trigger_type VARCHAR(20) NOT NULL, -- user / scheduled / event / forced
member_id BIGINT, -- 关联会员(可空)
request_prompt TEXT, -- 输入 prompt截断前 2000 字符)
response_text TEXT, -- 输出全文
tokens_used INTEGER DEFAULT 0,
latency_ms INTEGER, -- 响应耗时(毫秒)
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending / running / success / failed / timeout / budget_exceeded
error_message TEXT,
session_id VARCHAR(100), -- App1 百炼 session_id
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_ai_run_logs_site_app ON biz.ai_run_logs(site_id, app_type);
CREATE INDEX idx_ai_run_logs_created ON biz.ai_run_logs(created_at);
CREATE INDEX idx_ai_run_logs_status ON biz.ai_run_logs(status);
```
### 6.2 新增表ai_trigger_jobs调度运行记录
```sql
CREATE TABLE biz.ai_trigger_jobs (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
event_type VARCHAR(30) NOT NULL, -- consumption / note_created / task_assigned / scheduled / manual
connector_type VARCHAR(30) DEFAULT 'feiqiu', -- 连接器类型(多平台预留)
member_id BIGINT,
payload JSONB, -- 事件原始数据
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending / running / completed / failed / skipped_duplicate / budget_exceeded
is_forced BOOLEAN DEFAULT false, -- 手动强制执行标记
app_chain VARCHAR(100), -- 执行链:如 "app3→app8→app7"
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ai_trigger_jobs_site ON biz.ai_trigger_jobs(site_id, event_type);
CREATE INDEX idx_ai_trigger_jobs_dedup ON biz.ai_trigger_jobs(event_type, member_id, site_id, (created_at::date))
WHERE status NOT IN ('skipped_duplicate');
CREATE INDEX idx_ai_trigger_jobs_status ON biz.ai_trigger_jobs(status);
```
### 6.3 ai_conversations 新增字段
```sql
ALTER TABLE biz.ai_conversations ADD COLUMN session_id VARCHAR(100);
```
### 6.4 ai_cache 新增字段
```sql
ALTER TABLE biz.ai_cache ADD COLUMN status VARCHAR(20) DEFAULT 'valid'
CHECK (status IN ('valid', 'expired', 'invalidated', 'generating'));
```
---
## 七、内部 API 设计
### 7.1 ETL → 后端触发接口
```
POST /api/internal/ai/trigger
Authorization: Internal-Token {INTERNAL_API_TOKEN}
Content-Type: application/json
{
"event_type": "consumption", // consumption / dws_completed
"connector_type": "feiqiu",
"site_id": 2790685415443269,
"member_id": 12345, // 可选
"payload": { // 事件附加数据
"settlement_id": "xxx",
"dws_task": "DWS_MEMBER_CONSUMPTION"
}
}
Response 200:
{
"trigger_job_id": 1001,
"status": "pending"
}
```
### 7.2 认证方式
内部 API 使用独立的 `INTERNAL_API_TOKEN` 环境变量,不走 JWT。ETL 进程通过 HTTP Header 传递。
---
## 八、文件变更清单
### 需要重写的文件
| 文件 | 说明 |
|------|------|
| `apps/backend/app/ai/bailian_client.py` | 完全重写为 `dashscope_client.py` |
| `apps/backend/app/ai/dispatcher.py` | asyncio 修复 + 事件触发链 |
| `apps/backend/app/ai/config.py` | 环境变量 BAILIAN_* → DASHSCOPE_* |
### 需要新增的文件
| 文件 | 说明 |
|------|------|
| `apps/backend/app/ai/circuit_breaker.py` | 熔断器实现 |
| `apps/backend/app/ai/rate_limiter.py` | 限流器实现 |
| `apps/backend/app/ai/budget_tracker.py` | Token 预算追踪 |
| `apps/backend/app/routers/internal_ai.py` | 内部触发 API 路由 |
| `db/zqyy_app/migrations/YYYYMMDD_ai_run_logs.sql` | DDL 迁移脚本 |
### 需要修改的文件
| 文件 | 说明 |
|------|------|
| `apps/backend/app/services/ai/cache_service.py` | 新增 status 字段处理 |
| `apps/backend/app/services/ai/chat_service.py` | session_id 双轨逻辑 |
| `apps/etl/connectors/feiqiu/tasks/` | DWS 完成后 HTTP 触发 |
| `.env` / `.env.template` | 环境变量更新 |
| `pyproject.toml` | 依赖:移除 openai新增 dashscope |
---
## 九、问题修复映射
本 PRD 覆盖的问题(来自 `docs/reports/2026-03-21__ai_module_issues.md`
| 问题编号 | 优先级 | 描述 | 本 PRD 对应章节 |
|---------|--------|------|----------------|
| P0-1 | P0 | App3~8 JSON 格式化失败 | §2.4Application API 原生支持) |
| P0-2 | P0 | AI 事件未触发 | §4.2(事件触发打通) |
| P0-3 | P0 | App2 无定时机制 | §4.3App2 预生成) |
| P0-4 | P0 | ETL→AI 联动断裂 | §4.2 + §7.1(内部 API |
| P1-1 | P1 | 缓存过期未检查 | §5缓存策略 |
| P1-2 | P1 | 无限流/熔断 | §3熔断/限流/降级) |
| P1-3 | P1 | 无 Token 预算控制 | §3.2Token 预算) |
| P1-4 | P1 | dispatcher asyncio 问题 | §4.1asyncio 修复) |
| P1-5 | P1 | 前端无刷新机制 | 不在本 PRD 范围(前端改动) |
| P1-6 | P1 | FDW 数据获取不一致 | 不在本 PRD 范围(数据层) |
| P2-3 | P2 | 缓存清理过宽松 | §5.3(保留上限) |
| P2-5 | P2 | 对话记录无清理 | §5.3App1 不删,其他 2 万条) |
| P3-1 | P3 | 迁移 DashScope SDK | §2.1(核心改动) |
| P3-2 | P3 | 调度器独立化 | §4调度器修复 |
---
## 十、收尾标准流程
> 参考历史 Spec 收尾模板P5、P13、RNS1.4
### 10.1 DDL 迁移
1. 编写迁移脚本 `db/zqyy_app/migrations/YYYYMMDD_p14_ai_module.sql`
2. 在测试库 `test_zqyy_app` 执行并验证
3. 编写回滚脚本(逆序 DROP
4. 合并到 DDL 基线 `db/zqyy_app/ddl/`
### 10.2 BD 手册更新
- 更新 `docs/database/BD_Manual_ai_tables.md`:新增 `ai_run_logs``ai_trigger_jobs` 表结构
- 更新 `ai_cache``status` 字段说明
- 更新 `ai_conversations``session_id` 字段说明
### 10.3 文档同步
| 文档 | 更新内容 |
|------|---------|
| `docs/prd/ai-app-prompts.md` | 环境变量映射更新BAILIAN_* → DASHSCOPE_* |
| `apps/backend/README.md` | AI 模块架构说明更新 |
| `docs/DOCUMENTATION-MAP.md` | 新增文档条目 |
| `.env.template` | 环境变量模板更新 |
| `docs/deployment/EXPORT-PATHS.md` | 如有新输出路径则更新 |
### 10.4 属性测试
- 更新 `tests/` 下 AI 相关属性测试,适配新的 `dashscope` 调用方式
- 新增属性测试覆盖熔断器状态转换、Token 预算计算、去重逻辑
### 10.5 最终检查点
- [ ] 所有 8 个 App 通过 Application API 调用成功
- [ ] App1 流式输出正常session_id 双轨工作
- [ ] App2~8 返回合法 JSON
- [ ] 事件触发链完整消费→App3→App8→App7
- [ ] ETL → 后端内部 API 联通
- [ ] 熔断器在连续失败后正确触发
- [ ] Token 预算超限后正确降级
- [ ] 环境变量全部切换到 DASHSCOPE_*
- [ ] DDL 迁移脚本在测试库执行通过
- [ ] BD 手册已更新
- [ ] 属性测试全部通过
---
## 十一、风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| DashScope SDK 同步调用阻塞事件循环 | App1 响应延迟 | `asyncio.to_thread()` + 线程池大小限制 |
| session_id 过期重建增加 token 消耗 | 成本上升 | 限制重建时的历史消息条数(最近 20 条) |
| 百炼控制台 System Prompt 与代码不一致 | 输出质量下降 | 代码不再维护 System Prompt以控制台为准 |
| 环境变量一步切换导致部署遗漏 | 服务不可用 | 部署检查清单 + 启动时校验必需变量 |
| App8 写业务表member_retention_clue幂等失败 | 数据不一致 | 事务包裹 DELETE + INSERT失败自动回滚 |
---
## 十二、不在本 PRD 范围
以下内容由 P15 或后续 PRD 处理:
- admin-web AI 监控后台P15
- 全链路测试重建P15
- 历史回填P15
- 旧测试脚本归档P15
- 多门店支持BACKLOG
- 消息队列(单独 PRD
- Prompt 版本管理BACKLOG
- 前端刷新机制P1-5前端改动
- FDW 数据一致性P1-6数据层改动

View File

@@ -0,0 +1,316 @@
# P15AI 监控后台 + 测试重建 + 回填
> 状态Draft | 创建日期2026-03-21 | 依赖P14DashScope 迁移 + 调度器完善)
> 前置条件P14 全部完成并验证通过后,方可开始 P15 实施
---
## 一、执行摘要
P14 完成 SDK 迁移和调度器修复后,本 PRD 覆盖三个方面:
1. admin-web AI 监控后台(运行总览、调度状态、调用明细、手动操作)
2. 测试体系重建(旧脚本归档、新全链路测试、属性测试更新)
3. 历史数据回填(半年内活跃会员,<100 人)
### 问题覆盖
本 PRD 覆盖 `docs/reports/2026-03-21__ai_module_issues.md` 中的:
- P2-1App5 话术缺分类
- P2-2MCP 无健康检查
- P2-4全链路测试不完整
- P3-3Prompt 版本管理(仅监控展示部分)
---
## 二、admin-web AI 监控后台
### 2.1 技术基础
- 框架React + Vite + Ant Design已有 8 个页面,加页面即可)
- 认证JWT Bearer Token复用现有 admin 权限体系
- API 前缀:`/api/admin/ai/*`
- 权限:系统管理员全量可见,不脱敏,点开详情看原文
- 门店筛选:支持按 `site_id` 筛选查看
### 2.2 页面规划
#### 页面 1AI 运行总览Dashboard
| 区域 | 内容 | 数据来源 |
|------|------|---------|
| 顶部统计卡片 | 今日调用次数、成功率、Token 消耗、平均延迟 | `ai_run_logs` 聚合 |
| 趋势图 | 近 7 天调用量 + 成功率折线图 | `ai_run_logs` 按日聚合 |
| App 分布 | 各 App 调用占比饼图 | `ai_run_logs.app_type` 分组 |
| Token 预算 | 日/月预算使用进度条 | `ai_run_logs.tokens_used` 聚合 |
| 告警列表 | 最近失败/超时/熔断事件 | `ai_run_logs` WHERE status IN ('failed','timeout') |
#### 页面 2调度状态
| 区域 | 内容 | 数据来源 |
|------|------|---------|
| 触发任务列表 | 分页表格:事件类型、会员、状态、执行链、耗时 | `ai_trigger_jobs` |
| 筛选器 | event_type / status / site_id / 日期范围 | — |
| 操作列 | 查看详情、手动重跑 | — |
| 去重统计 | 今日跳过的重复事件数 | `ai_trigger_jobs` WHERE status='skipped_duplicate' |
#### 页面 3调用明细
| 区域 | 内容 | 数据来源 |
|------|------|---------|
| 调用记录表格 | app_type、trigger_type、member_id、tokens、延迟、状态 | `ai_run_logs` |
| 筛选器 | app_type / status / trigger_type / site_id / 日期范围 | — |
| 详情抽屉 | 点击行展开:完整 prompt、完整 response、error_message | `ai_run_logs` 单条 |
#### 页面 4手动操作
| 功能 | 说明 |
|------|------|
| 手动重跑 | 选择 App + 会员 + 门店,触发单次执行(标记 `is_forced=true` |
| 缓存失效 | 按 App / 会员 / 门店批量将 `ai_cache.status` 设为 `invalidated` |
| 成本二次确认 | 批量操作前展示预估调用次数和 Token 消耗,点"确认执行"后才真正执行 |
| 告警确认/忽略 | 对失败告警标记"已确认"或"忽略" |
### 2.3 后端 API 清单
```
GET /api/admin/ai/dashboard — 总览统计数据
GET /api/admin/ai/trigger-jobs — 调度任务列表(分页 + 筛选)
GET /api/admin/ai/trigger-jobs/:id — 调度任务详情
POST /api/admin/ai/trigger-jobs/:id/retry — 手动重跑
GET /api/admin/ai/run-logs — 调用记录列表(分页 + 筛选)
GET /api/admin/ai/run-logs/:id — 调用记录详情(含完整 prompt/response
POST /api/admin/ai/cache/invalidate — 批量缓存失效
GET /api/admin/ai/budget — Token 预算使用情况
POST /api/admin/ai/batch-run — 批量执行(需二次确认)
POST /api/admin/ai/batch-run/confirm — 确认批量执行
GET /api/admin/ai/alerts — 告警列表
POST /api/admin/ai/alerts/:id/ack — 确认告警
POST /api/admin/ai/alerts/:id/ignore — 忽略告警
```
### 2.4 成本二次确认流程
```
管理员选择批量操作(如:回填 50 个会员)
→ POST /api/admin/ai/batch-run
→ 后端计算50 会员 × 5 App = 250 次调用,预估 ~500K tokens
→ 返回 { batch_id, estimated_calls: 250, estimated_tokens: 500000 }
→ 前端展示确认弹窗:"本次将执行 250 次 AI 调用,预估消耗 50 万 tokens确认执行"
→ 管理员点"确认执行"
→ POST /api/admin/ai/batch-run/confirm { batch_id }
→ 后端异步执行
```
---
## 三、测试体系重建
### 3.1 旧测试脚本归档
以下 4 个脚本移至 `_archived/` 目录:
| 脚本 | 原位置 | 归档位置 |
|------|---------|---------|
| `ai_full_chain_test.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `test_chat_e2e.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `test_chat_ai_quality.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `test_bailian_apps.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `test_bailian_single.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `test_bailian_full.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `_run_ai_tests_remaining.py` | `scripts/ops/` | `scripts/ops/_archived/` |
| `test_ai_bailian.py` | `apps/backend/tests/` | `apps/backend/tests/_archived/` |
| `2026-03-21__ai_full_chain_test.md` | `docs/reports/` | `docs/reports/_archived/` |
归档时保留文件内容不变,仅移动位置。审计记录(`docs/audit/`)不删除,只停止引用。
### 3.2 新全链路测试
#### 测试覆盖矩阵
| 测试场景 | 覆盖 App | 验证点 |
|---------|---------|--------|
| App1 对话10 种入口) | App1 | SSE 流式、session_id 双轨、对话复用规则 |
| App2 定时预生成 | App2 | 8 个时间维度、缓存写入、JSON 合法性 |
| 消费事件触发链 | App3→App8→App7 | 事件传播、缓存写入、业务表写入 |
| 助教消费触发链 | App3→App8→App7→App4→App5 | 完整链路、助教关联 |
| 备注事件触发链 | App6→App8 | 备注分析、线索整合 |
| 任务分配触发链 | App4→App5 | 关系分析、话术生成 |
| 缓存命中 | App2~8 | 缓存有效期内直接返回、过期后重新生成 |
| 缓存失效 | App2~8 | admin-web 手动失效后重新生成 |
| 熔断触发 | 任意 App | 连续 5 次失败→熔断→半开→恢复 |
| Token 预算超限 | 任意 App | 超限后正确降级 |
| 失败记录 | 任意 App | `ai_run_logs` 记录完整、error_message 有值 |
| 后台可见性 | 全部 | admin-web 能看到所有运行记录和详情 |
| JSON 兜底 | App2~8 | 非法 JSON 重试 3 次 |
| 幂等验证 | App8 | 同一 member 同一天重复触发只执行一次 |
| 内容质量 | App2~8 | 返回内容包含必要字段、格式正确 |
#### App1 的 10 种入口
1. general通用对话始终新建
2. customer_detail客户详情页
3. coach_detail助教详情页
4. task_detail任务详情页
5. finance_overview财务总览页
6. member_list会员列表页
7. settlement_detail结算详情页
8. note_detail备注详情页
9. dashboard首页仪表盘
10. report报表页
### 3.3 属性测试更新
更新 `tests/` 下的 Hypothesis 属性测试:
| 属性测试 | 验证不变量 |
|---------|-----------|
| 熔断器状态机 | 状态转换合法性closed→open→half_open→closed/open |
| Token 预算计算 | 日/月聚合值 = 各条记录 tokens_used 之和 |
| 去重逻辑 | 相同 (event_type, member_id, site_id, date) 只产生一条非 skipped 记录 |
| 缓存过期 | expires_at 过期的缓存 status 必须为 expired |
| App8 幂等 | 同一 member 同一天的 member_retention_clue 只有一组记录 |
| session_id 重建 | 重建后的 messages 数组与本地 ai_messages 一致 |
| 限流计数 | 窗口内请求数不超过上限 |
---
## 四、历史数据回填
### 4.1 回填范围
半年内2025-09-21 ~ 2026-03-21三者并集
- 有消费记录的 member
- 有备注记录的 member
- 有任务变更的 member
预估规模:<100 会员。
门店范围:`site_id = 2790685415443269`(写死)。
### 4.2 回填策略
- 执行方式:专门脚本(`scripts/ops/ai_backfill.py`
- 分批执行:每批 10 个会员,批间间隔 5 秒
- 断点续跑:脚本记录已完成的 member_id 列表到本地文件,失败后从断点继续
- 不备份现有数据
- App8 写业务表(`member_retention_clue`DELETE + INSERT事务包裹
### 4.3 回填调用链
每个会员执行:
```
App3维客线索→ App8线索整理→ App7客户分析
如有助教关联:→ App4关系分析→ App5话术参考
如有备注:→ App6备注分析→ App8再次整合
```
### 4.4 成本预估
- 100 会员 × 平均 5 个 App = 500 次调用
- 每次约 2000 tokens → 总计约 100 万 tokens
- 执行前通过 admin-web 二次确认
---
## 五、数据保留策略
| 数据类型 | 保留策略 | 清理方式 |
|---------|---------|---------|
| App1 对话记录ai_conversations + ai_messages | 永久保留,不自动删除 | — |
| App2~8 缓存ai_cache | 每个 App 保留最新 20,000 条 | 定时任务:按 created_at 排序,超出部分 DELETE |
| AI 运行记录ai_run_logs | 保留 90 天 | 定时任务DELETE WHERE created_at < now() - 90 days |
| 调度记录ai_trigger_jobs | 保留 90 天 | 同上 |
清理定时任务建议每日凌晨 03:00 执行。
---
## 六、后端 API 变更汇总
### 新增路由文件
`apps/backend/app/routers/admin_ai.py`
### 新增 Service
`apps/backend/app/services/ai/admin_service.py` — 聚合查询、批量操作、告警管理
### 数据库查询优化
- `ai_run_logs` 的 Dashboard 聚合查询需要按日分区或添加 BRIN 索引
- `ai_trigger_jobs` 的去重查询已有复合索引§P14 六.2
---
## 七、问题修复映射
| 问题编号 | 优先级 | 描述 | 本 PRD 对应章节 |
|---------|--------|------|----------------|
| P2-1 | P2 | App5 话术缺分类 | 回填时验证 App5 输出包含分类字段 |
| P2-2 | P2 | MCP 无健康检查 | admin-web Dashboard 展示各 App 最近调用状态 |
| P2-4 | P2 | 全链路测试不完整 | §3.2(新全链路测试) |
| P3-3 | P3 | Prompt 版本管理 | admin-web 展示当前 App 配置(只读) |
---
## 八、收尾标准流程
### 8.1 DDL 迁移
本 PRD 无新增表P14 已创建)。如有字段调整:
1. 编写增量迁移脚本
2. 测试库验证
3. 合并基线
### 8.2 BD 手册更新
- 更新 `docs/database/BD_Manual_ai_tables.md`:补充 admin API 相关的查询模式说明
### 8.3 文档同步
| 文档 | 更新内容 |
|------|---------|
| `apps/admin-web/README.md` | 新增 AI 监控页面说明 |
| `apps/backend/README.md` | 新增 admin AI API 说明 |
| `docs/DOCUMENTATION-MAP.md` | 新增条目 |
| `docs/prd/ai-app-prompts.md` | 确认环境变量已全部更新 |
### 8.4 属性测试
- 新增 §3.3 中列出的 7 个属性测试
- 确保所有属性测试通过
### 8.5 最终检查点
- [ ] admin-web AI 监控 4 个页面功能正常
- [ ] 手动重跑功能正常,标记 forced
- [ ] 缓存失效功能正常
- [ ] 成本二次确认流程完整
- [ ] 旧测试脚本已归档到 `_archived/`
- [ ] 新全链路测试覆盖 15 个场景
- [ ] 属性测试全部通过
- [ ] 回填脚本执行完成,<100 会员数据完整
- [ ] 数据保留定时任务配置完成
- [ ] 所有文档已同步更新
---
## 九、风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 回填触发大量 API 调用 | 成本突增 | 分批执行 + admin-web 二次确认 |
| 回填中途失败 | 部分会员数据不完整 | 断点续跑机制 |
| admin-web 查询大量 ai_run_logs 性能差 | 页面卡顿 | 分页 + 索引 + 90 天保留策略 |
| 全链路测试依赖真实百炼 API | 测试不稳定 | Mock 模式 + 真实 API 模式双轨 |
---
## 十、不在本 PRD 范围
- 多门店支持BACKLOG
- 消息队列(单独 PRD
- Prompt 版本管理的编辑功能BACKLOG本轮仅展示
- 前端刷新机制P1-5
- FDW 数据一致性P1-6
- 企业微信/邮件告警推送(后续迭代)

View File

@@ -0,0 +1,178 @@
# P15 — 小程序前后端联调Mock → 真实 API 切换
> 创建时间2026-03-22
> 状态:草案
> 优先级P0dev-trace-log 的前置依赖)
> 关联 Specdev-trace-log调试工具链
---
## 一、背景
小程序前端已完成 H5 → 原生迁移h5-miniprogram-migration spec和格式统一P13 spec
26 个后端接口已全部实现API 契约文档确认)。但当前 `services/api.ts` 所有函数返回空 Mock 数据,
`request` 导入已注释,前端与后端之间没有真实的 HTTP 通信。
本 PRD 覆盖"前后端联调"中 dev-trace-log 未涵盖的部分:将小程序从 Mock 数据切换到真实后端接口。
## 二、目标用户与场景
- 用户:开发者(前后端联调阶段)
- 场景:小程序连接本地 FastAPI 后端localhost验证登录、审核、任务、AI 模块的完整数据流
## 三、切换范围
### 3.1 本期切换P0
| 模块 | 涉及页面 | 涉及接口 |
|------|---------|---------|
| 认证 | login、apply、reviewing、no-permission | AUTH-1~5login、dev-login、me、refresh、apply |
| 任务 | task-list、task-detail4 变体) | TASK-1~4列表、详情、操作、绩效概览 |
| 审核 | reviewing、apply | AUTH-3me状态判断、AUTH-5apply |
| AI | chat、chat-history | CHAT-1~3对话列表、发送消息/SSE、历史记录 |
### 3.2 本期不做
| 模块 | 页面 | 原因 |
|------|------|------|
| 看板 | board-finance、board-customer、board-coach | 用户明确排除 |
| 助教 | coach-detail | 非优先联调模块 |
| 客户 | customer-detail、customer-service-records | 非优先联调模块 |
| 备注 | notes | 非优先联调模块 |
| 绩效 | performance、performance-records | 非优先联调模块 |
| 个人 | my-profile | 非优先联调模块 |
## 四、需求清单
### REQ-1API 环境配置
**描述**:创建统一的 API 配置模块,支持开发/测试/生产环境切换。
**验收标准**
- 创建 `apps/miniprogram/miniprogram/config/api.ts`
- 定义 `API_BASE_URL` 常量,开发环境指向 `http://localhost:8000`
- 支持通过构建变量或条件编译切换环境
- `utils/request.ts` 从配置模块读取 base URL禁止硬编码
### REQ-2恢复真实 API 调用
**描述**:将 `services/api.ts` 从 Mock 数据恢复为真实 HTTP 请求。
**验收标准**
- 取消 `request` 导入的注释
- 移除 `delay()` 辅助函数
- 移除所有空 Mock 数据返回
- P0 模块认证、任务、审核、AI的 service 函数改为调用 `request()` 发起真实 HTTP 请求
- 非 P0 模块暂时保留 Mock添加 `// TODO: P15 后续批次切换` 注释)
### REQ-3请求层统一封装
**描述**:确保 `utils/request.ts` 提供完整的请求封装能力。
**验收标准**
- 自动附加 `Authorization: Bearer <token>` 请求头(从 Storage 读取)
- 统一解析响应格式 `{ code: 0, data: ... }` / `{ code: number, message: string }`
- code ≠ 0 时抛出业务错误(含 code 和 message
- 网络异常(超时、断连)抛出网络错误(区分于业务错误)
- Token 过期401自动尝试 refreshrefresh 失败跳转登录页
- 请求超时设置(默认 30sAI 接口 120s
### REQ-4错误处理与用户反馈
**描述**:切换到真实接口后,网络错误、超时、空数据等场景需要有明确的用户反馈。
**验收标准**
- 网络错误:显示"网络连接失败,请检查网络后重试"+ 重试按钮
- 请求超时:显示"请求超时,请稍后重试"+ 重试按钮
- 业务错误code ≠ 0显示后端返回的 message
- 鉴权失败401/403跳转登录页或显示无权限页
- 空数据:显示对应的空态页面(已有四态框架,确保正确触发)
- 所有错误场景记录到 console开发环境便于调试
- 页面级 `pageState` 四态loading/empty/error/normal在真实 API 场景下正确流转
### REQ-5数据格式适配验证
**描述**:验证前端数据结构与后端接口返回的数据结构一致,不一致处做适配。
**验收标准**
- 后端响应统一 camelCaseResponseWrapperMiddleware 已处理)
- 前端类型定义(`services/api.ts` 中的 interface与后端实际返回字段一一对应
- 分页格式对齐:`{ items: T[], total, page, pageSize }`
- 金额字段number 类型,保留 2 位小数,前端通过 `formatMoney()` 展示
- 时间字段ISO 8601 字符串,前端通过 `formatDateShort()` / `formatDateFull()` 展示
- 不一致处在 service 层做适配转换,不在页面层处理
### REQ-6SSE 流式对接AI 模块)
**描述**AI 对话模块的 SSE 流式响应需要特殊处理,不能用普通 HTTP 请求。
**验收标准**
- `services/api.ts` 中 chat 相关函数使用 SSE 方式调用后端
- 支持流式接收 token 并实时渲染到对话界面
- SSE 连接异常时显示错误提示并允许重试
- SSE 超时处理AI 响应可能较慢,超时阈值 120s
- 流式过程中显示"正在思考..."loading 态
### REQ-7登录流程对接
**描述**:小程序登录流程从 Mock 切换到真实微信登录 → 后端换 Token 流程。
**验收标准**
- 开发环境使用 `POST /api/xcx/dev-login`openid 模拟登录)
- 登录成功后将 access_token 和 refresh_token 存入 Storage
- `app.ts``checkAuthStatus()` 调用真实 `GET /api/xcx/me` 判断用户状态
- 根据用户 statusnew/pending/approved/rejected/disabled正确路由到对应页面
- Token 过期自动 refreshrefresh 失败清除 Storage 并跳转登录页
## 五、数据流
```
小程序页面
↓ 调用 service 函数
services/api.ts
↓ 调用 request()
utils/request.ts
↓ wx.request() + Authorization header
↓ 响应解析 + 错误处理
FastAPI 后端 (localhost:8000)
↓ ResponseWrapperMiddleware (camelCase)
↓ { code: 0, data: ... }
返回页面 → 更新 data → 渲染
```
## 六、依赖与约束
| 依赖项 | 状态 | 说明 |
|--------|------|------|
| 后端 26 个接口 | ✅ 已实现 | API 契约文档确认 |
| API 契约文档 | ✅ 已就绪 | `docs/miniprogram-dev/API-contract.md` |
| 前端类型定义 | ✅ 已定义 | `services/api.ts` 中的 interface |
| 四态框架 | ✅ 已实现 | loading/empty/error/normal |
| 格式化工具函数 | ✅ 已实现 | P13 spec 已完成 |
| request.ts 封装 | ⚠️ 需验证 | 文件存在但当前未使用 |
| dev-trace-log | 🔜 并行开发 | 联调时提供调试可视化 |
## 七、验收标准(整体)
1. 登录流程dev-login → 获取 token → 自动路由到正确页面
2. 任务列表:真实数据加载、分页、筛选正常工作
3. 任务详情4 种变体页面正确渲染真实数据
4. AI 对话SSE 流式响应实时渲染,错误时有提示
5. 错误场景断网、超时、401、403、空数据均有正确的用户反馈
6. 非 P0 模块:保持 Mock 不受影响
## 八、风险
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 后端接口返回格式与前端类型不一致 | 页面渲染异常 | REQ-5 要求逐字段验证 |
| 微信开发者工具不支持 localhost | 无法联调 | 使用 dev-login 绕过微信登录;配置开发者工具"不校验合法域名" |
| SSE 在小程序中的兼容性 | AI 模块无法工作 | 需验证 wx.request 的 enableChunked 或使用 requestTask |
| Token 过期处理不完善 | 用户体验中断 | REQ-3 要求自动 refresh |
## 九、与 dev-trace-log 的关系
本 PRD 是 dev-trace-log 的前置依赖:
- 没有真实 API 调用 → trace 系统无数据可采集
- 建议并行推进:先完成 REQ-1~3基础设施再逐模块切换REQ-7 → REQ-2 → REQ-4~6
- 切换过程中使用 dev-trace-log 的 span 链路验证每个接口的完整处理流程

View File

@@ -0,0 +1,227 @@
# P16调度任务最小运行间隔机制 — task-min-run-interval
> 优先级P2调度系统增强
> 来源Session 58_5de84e40_195620 用户需求
> 预估工作量:中
> 依赖:无新增外部依赖
---
## 背景
现有调度系统(`scheduled_tasks` 表 + `scheduler.py`)支持 5 种调度类型once/interval/daily/weekly/cron但缺少"最小运行间隔"维度。部分 ETL 任务(如租户配置)实际只需 10 天运行一次,员工/助教信息 1 天一次,而订单类任务无此限制。
当前问题:
- 无法限制任务的最小再次运行间隔,调度到期即执行
- 无法防止同一任务并发执行(上一次未完成就再次入队)
- 手动执行(`POST /api/schedules/{id}/run`)无法区分"尊重间隔"和"强制执行"
---
## 需求Requirements
### 用户故事
1. 作为管理员,我需要为每个调度任务设置最小运行间隔(如 10 天、1 天),使任务即使调度到期也不会在间隔内重复执行,避免资源浪费。
2. 作为管理员,我需要在必要时强制执行某个任务(绕过最小间隔限制),以应对紧急数据同步需求。
3. 作为管理员,我需要在调度任务列表中看到每个任务的最小间隔配置和上次成功执行时间,以便了解任务运行状态。
### 验收标准
- AC1`scheduled_tasks` 表新增 `min_run_interval_value`INTEGER`min_run_interval_unit`VARCHAR`last_success_at`TIMESTAMPTZ3 个字段
- AC2调度器轮询时`now() - last_run_at < min_run_interval`(换算为秒),跳过本次执行并推进 `next_run_at`
- AC3任务执行失败时不更新 `last_success_at`,允许下次调度到期时立即重试(失败不算有效执行)
- AC4若任务 `last_status = 'running'`(上次未完成),跳过本次入队,标记为 `skipped_concurrent`
- AC5`POST /api/schedules/{id}/run` 新增 `force` 查询参数,`force=true` 时绕过最小间隔和并发检查
- AC6Admin Web 创建/编辑调度任务表单新增"最小运行间隔"字段(数字输入 + 单位下拉:分钟/小时/天)
- AC7Admin Web 任务列表新增"最小间隔"列和"上次成功"列
- AC8Admin Web "手动执行"按钮增加"强制执行"勾选项
- AC9现有调度任务 `min_run_interval_value` 默认 0无限制向后兼容
- AC10所有变更有对应的 BD 手册更新和审计记录
---
## 设计要点
### 核心概念
- **最小运行间隔**:任务开始执行后的最小等待时间(非完成后),基于 `last_run_at` 判断
- **有效执行**:仅成功完成的执行才更新 `last_success_at`;失败不重置间隔计时器,允许立即重试
- **并发保护**:若上一次执行仍在进行中(`last_status = 'running'`),跳过本次入队
- **强制执行**:通过 API `force=true` 参数绕过最小间隔和并发检查
### DDL 变更(`scheduled_tasks` 表)
新增 3 个字段:
| 字段名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `min_run_interval_value` | INTEGER | 0 | 最小间隔数值0 = 无限制) |
| `min_run_interval_unit` | VARCHAR(20) | `'minutes'` | 间隔单位:`minutes` / `hours` / `days` |
| `last_success_at` | TIMESTAMPTZ | NULL | 最后一次成功执行的时间 |
> `min_run_interval_value = 0` 表示无限制,与现有行为完全一致,确保向后兼容。
### 调度器逻辑修改(`scheduler.py`
`check_and_enqueue()` 方法的判断流程变更为:
```
对每个到期任务enabled=true AND next_run_at <= now
1. 并发检查if last_status == 'running' → 跳过,标记 skipped_concurrent
2. 间隔检查if min_run_interval_value > 0
a. 计算 min_interval_seconds = convert(value, unit)
b. if last_run_at IS NOT NULL AND (now - last_run_at) < min_interval_seconds
→ 跳过,推进 next_run_at标记 skipped_interval
3. 正常入队执行
```
SQL 查询扩展(新增读取字段):
```sql
SELECT id, site_id, task_config, schedule_config,
min_run_interval_value, min_run_interval_unit,
last_run_at, last_status
FROM scheduled_tasks
WHERE enabled = TRUE
AND next_run_at IS NOT NULL
AND next_run_at <= NOW()
ORDER BY next_run_at ASC
```
任务完成回调需区分成功/失败:
- 成功:`last_status = 'completed'`,同时更新 `last_success_at = NOW()`
- 失败:`last_status = 'failed'`,不更新 `last_success_at`
### API 扩展(`schedules.py`
| 端点 | 变更 |
|---|---|
| `POST /api/schedules` | `CreateScheduleRequest` 新增 `min_run_interval_value`int, default=0`min_run_interval_unit`str, default='minutes' |
| `PUT /api/schedules/{id}` | `UpdateScheduleRequest` 新增同上两个可选字段 |
| `GET /api/schedules` | `ScheduleResponse` 新增 `min_run_interval_value``min_run_interval_unit``last_success_at` |
| `POST /api/schedules/{id}/run` | 新增查询参数 `force: bool = False``force=true` 时绕过间隔和并发检查 |
### Admin Web 变更(`ScheduleTab.tsx`
**创建/编辑表单**
- 新增"最小运行间隔"行:`InputNumber`(数值)+ `Select`(单位:分钟/小时/天)
- 数值为 0 时显示提示"无限制"
- 位置:在调度类型配置区域下方
**任务列表表格**
- 新增"最小间隔"列:显示格式如"10 天"、"1 小时"、"无限制"
- 新增"上次成功"列:显示 `last_success_at` 的相对时间(如"2 小时前"
**手动执行**
- 现有"立即执行"按钮点击后弹出确认框
- 确认框新增"强制执行(忽略最小间隔)"勾选项,默认不勾选
- 勾选后调用 `POST /api/schedules/{id}/run?force=true`
### ETL 任务注册
ETL 侧 `TaskMeta``TaskRegistry` 不需要修改。最小运行间隔是调度层概念,在 `scheduled_tasks` 表中配置,与 ETL 任务注册解耦。
---
## 数据流向
```
Admin WebScheduleTab.tsx
├─ 创建/编辑表单 → min_run_interval_value + min_run_interval_unit
└─ 手动执行 → force=true/false
↓ APIschedules.py
数据库scheduled_tasks 表
├─ min_run_interval_value (INTEGER)
├─ min_run_interval_unit (VARCHAR)
└─ last_success_at (TIMESTAMPTZ)
↓ 调度器轮询scheduler.py每 30 秒)
判断逻辑:
1. 并发检查 → last_status == 'running' → 跳过
2. 间隔检查 → now - last_run_at < min_interval → 跳过
3. 正常入队 → task_queue.enqueue()
↓ 任务执行完成回调
更新 scheduled_tasks
- 成功 → last_status='completed', last_success_at=NOW()
- 失败 → last_status='failed'last_success_at 不变)
```
---
## 边界条件与风险
| 场景 | 处理方式 |
|---|---|
| `min_run_interval_value = 0` | 无限制,与现有行为一致 |
| `last_run_at IS NULL`(从未执行) | 跳过间隔检查,正常执行 |
| 任务执行失败后立即到期 | `last_success_at` 未更新,但间隔检查基于 `last_run_at`(开始时间),所以失败后仍需等待间隔。但因为失败不算有效执行,下次到期时 `last_run_at` 已过间隔,可正常执行 |
| 手动执行 + force=false + 间隔未到 | 返回 409 Conflict提示"最小运行间隔未到,距下次可执行还有 X 分钟" |
| 手动执行 + force=true | 绕过所有检查,直接入队 |
| 并发:上次仍在 running | 跳过入队,`last_status` 标记为 `skipped_concurrent`(不覆盖原 running 状态,仅日志记录) |
| 调度器重启后 | `last_run_at``last_success_at` 持久化在数据库,重启无影响 |
---
## 不做什么(明确排除)
- 不修改 ETL 任务注册机制(`TaskMeta`/`TaskRegistry`
- 不新增独立的 `task_run_policy` 表(直接在 `scheduled_tasks` 表扩展)
- 不提供批量 seed SQL 设定初始间隔值(用户逐个配置)
- 不修改 `schedule_config` JSONB 结构(新字段放在表级列)
- 不涉及 ETL 侧的执行逻辑修改
- 不涉及小程序或 MCP Server
---
## 任务清单
### 数据库层
- [x] T1DDL 迁移 — `scheduled_tasks` 新增 3 个字段
- 迁移脚本:`db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql`
- DDL 基线同步:`docs/database/ddl/zqyy_app__public.sql`
### 后端层
- [x] T2Schema 更新 — `ScheduleConfigSchema` 不变,`CreateScheduleRequest`/`UpdateScheduleRequest`/`ScheduleResponse` 新增字段
- 文件:`apps/backend/app/schemas/schedules.py`
- [x] T3调度器核心逻辑 — `check_and_enqueue()` 新增并发检查 + 间隔检查
- 文件:`apps/backend/app/services/scheduler.py`
- 新增辅助函数 `_convert_interval_to_seconds(value, unit)`
- [x] T4API 路由 — 创建/更新端点支持新字段,手动执行端点支持 `force` 参数
- 文件:`apps/backend/app/routers/schedules.py`
- [x] T5任务完成回调 — 区分成功/失败,成功时更新 `last_success_at`
- 文件:`apps/backend/app/services/task_queue.py`
### 前端层
- [x] T6ScheduleTab 表单 — 新增"最小运行间隔"输入(数字 + 单位下拉)
- 文件:`apps/admin-web/src/components/ScheduleTab.tsx`
- [x] T7ScheduleTab 列表 — 新增"最小间隔"列和"上次成功"列
- 文件:同 T6
- [x] T8手动执行确认框 — 新增"强制执行"勾选项
- 文件:同 T6
### 文档与验证
- [x] T9BD 手册更新 — `scheduled_tasks` 表新增字段说明
- 文件:`docs/database/BD_Manual_scheduled_tasks.md`
- [x] T10验证 — Monorepo 属性测试通过OpenAPI 契约同步;文档同步完成
### 涉及文件汇总
| 模块 | 文件路径 | 操作 |
|---|---|---|
| DDL 迁移 | `db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql` | 新增 |
| DDL 基线 | `docs/database/ddl/zqyy_app__public.sql` | 修改 |
| Schema | `apps/backend/app/schemas/schedules.py` | 修改 |
| 调度器 | `apps/backend/app/services/scheduler.py` | 修改 |
| API 路由 | `apps/backend/app/routers/schedules.py` | 修改 |
| 任务队列 | `apps/backend/app/services/task_queue.py` | 可能修改(回调) |
| Admin Web | `apps/admin-web/src/components/ScheduleTab.tsx` | 修改 |
| BD 手册 | `docs/database/BD_Manual_scheduled_tasks.md` | 新建/修改 |

View File

@@ -0,0 +1,516 @@
# P17助教客户归属与任务生成引擎 — 商业逻辑 PRD
> 版本v1.0 | 日期2026-03-24 | 作者Neo
> 依赖P4核心业务层、ETL INDEX 层RS/OS/MS/ML/WBI/NCI
---
## 0. 文档背景与目标
### 0.1 问题来源
本文档源自 2026-03-24 的三次会话(#20 / #21 / #22)对任务生成器的持续审查,核心发现两大系统性问题:
**问题 A — 客户归属失控**:任务生成器在分配"召回类"任务时,仅凭"是否绑定微信"来圈定助教候选池,而不是依据助教与客户之间真实发生的服务关系。导致全店所有客户的召回任务都堆给唯一绑定了微信的助教,任务量严重失衡。
**问题 B — 客户转移无保护**:当召回连续失败后需要将客户转给其他助教跟进,但现行逻辑缺少任何保护机制:
- 没有"门店助教规模保护"——助教数量未达标时就启动转移,容易混乱
- 没有"入驻时间保护"——新助教未经历足够交互就被分配陌生客户
- 没有"服务关系门槛"——客户有可能被转给从未服务过他的助教,关系冷启动成本极高
### 0.2 类比:助教 = 台球厅的销售 + 客户运营
助教的角色本质上同时承担两件事:
| 职能 | 销售视角 | 客户运营视角 |
|------|---------|-------------|
| 核心工作 | 把"流失/新客"召回到店并成交 | 和已服务客户维持稳定关系 |
| 核心指标 | WBI流失风险分、NCI新客转化分 | RS关系强度、MS升温动量 |
| 任务类型 | 高优先召回、优先召回 | 关系构建、客户回访 |
| 客户归属逻辑 | 谁有机会承接该客户的召回 | 谁是该客户的主责助教 |
因此,**客户归属**和**任务分配**需要两套紧密联动但逻辑独立的规则。
### 0.3 本文档目标
1. 定义清晰、可落地的**客户归属算法**(基于 OS/RS 四象限模型)
2. 定义完整、可解释的**任务生成算法**(基于归属约束 + 指数门槛 + 四种任务类型)
3. 定义**客户转移保护机制**(三重保护:规模保护 + 时间保护 + 关系门槛)
4. 给出每个决策点的**参数化方案**,支持门店级配置调整
---
## 1. 概念词典
| 术语 | 通俗解释 | 技术对应 |
|------|---------|----------|
| **RS关系强度分** | 助教和客户之间服务关系的紧密程度上过的课越多、越近期分越高0-10分 | `rs_display`,来自 `dws_member_assistant_relation_index` |
| **OS归属份额** | 在服务过该客户的所有助教中,该助教占多大比重;决定"主责/共管/待认领"标签 | `os_label ∈ {MAIN, COMANAGE, POOL, UNASSIGNED}` |
| **MS升温动量分** | 最近服务是在增多还是减少升温则分高降温则分低0-10分 | `ms_display` |
| **ML付费关联分** | 客户的消费台账中有多少是由该助教直接带来的0-10分 | `ml_display` |
| **WBI流失风险分** | 这个客户有多久没来了、来的频率是否下降分越高越需要主动联系0-10分 | `display_score`,来自 `dws_member_winback_index` |
| **NCI新客转化分** | 新客户被转化为回头客的紧迫程度0-10分 | `display_score`,来自 `dws_member_newconv_index` |
| **客户转移** | 原主责助教召回失败超过阈值后,系统将该客户的召回任务扩展给其他有服务关系的助教 | task_generator 中的转移逻辑 |
| **门店规模保护** | 若店内绑定微信的在职助教比例不足50%,禁用客户转移功能 | `guard_assistant_coverage_ratio` |
| **入驻时间保护** | 助教绑定微信后10天内不接收转移客户 | `guard_new_assistant_days` |
| **服务关系门槛** | 只把客户转给曾经服务过该客户的助教 | `os_label ≠ UNASSIGNED` |
---
## 2. 客户归属算法
### 2.1 设计原则
客户归属解决的问题是:**一个客户应该由哪个(些)助教负责跟进?**
台球厅的实际场景是:一个客户可能被多个助教服务过,但服务次数和亲密度差别很大。归属算法需要把这种模糊关系量化为清晰的"主责/共管/待认领/未归属"四个层级。
> 参考 CRM 行业最佳实践Salesforce/HubSpot 的 Account Ownership 模型):
> 当多名销售都服务过同一客户时,最优解不是"谁先认领谁拥有",而是用历史交互深度加权判断,避免资深关系被新人抢占。
### 2.2 OS 归属标签定义
OS 由 ETL `RelationIndexTask` 已实现,输出 `os_label` 字段,定义如下:
```
MAIN — 主责助教:在服务过该客户的助教中,该助教的 os_share 显著高于其他人
COMANAGE — 共管助教:多名助教的 os_share 差距不大RS 相对差 < 50%),均视为负责人
POOL — 待认领:有过服务记录,但 os_share 较低,属于潜在接管候选
UNASSIGNED — 无关联:从未为该客户提供过服务记录
```
**RS 相对差公式(来自 PRD 审阅 Q3.2**
```
对于助教 Ars=8和助教 Brs=5
相对差 = (8 - 5) / 8 = 0.375 < 0.5
→ 两人共管该客户COMANAGE
对于助教 Ars=9和助教 Brs=4
相对差 = (9 - 4) / 9 = 0.556 > 0.5
→ 助教 A 为主责MAIN助教 B 降为 POOL
```
### 2.3 归属判定流程
```
输入:某客户的所有 (assistant_id, rs_display) 记录
Step 1 — 过滤无效记录
rs_display = 0 → 视为无有效服务os_label = UNASSIGNED
Step 2 — 排序
按 rs_display DESC 排列所有助教
Step 3 — 主责判定
取最高分助教 A
若 A 是唯一有效助教,或与第二名 B 的相对差 ≥ 50%
→ A 标记为 MAIN其余有效助教标记为 POOL
Step 4 — 共管判定
若 A 与 B 的相对差 < 50%
→ 继续对 B 与 C 执行同样判断
→ 所有满足"与最高分相对差 < 50%"的助教标记为 COMANAGE
→ 其余有效助教标记为 POOL
Step 5 — 输出
写入 os_label + os_share + os_rank 字段(由 ETL 层计算,本 PRD 只消费结果)
```
### 2.4 归属与任务分配的映射关系
| os_label | 召回类任务 | 关系构建任务 |
|----------|-----------|-------------|
| MAIN | ✅ 有资格接收 | ✅ 有资格接收 |
| COMANAGE | ✅ 有资格接收 | ✅ 有资格接收 |
| POOL | ❌ 常规不分配;仅在"客户转移"条件触发后分配召回 | ❌ 不分配 |
| UNASSIGNED | ❌ 永不分配 | ❌ 永不分配 |
>
**关键改变**:将 WBI/NCI 召回任务的候选池从「绑定微信的助教」改为「对该客户 os_label ∈ {MAIN, COMANAGE} 的助教」。这是修复"小燕任务爆炸"问题的核心。
---
## 3. 客户转移机制
### 3.1 触发条件
客户转移是召回失败后的兜底机制,类似销售中的「线索升级」:
```
触发条件:
某客户的主责/共管助教MAIN/COMANAGE对该客户的召回任务
在连续 N 个任务周期内均未完成status ≠ completed
且 WBI 或 NCI 持续高于门槛值
默认参数:
consecutive_recall_fail_cycles = 3 连续3个生成周期未完成
min_wbi_for_transfer = 5.0 WBI > 5 才触发转移)
```
### 3.2 三重保护机制
客户转移在触发前,必须通过三道检查。任一检查不通过,本次不转移。
#### 保护 1 — 门店助教规模保护
```
规则:
若 (店内绑定微信的在职助教数 / 店内全部在职助教总数) < 0.5
→ 客户转移功能全局禁用
业务意义:
门店大部分助教都没绑定小程序时,系统对助教团队的覆盖率太低,
此时启动转移会造成信息盲区(被转出的任务助教看不到)。
只有当绑定率超过 50% 时,才能保障转移链路有效。
参数guard_assistant_coverage_ratio = 0.5(可配置)
```
#### 保护 2 — 入驻时间保护
```
规则:
助教首次绑定微信的时间binding_created_at距今不足 10 天
→ 该助教本轮不参与转移候选池
业务意义:
新助教刚入驻,还没有建立足够的客户印象,
贸然分配陌生客户会降低召回成功率,也打击新助教积极性。
10 天保护期给新助教建立自己客户基础的空间。
参数guard_new_assistant_days = 10可配置
```
#### 保护 3 — 服务关系门槛
```
规则:
待转入的助教对该客户的 os_label 必须 ∈ {POOL}
(即曾经服务过该客户,但目前归属份额较低)
os_label = UNASSIGNED 的助教永远不参与转移候选
业务意义:
从未服务过该客户的助教,关系完全冷启动。
不论技术上可以转,业务上也不应该这样做。
转移只在"有过接触但目前不是主责"的助教之间发生,
最大化利用已有的关系温度。
参数transfer_eligible_labels = ['POOL'](固定,不可放开到 UNASSIGNED
```
### 3.3 转移候选排序
通过三重保护后,对候选助教按以下优先级排序,取得分最高的 1 名(或多名,取决于配置):
```
转移得分 = w_rs × rs_display + w_ms × ms_display + w_ml × ml_display
默认权重:
w_rs = 0.5 (关系强度,历史服务深度)
w_ms = 0.3 (升温动量,关系是在改善还是冷却)
w_ml = 0.2 (付费关联,客户是否在该助教服务期间消费)
业务意义:
优先把客户转给"之前有服务基础、且关系正在升温、且有消费记录"的助教,
而不是随机转给"历史最高分"。MS 权重确保选的是当下状态最好的关系,
而不是过去最好的关系。
```
### 3.4 转移后的归属处理
```
转移发生后:
新助教的任务状态 = active高优先召回 or 优先召回)
原主责助教的同类型召回任务 status = 'transferred'(新增任务状态)
—— 不关闭原任务,而是标记为"已转移",供审计和历史查询
若转移后新助教也失败(连续 consecutive_recall_fail_cycles 次),
且 POOL 中还有其他候选助教,可再次转移
但每个客户的累计转移次数上限 = max_transfer_count默认 2 次)
超过上限后,任务进入 PENDING_REVIEW 状态,等待人工介入
```
---
## 4. 任务生成算法(重新设计版)
### 4.1 总体流程
```
每日 07:00 任务生成器 run() 执行:
Step 1 — 确定全店有效助教池
查询 dws_member_assistant_relation_index
取 os_label ∈ {MAIN, COMANAGE} 的所有 (assistant_id, member_id) 对
(放弃以 user_assistant_binding 为入口的旧逻辑)
Step 2 — 读取指数
对每个 assistant_id 关联的 member_id 集合:
WBI = dws_member_winback_index.display_score按 member_id 查)
NCI = dws_member_newconv_index.display_score按 member_id 查)
RS = dws_member_assistant_relation_index.rs_display按 assistant_id + member_id 查)
OS = dws_member_assistant_relation_index.os_label同上
MS = dws_member_assistant_relation_index.ms_display同上
Step 3 — 归属过滤(新增)
对每个 (assistant_id, member_id) 对:
若 os_label ∉ {MAIN, COMANAGE} → 跳过召回类任务判断
POOL 助教只在"客户转移"触发后才参与)
Step 4 — 任务类型判定 determine_task_type()
见第 4.2 节
Step 5 — 任务状态检查与写入
见第 4.3 节
Step 6 — 客户转移检查(独立子流程)
见第 3 节
Step 7 — 更新 trigger_jobs 时间戳
```
### 4.2 任务类型判定算法(四级漏斗)
```
function determine_task_type(os_label, wbi, nci, rs, has_pending_recall, has_follow_up_note):
priority_score = max(wbi, nci)
-- 漏斗第一级:高优先召回
if priority_score > 7 AND os_label ∈ {MAIN, COMANAGE}:
return 'high_priority_recall'
-- 漏斗第二级:优先召回
if priority_score > 5 AND os_label ∈ {MAIN, COMANAGE}:
return 'priority_recall'
-- 漏斗第三级:客户回访
-- (召回已完成 ETL 确认,但助教尚未提交备注)
if has_pending_recall == True AND has_follow_up_note == False:
return 'follow_up_visit'
-- 漏斗第四级:关系构建
-- RS ≤ 1 视为无有效交互,不生成任务
if 1 < rs < 6 AND os_label ∈ {MAIN, COMANAGE}:
return 'relationship_building'
return None -- 不生成任务
```
**四级漏斗的业务逻辑说明**
| 级别 | 任务 | 触发信号 | 业务含义 |
|------|------|---------|----------|
| 1 | 高优先召回 | WBI 或 NCI > 7且本人是主责/共管 | 客户流失风险极高,必须今天联系 |
| 2 | 优先召回 | WBI 或 NCI > 5且本人是主责/共管 | 客户有流失迹象,本周内联系 |
| 3 | 客户回访 | 召回成功但未备注ETL 已确认到店) | 召回成功后的温度维护,不能凉掉 |
| 4 | 关系构建 | RS 在 1-6 之间,关系有提升空间 | 日常维护客情,升温关系 |
> 注意:漏斗是互斥优先的。一个客户-助教对同一时刻只生成一条最高优先级的任务。
### 4.3 任务状态检查与写入逻辑
```
对每个 (assistant_id, member_id, new_task_type)
Case A — 已存在相同类型的 active 任务:
→ 跳过skip不更新 created_at
stats['skipped'] += 1
Case B — 已存在不同类型的 active 任务:
→ 将旧任务 status 改为 'inactive'
→ 创建新任务status = 'active'
→ 记录 coach_task_history
stats['replaced'] += 1
Case C — 不存在 active 任务:
→ 直接创建新任务
stats['created'] += 1
Case D — new_task_type = None
→ 检查 follow_up_visit 是否超过48小时 → inactive
stats['skipped'] += 1
```
### 4.4 关系构建任务的 RS 门槛说明
| RS 区间 | 含义 | 是否生成任务 |
|---------|------|------------|
| RS = 0 | 无有效服务数据 | 否 |
| RS ≤ 1 | 仅 1 次以下交互,关系未建立 | 否 |
| 1 < RS < 6 | 有初步关系但未牢固,黄金维护窗口 | 是(关系构建) |
| RS ≥ 6 | 关系已牢固,无需系统催动 | 否 |
---
## 5. 参数总览与配置说明
所有参数存储于 `biz.cfg_task_generator_params`,支持按 `site_id` 级别覆盖。
| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `high_priority_recall_threshold` | 7.0 | max(WBI,NCI) 超过此值生成高优先召回 |
| `priority_recall_threshold` | 5.0 | max(WBI,NCI) 超过此值生成优先召回 |
| `rs_min_for_relationship` | 1.0 | RS ≤ 此值不生成关系构建 |
| `rs_max_for_relationship` | 6.0 | RS ≥ 此值不生成关系构建 |
| `consecutive_recall_fail_cycles` | 3 | 连续失败多少轮触发客户转移 |
| `min_wbi_for_transfer` | 5.0 | WBI 低于此值不触发转移 |
| `guard_assistant_coverage_ratio` | 0.5 | 绑定率低于此值禁用转移 |
| `guard_new_assistant_days` | 10 | 新助教入驻保护天数 |
| `transfer_score_w_rs` | 0.5 | 转移候选排序RS 权重 |
| `transfer_score_w_ms` | 0.3 | 转移候选排序MS 权重 |
| `transfer_score_w_ml` | 0.2 | 转移候选排序ML 权重 |
| `max_transfer_count` | 2 | 单客户最大累计转移次数 |
| `follow_up_visit_retention_hours` | 48 | 回访任务最低保留时长(小时) |
## 6. 数据库变更需求
### 6.1 新增字段:`biz.coach_tasks`
```sql
-- 新增任务状态枚举值
ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'transferred';
ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'pending_review';
-- 新增转移追踪字段
ALTER TABLE biz.coach_tasks
ADD COLUMN transfer_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN transferred_from BIGINT REFERENCES biz.coach_tasks(id),
ADD COLUMN transferred_at TIMESTAMPTZ;
```
### 6.2 新增表:`biz.cfg_task_generator_params`
```sql
CREATE TABLE biz.cfg_task_generator_params (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT, -- NULL 表示全局默认值
param_key VARCHAR(64) NOT NULL,
param_value NUMERIC NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (site_id, param_key)
);
```
### 6.3 新增表:`biz.coach_task_transfer_log`
```sql
CREATE TABLE biz.coach_task_transfer_log (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
member_id BIGINT NOT NULL,
from_assistant_id BIGINT NOT NULL,
to_assistant_id BIGINT NOT NULL,
from_task_id BIGINT NOT NULL REFERENCES biz.coach_tasks(id),
to_task_id BIGINT REFERENCES biz.coach_tasks(id),
transfer_reason TEXT,
guard_checks JSONB, -- 三重保护检查结果
transfer_score NUMERIC,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
---
## 7. 核心流程伪代码(供开发参考)
### 7.1 任务生成器主流程(重写版)
```python
def run() -> dict:
stats = {"created": 0, "replaced": 0, "skipped": 0, "transferred": 0}
params = load_params() # 从 cfg_task_generator_params 加载
# Step 1: 以 OS 归属为入口(取代旧的 user_assistant_binding 入口)
ownership_pairs = query("""
SELECT assistant_id, member_id, os_label,
rs_display, ms_display, ml_display
FROM app.v_dws_member_assistant_relation_index
WHERE os_label IN ('MAIN', 'COMANAGE')
""")
# Step 2: 批量读取 WBI / NCI
member_ids = {p['member_id'] for p in ownership_pairs}
wbi_map = fetch_wbi(member_ids)
nci_map = fetch_nci(member_ids)
# Step 3: 逐对生成任务
for pair in ownership_pairs:
process_pair(pair, wbi_map, nci_map, params, stats)
# Step 4: 客户转移子流程
run_transfer_check(params, stats)
update_trigger_timestamp('task_generator')
return stats
```
### 7.2 客户转移子流程
```python
def run_transfer_check(params, stats):
# 保护 1: 门店规模检查
if coverage_ratio() < params['guard_assistant_coverage_ratio']:
return # 全局禁用
# 查找连续失败达阈值的 (member_id, assistant_id) 对
for candidate in find_failed_recall_candidates(params):
pool = get_pool_assistants(candidate['member_id'])
eligible = [
a for a in pool
if days_since_binding(a) >= params['guard_new_assistant_days'] # 保护 2
and a['os_label'] == 'POOL' # 保护 3
]
if not eligible:
continue
# 按转移得分选最优候选
best = max(eligible, key=lambda a:
params['w_rs'] * a['rs'] +
params['w_ms'] * a['ms'] +
params['w_ml'] * a['ml']
)
do_transfer(candidate, best, stats)
```
---
## 8. 验收标准Acceptance Criteria
| # | 验收项 | 判定方式 |
|---|--------|----------|
| AC1 | 召回任务只分配给 os_label ∈ {MAIN, COMANAGE} 的助教 | 数据库核查,无 UNASSIGNED/POOL 助教的召回任务 |
| AC2 | 关系构建任务 RS 门槛正确1 < RS < 6 | 检查 relationship_building 任务对应的 rs_display |
| AC3 | 客户转移通过三重保护 | transfer_log.guard_checks 全部 pass |
| AC4 | 新助教10天内不接收转移 | transfer_log 中 to_assistant binding 距转移时间 ≥ 10 天 |
| AC5 | 绑定率 < 50% 全局禁用转移 | 低覆盖率场景下 transfer_log 无新记录 |
| AC6 | 相同类型任务不重复生成 | 重复运行两次,第二次 skipped = 第一次 created |
| AC7 | 回访任务最低保留48小时 | 将 created_at 回拨49小时验证 expiry check |
| AC8 | 转移累计上限生效 | 第3次转移触发 pending_review 状态 |
---
## 9. 开放问题与后续讨论
| # | 问题 | 优先级 |
|---|------|--------|
| O1 | OS 标签每4小时更新是否足以支撑7:00任务生成需确认 ETL 完成时间窗口 | 高 |
| O2 | follow_up_visit 由召回完成检测器触发 vs 本 PRD Step 3 漏斗判定,两者需对齐触发逻辑 | 高 |
| O3 | POOL 助教何时晋升为 COMANAGE/MAINOS 算法是否有晋升路径,还是纯由 RS 数据自然演进 | 中 |
| O4 | pending_review 状态的任务如何人工干预需要管理后台支持P10 租户管理后台范畴) | 中 |
| O5 | 多门店场景下 cfg_task_generator_params 的继承逻辑(全局默认 → 门店覆盖) | 低 |
---
## 10. 与现有 PRD/代码的关系
| 文档/模块 | 关系说明 |
|-----------|----------|
| `docs/prd/specs/P4-miniapp-core-business.md` | 本 PRD 是 P4 中任务生成章节的细化和纠错版,以本文档为准 |
| `apps/backend/app/services/task_generator.py` | 需按本 PRD 重写 `run()``_process_assistant()`,主要改动是入口改为 OS 归属 |
| `apps/backend/app/services/fdw_queries.py` | 需新增 `get_ownership_pairs()` 查询方法 |
| `docs/prd/PRD审阅-Q&A.md` Q3.2 | RS 50% 相对差公式来源,本 PRD 已完整引用 |
| ETL `RelationIndexTask` | OS/RS/MS/ML 的计算源头,本 PRD 只消费其结果,不修改 ETL 层 |

View File

@@ -0,0 +1,743 @@
# P18管理后台 — 任务引擎运营看板与参数管理
> 版本v2.1-reviewed | 日期2026-03-24 | 作者AI待评审
> 依赖P17助教客户归属与任务生成引擎、P10租户管理后台部分复用
> 状态:评审通过,可进入实施
---
## 0. 文档背景与目标
### 0.1 问题来源
P17 实现了助教客户归属、任务生成、客户转移三大引擎能力,但所有数据仅存在于数据库中,运营团队无法:
- 查看客户转移日志(谁被转给了谁、为什么、三重保护检查结果)
- 审核 `pending_review` 状态的任务(转移次数超限后需人工介入)
- 按门店调整任务生成参数(召回阈值、转移保护参数等)
- 监控任务生成器的运行健康度(生成/替换/跳过/转移统计)
同时,现有 `http://localhost:5173/trigger-jobs` 定时任务页面功能较基础(仅展示 + 手动执行),需要评估是否扩展为更完整的调度监控面板。
### 0.2 本文档目标
1. 定义 admin-web 中 P17 相关的三个新页面(转移日志、待审核任务、参数管理)
2. 评估现有 trigger-jobs 页面的扩展需求
3. 明确后端 API 需求(含 Pydantic schema 定义)
4. 给出优先级排序和实施建议
5. 明确技术实现方案(前端组件结构、路由注册、权限控制)
### 0.3 技术栈概要
| 层 | 技术 |
|----|------|
| 前端 | React 19 + Vite 6 + Ant Design 5 + axios + Zustand + react-router-dom 7 |
| API 客户端 | `apps/admin-web/src/api/client.ts`baseURL = `/api`JWT 自动附加 + 401 自动刷新 |
| 后端 | FastAPI + Pydantic v2 + asyncpg |
| 认证 | JWT`CurrentUser` 含 user_id / site_id / roles`Depends(get_current_user)` |
| 响应包装 | 全局中间件自动 `{ code: 0, data: <body> }`,前端 axios 拦截器自动解包 |
---
## 1. 现有 trigger-jobs 页面评估
### 1.1 当前能力
| 功能 | 状态 |
|------|------|
| 列出所有定时任务(名称、触发方式、配置、状态) | ✅ 已实现 |
| 展示上次/下次执行时间 | ✅ 已实现 |
| 展示最近错误信息 | ✅ 已实现 |
| 手动执行按钮(需确认) | ✅ 已实现 |
| 刷新按钮 | ✅ 已实现 |
### 1.2 缺失能力(待评估)
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 执行历史记录 | 中 | 当前只有 last_run_at无法查看历史执行记录和每次的统计结果 |
| 任务启用/禁用开关 | 中 | 当前只能查看状态,无法在界面上切换 |
| 执行结果统计 | 高 | task_generator 的 created/replaced/skipped/transferred 统计应可视化 |
| Cron 表达式可编辑 | 低 | 当前触发配置只读,修改需直接改数据库 |
| 执行耗时监控 | 低 | 无执行耗时记录 |
### 1.3 建议
trigger-jobs 页面的核心定位是"调度监控",不宜过度膨胀。建议:
- 短期:在现有页面增加"最近执行结果"列(展示 task_generator 的 stats JSON
- 中期:新增"执行历史"抽屉(点击任务名展开,展示最近 N 次执行记录)
- 长期:考虑是否需要独立的"调度中心"页面(类似 Airflow UI
> **决策O6**:暂不新增 `biz.trigger_job_execution_log` 表。短期通过在 `trigger_jobs` 表新增 `last_stats JSONB` 字段记录最近一次执行统计即可满足需求。中期再评估是否需要完整执行历史表。
---
## 2. 新增页面设计
### 2.1 页面一客户转移日志Transfer Log
**路由**`/task-engine/transfer-log`
**目标用户**:运营管理员(超级管理员看全部,门店管理员看本店)
**数据源**`biz.coach_task_transfer_log`
**展示内容**
| 列 | 数据来源 | 说明 |
|----|---------|------|
| 转移时间 | `created_at` | 降序排列 |
| 门店 | `site_id``biz.sites.site_name` | JOIN 门店表 |
| 客户 | `member_id` → 会员昵称/手机号 | FDW 关联 ETL 库会员维度表 |
| 原助教 | `from_assistant_id` → 助教姓名 | FDW 关联 ETL 库助教维度表 |
| 新助教 | `to_assistant_id` → 助教姓名 | 同上 |
| 转移原因 | `transfer_reason` | 文本展示 |
| 转移得分 | `transfer_score` | 数值,保留 2 位小数 |
| 保护检查 | `guard_checks` | JSON → 三项检查结果(✅/❌) |
**筛选条件**
- 门店(下拉选择,门店管理员自动锁定本店)
- 时间范围(日期选择器,默认最近 7 天)
- 助教(搜索框,支持原助教/新助教)
**操作**
- 无写操作,纯查看
- 支持导出 CSV后续迭代
> **决策O3**:转移日志不关联 WBI/NCI/RS 快照。原因:(1) 转移时刻的指数值可通过 `transfer_reason` 文本和 `transfer_score` 间接推断;(2) 存储快照会增加 `coach_task_transfer_log` 表宽度,且历史指数可从 DWS 层按日期回溯。如后续运营反馈需要,可在 `guard_checks` JSONB 中追加指数快照字段,无需 DDL 变更。
---
### 2.2 页面二待审核任务Pending Review
**路由**`/task-engine/pending-review`
**目标用户**:运营管理员(超级管理员看全部,门店管理员看本店)
**数据源**`biz.coach_tasks WHERE status = 'pending_review'`
**展示内容**
| 列 | 数据来源 | 说明 |
|----|---------|------|
| 创建时间 | `created_at` | 降序排列 |
| 门店 | `site_id` → 门店名称 | |
| 客户 | `member_id` → 会员昵称 | FDW 关联 |
| 当前助教 | `assistant_id` → 助教姓名 | FDW 关联 |
| 任务类型 | `task_type` | 中文映射(见下方枚举表) |
| 累计转移次数 | `transfer_count` | |
| 优先级分 | `priority_score` | WBI/NCI 分值 |
**任务类型中文映射**
| task_type | 中文 |
|-----------|------|
| `high_priority_recall` | 高优先召回 |
| `priority_recall` | 优先召回 |
| `follow_up_visit` | 客户回访 |
| `relationship_building` | 关系构建 |
**操作**
| 操作 | 说明 |
|------|------|
| 重新分配 | 弹窗选择目标助教 → 调用 reassign API → 原任务标记 `transferred`,新任务 `active` |
| 关闭任务 | 弹窗填写关闭原因 → 调用 close API → 原任务标记 `inactive` |
| 查看转移历史 | 抽屉展示该客户的所有转移记录(复用转移日志 API |
> **决策O1**:重新分配的候选助教列表获取方式 — 复用 P17 的转移候选逻辑。后端新增 `GET /api/admin/task-engine/pending-review/{task_id}/candidates` 端点,内部调用 `fdw_queries.get_pool_assistants(member_id)` 获取 POOL 助教列表,按转移得分排序返回。前端展示为下拉选择框,显示助教姓名 + 转移得分。若 POOL 为空,候选列表返回空数组,界面提示"该客户暂无符合转移条件的助教,请联系 ETL 团队确认数据覆盖情况"不提供降级到全店助教的选项P17 明确规定 UNASSIGNED 助教永远不分配)。若确实有紧急人工干预需求,可提供"强制指定"按钮,需额外二次确认弹窗,且操作记录中标注 `source: "manual_override"`,写入 `transfer_log.transfer_reason`。
---
### 2.3 页面三任务引擎参数管理Task Engine Config
**路由**`/task-engine/config`
**目标用户**:超级管理员(门店管理员只读)
**数据源**`biz.cfg_task_generator_params`
**展示内容**
表格形式,按参数分组,每行展示全局默认值 + 各门店覆盖值:
| 列 | 说明 |
|----|------|
| 参数名 | `param_key`,中文描述 |
| 全局默认值 | `site_id IS NULL` 的记录 |
| 门店覆盖值 | 特定 `site_id` 的记录(无覆盖时显示"使用默认"灰色标签) |
| 说明 | `description` 字段 |
| 操作 | 编辑 / 删除覆盖 |
**参数列表**(来自 P17 第 5 节):
| 参数 | 默认值 | 中文说明 | 校验规则 |
|------|--------|---------|----------|
| `high_priority_recall_threshold` | 7.0 | 高优先召回阈值 | 0-10numeric |
| `priority_recall_threshold` | 5.0 | 优先召回阈值 | 0-10numeric |
| `rs_min_for_relationship` | 1.0 | 关系构建 RS 下限 | 0-10numeric |
| `rs_max_for_relationship` | 6.0 | 关系构建 RS 上限 | 0-10> rs_min |
| `consecutive_recall_fail_cycles` | 3 | 连续失败触发转移的轮数 | 1-10integer |
| `min_wbi_for_transfer` | 5.0 | 触发转移的最低 WBI | 0-10numeric |
| `guard_assistant_coverage_ratio` | 0.5 | 门店助教绑定率保护阈值 | 0-1numeric |
| `guard_new_assistant_days` | 10 | 新助教入驻保护天数 | 1-90integer |
| `transfer_score_w_rs` | 0.5 | 转移排序 RS 权重 | 0-1三项之和 = 1 |
| `transfer_score_w_ms` | 0.3 | 转移排序 MS 权重 | 0-1三项之和 = 1 |
| `transfer_score_w_ml` | 0.2 | 转移排序 ML 权重 | 0-1三项之和 = 1 |
| `max_transfer_count` | 2 | 单客户最大转移次数 | 1-5integer |
| `follow_up_visit_retention_hours` | 48 | 回访任务保留时长(小时) | 1-168integer |
**操作**
| 操作 | 说明 | 权限 |
|------|------|------|
| 编辑参数值 | 行内编辑,保存后立即生效 | 超级管理员 |
| 新增门店覆盖 | 选择门店 + 参数,设置覆盖值 | 超级管理员 |
| 删除门店覆盖 | 恢复使用全局默认值 | 超级管理员 |
| 重置为默认 | 将全局默认值恢复为 P17 定义的初始值 | 超级管理员 |
**前端校验规则**
- 权重参数(`w_rs` + `w_ms` + `w_ml`)之和必须 = 1.0(容差 0.001)。前端将三个权重参数合并为一个"权重配置"卡片,同时展示三个输入框,整体保存。后端 PUT 接口对权重类参数做联合校验:收到任一权重参数修改时,自动读取当前另外两个权重的值做求和校验
- 阈值参数范围 0-10与指数分值范围一致
- `rs_max_for_relationship` 必须 > `rs_min_for_relationship`
- 修改后弹出确认对话框,展示变更前后对比
> **决策O2**:参数修改直接生效,不需要审批流程。原因:(1) 参数修改频率极低(预计每月 1-2 次);(2) 修改记录通过 `updated_at` 字段和后端日志可追溯;(3) 引入审批流程会增加不必要的复杂度。后续如需审批,可在 `cfg_task_generator_params` 表新增 `updated_by` 字段记录操作人。
---
## 3. 后端 API 需求
### 3.1 转移日志 API
**路由文件**`apps/backend/app/routers/admin_task_engine.py`(新建)
```
GET /api/admin/task-engine/transfer-log
Query: site_id?, from_date?, to_date?, assistant_id?, page=1, page_size=20
Response: { items: TransferLogItem[], total: int }
权限: get_current_user门店管理员自动按 site_id 过滤)
GET /api/admin/task-engine/transfer-log/{member_id}/history
Response: { items: TransferLogItem[] }
权限: get_current_user
```
**SQL 示例(转移日志列表)**
```sql
SELECT
tl.id, tl.site_id, tl.member_id,
tl.from_assistant_id, tl.to_assistant_id,
tl.transfer_reason, tl.transfer_score,
tl.guard_checks, tl.created_at,
s.site_name
FROM biz.coach_task_transfer_log tl
JOIN biz.sites s ON s.site_id = tl.site_id
WHERE ($1::bigint IS NULL OR tl.site_id = $1)
AND ($2::date IS NULL OR tl.created_at >= $2)
AND ($3::date IS NULL OR tl.created_at < $3 + INTERVAL '1 day')
AND ($4::bigint IS NULL OR tl.from_assistant_id = $4 OR tl.to_assistant_id = $4)
ORDER BY tl.created_at DESC
LIMIT $5 OFFSET $6;
```
> **姓名关联实现方式**:由于 postgres_fdw 不传递 GUC 参数,不能在 admin-web 后端直接用 FDW JOIN。实现上采用 Python 层批量合并:先从 `biz.coach_task_transfer_log` 取分页数据,然后收集所有 `member_id` 和 `assistant_id`,批量调用 `fdw_queries.get_member_names(member_ids)` 和新增的 `fdw_queries.get_assistant_names(assistant_ids)` 工具函数(通过 `_fdw_context()` 创建独立 ETL 连接),最后在 Python 层做字典合并。待审核任务列表同理。避免在 SQL 里做跨库 JOIN。
### 3.2 待审核任务 API
```
GET /api/admin/task-engine/pending-review
Query: site_id?, page=1, page_size=20
Response: { items: PendingReviewItem[], total: int }
权限: get_current_user
GET /api/admin/task-engine/pending-review/{task_id}/candidates
Response: { candidates: CandidateAssistant[] }
权限: get_current_user
说明: 返回可接收转移的候选助教列表POOL 助教 + 降级全店助教)
POST /api/admin/task-engine/pending-review/{task_id}/reassign
Body: { to_assistant_id: int }
Response: { success: bool, new_task_id: int }
权限: get_current_user + roles 包含 'super_admin'
副作用: 原任务 status → 'transferred',新建 active 任务,写入 transfer_log
POST /api/admin/task-engine/pending-review/{task_id}/close
Body: { reason: str }
Response: { success: bool }
权限: get_current_user + roles 包含 'super_admin'
副作用: 任务 status → 'inactive'abandon_reason = reason
```
### 3.3 参数管理 API
```
GET /api/admin/task-engine/config
Query: site_id?(不传返回全局默认 + 所有门店覆盖)
Response: { params: ConfigParam[] }
权限: get_current_user
PUT /api/admin/task-engine/config/{param_id}
Body: { param_value: float }
Response: { success: bool }
权限: get_current_user + roles 包含 'super_admin'
POST /api/admin/task-engine/config
Body: { site_id: int, param_key: str, param_value: float }
Response: { success: bool, id: int }
权限: get_current_user + roles 包含 'super_admin'
DELETE /api/admin/task-engine/config/{param_id}
Response: { success: bool }
权限: get_current_user + roles 包含 'super_admin'
约束: 不允许删除全局默认值site_id IS NULL仅允许删除门店覆盖
```
---
## 4. Pydantic Schema 定义
**文件位置**`apps/backend/app/schemas/admin_task_engine.py`(新建)
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
# ── 转移日志 ──
class TransferLogItem(BaseModel):
id: int
site_id: int
site_name: str = ""
member_id: int
member_name: str = "" # FDW 关联
from_assistant_id: int
from_assistant_name: str = "" # FDW 关联
to_assistant_id: int
to_assistant_name: str = "" # FDW 关联
transfer_reason: str | None = None
transfer_score: float | None = None
guard_checks: dict | None = None
created_at: datetime
class TransferLogPage(BaseModel):
items: list[TransferLogItem]
total: int
# ── 待审核任务 ──
class PendingReviewItem(BaseModel):
id: int
site_id: int
site_name: str = ""
member_id: int
member_name: str = ""
assistant_id: int
assistant_name: str = ""
task_type: str
task_type_label: str = "" # 中文映射
transfer_count: int = 0
priority_score: float | None = None
created_at: datetime
class PendingReviewPage(BaseModel):
items: list[PendingReviewItem]
total: int
class CandidateAssistant(BaseModel):
assistant_id: int
assistant_name: str = ""
rs_display: float = 0
ms_display: float = 0
ml_display: float = 0
transfer_score: float = 0 # w_rs*rs + w_ms*ms + w_ml*ml
source: str = "pool" # "pool" | "manual_override"(强制指定标记)
class CandidateListResponse(BaseModel):
candidates: list[CandidateAssistant]
class ReassignRequest(BaseModel):
to_assistant_id: int
class ReassignResponse(BaseModel):
success: bool
new_task_id: int | None = None
class CloseRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
class CloseResponse(BaseModel):
success: bool
# ── 参数管理 ──
class ConfigParam(BaseModel):
id: int
site_id: int | None = None
site_name: str | None = None # site_id 非空时关联
param_key: str
param_value: float
description: str | None = None
updated_at: datetime
class ConfigParamList(BaseModel):
params: list[ConfigParam]
class ConfigParamUpdate(BaseModel):
param_value: float
class ConfigParamCreate(BaseModel):
site_id: int
param_key: str = Field(..., max_length=64)
param_value: float
class ConfigParamResponse(BaseModel):
success: bool
id: int | None = None
```
---
## 5. 权限控制方案
> **决策O5**:采用双层权限模型 — 超级管理员(`super_admin`)全局访问 + 门店管理员(`site_admin`)本店只读。
### 5.1 权限矩阵
| 页面/操作 | super_admin | site_admin |
|-----------|-------------|------------|
| 转移日志 — 查看全部 | ✅ | ❌ |
| 转移日志 — 查看本店 | ✅ | ✅ |
| 待审核任务 — 查看全部 | ✅ | ❌ |
| 待审核任务 — 查看本店 | ✅ | ✅ |
| 待审核任务 — 重新分配 | ✅ | ❌ |
| 待审核任务 — 关闭任务 | ✅ | ❌ |
| 参数管理 — 查看 | ✅ | ✅(只读) |
| 参数管理 — 编辑/新增/删除 | ✅ | ❌ |
### 5.2 后端实现
> **与 P10 账号体系的关系**P10 租户管理后台(`/tenant-admins`)的门店管理员账号与本页面的 `site_admin` 角色使用同一套 JWT 体系,通过 `roles` 字段区分权限范围。`site_admin` 在 P18 页面为只读访问,在 P10 页面可能有写操作权限。两者面向的用户群不同P18 面向平台运营P10 面向门店管理员),但共享认证基础设施。
```python
# apps/backend/app/auth/dependencies.py 中已有 CurrentUser(roles=[...])
# 新增权限检查辅助函数:
from fastapi import HTTPException, status
def require_super_admin(user: CurrentUser) -> None:
"""写操作权限检查:仅超级管理员可执行。"""
if "super_admin" not in user.roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅超级管理员可执行此操作",
)
def filter_by_site(user: CurrentUser, query_site_id: int | None) -> int | None:
"""读操作门店过滤:门店管理员强制锁定本店。"""
if "super_admin" in user.roles:
return query_site_id # 超级管理员可查看任意门店
return user.site_id # 门店管理员强制本店
```
### 5.3 前端实现
```typescript
// 在页面组件中检查权限,控制操作按钮显示
const isSuperAdmin = user.roles.includes('super_admin');
// 转移日志:门店筛选器
// super_admin → 显示全部门店下拉
// site_admin → 隐藏门店筛选器API 自动按 site_id 过滤
// 待审核任务:操作列
// super_admin → 显示"重新分配"和"关闭"按钮
// site_admin → 不显示操作列
// 参数管理:编辑按钮
// super_admin → 显示编辑/新增/删除按钮
// site_admin → 隐藏所有写操作按钮
```
---
## 6. 前端实现方案
### 6.1 导航结构
`apps/admin-web/src/App.tsx``NAV_ITEMS` 数组中新增"任务引擎"菜单组:
```typescript
// 新增 import
import { ApartmentOutlined } from "@ant-design/icons";
import TransferLog from "./pages/TransferLog";
import PendingReview from "./pages/PendingReview";
import TaskEngineConfig from "./pages/TaskEngineConfig";
// NAV_ITEMS 中新增(插入在"定时任务"之后)
{
key: "task-engine-group",
icon: <ApartmentOutlined />,
label: "任务引擎",
children: [
{ key: "/task-engine/transfer-log", label: "转移日志" },
{ key: "/task-engine/pending-review", label: "待审核任务" },
{ key: "/task-engine/config", label: "参数管理" },
],
},
```
**最终侧边栏结构**
```
📋 任务配置 /
📋 任务管理 /task-manager
📊 ETL 状态 /etl-status
⏰ 定时任务 /trigger-jobs
🔀 任务引擎(新增)
├── 转移日志 /task-engine/transfer-log
├── 待审核任务 /task-engine/pending-review
└── 参数管理 /task-engine/config
💾 数据库 /db-viewer
📄 日志 /log-viewer
⚙️ 环境配置 /env-config
🖥️ 运维面板 /ops-panel
👥 租户管理员 /tenant-admins
🤖 AI 监控
├── 运行总览 /ai/dashboard
├── 调度状态 /ai/trigger-jobs
├── 调用明细 /ai/run-logs
└── 手动操作 /ai/operations
🐛 开发调试日志 /dev-trace
```
### 6.2 路由注册
`AppLayout``<Routes>` 中新增:
```tsx
<Route path="/task-engine/transfer-log" element={<TransferLog />} />
<Route path="/task-engine/pending-review" element={<PendingReview />} />
<Route path="/task-engine/config" element={<TaskEngineConfig />} />
```
同时修改 `<Menu>``defaultOpenKeys`,补充 `task-engine-group` 前缀:
```tsx
defaultOpenKeys={[
...(location.pathname.startsWith("/ai/") ? ["ai-group"] : []),
...(location.pathname.startsWith("/task-engine/") ? ["task-engine-group"] : []),
]}
```
### 6.3 API 模块
新建 `apps/admin-web/src/api/taskEngine.ts`
```typescript
import { apiClient } from "./client";
// ── 转移日志 ──
export interface TransferLogItem { /* 同 Pydantic schema */ }
export interface TransferLogPage { items: TransferLogItem[]; total: number; }
export async function fetchTransferLog(params: {
site_id?: number; from_date?: string; to_date?: string;
assistant_id?: number; page?: number; page_size?: number;
}): Promise<TransferLogPage> {
const { data } = await apiClient.get("/admin/task-engine/transfer-log", { params });
return data;
}
export async function fetchMemberTransferHistory(memberId: number): Promise<TransferLogItem[]> {
const { data } = await apiClient.get(`/admin/task-engine/transfer-log/${memberId}/history`);
return data.items;
}
// ── 待审核任务 ──
export interface PendingReviewItem { /* 同 Pydantic schema */ }
export async function fetchPendingReview(params: {
site_id?: number; page?: number; page_size?: number;
}): Promise<{ items: PendingReviewItem[]; total: number }> {
const { data } = await apiClient.get("/admin/task-engine/pending-review", { params });
return data;
}
export async function fetchCandidates(taskId: number) {
const { data } = await apiClient.get(`/admin/task-engine/pending-review/${taskId}/candidates`);
return data.candidates;
}
export async function reassignTask(taskId: number, toAssistantId: number) {
const { data } = await apiClient.post(`/admin/task-engine/pending-review/${taskId}/reassign`, {
to_assistant_id: toAssistantId,
});
return data;
}
export async function closeTask(taskId: number, reason: string) {
const { data } = await apiClient.post(`/admin/task-engine/pending-review/${taskId}/close`, {
reason,
});
return data;
}
// ── 参数管理 ──
export interface ConfigParam { /* 同 Pydantic schema */ }
export async function fetchConfig(siteId?: number): Promise<ConfigParam[]> {
const { data } = await apiClient.get("/admin/task-engine/config", {
params: siteId ? { site_id: siteId } : {},
});
return data.params;
}
export async function updateConfig(paramId: number, value: number) {
const { data } = await apiClient.put(`/admin/task-engine/config/${paramId}`, {
param_value: value,
});
return data;
}
export async function createConfig(siteId: number, paramKey: string, value: number) {
const { data } = await apiClient.post("/admin/task-engine/config", {
site_id: siteId, param_key: paramKey, param_value: value,
});
return data;
}
export async function deleteConfig(paramId: number) {
const { data } = await apiClient.delete(`/admin/task-engine/config/${paramId}`);
return data;
}
```
### 6.4 页面组件结构
每个页面遵循现有 `TriggerJobs.tsx` 的模式:`useState` + `useCallback` + `useEffect` + Ant Design Table。
| 文件 | 说明 |
|------|------|
| `apps/admin-web/src/pages/TransferLog.tsx` | 转移日志页面 |
| `apps/admin-web/src/pages/PendingReview.tsx` | 待审核任务页面 |
| `apps/admin-web/src/pages/TaskEngineConfig.tsx` | 参数管理页面 |
| `apps/admin-web/src/api/taskEngine.ts` | API 调用模块 |
| `apps/backend/app/routers/admin_task_engine.py` | 后端路由 |
| `apps/backend/app/schemas/admin_task_engine.py` | Pydantic schema |
---
## 7. 数据库变更需求
### 7.1 trigger_jobs 表扩展P1 优先级)
> `last_error` 和 `description` 字段已存在于数据库中(已通过 `\d biz.trigger_jobs` 确认),无需重复添加。
```sql
-- 仅 last_stats 是真正新增的
ALTER TABLE biz.trigger_jobs
ADD COLUMN IF NOT EXISTS last_stats JSONB;
COMMENT ON COLUMN biz.trigger_jobs.last_stats
IS '最近一次执行的统计结果 JSON如 {"created":5,"replaced":2,"skipped":10,"transferred":1}';
```
`last_stats``task_generator.run()` 在执行完成后写入,是运营监控任务引擎健康度的最直接手段。
### 7.2 cfg_task_generator_params 表扩展
```sql
-- 记录修改人(审计追溯)
ALTER TABLE biz.cfg_task_generator_params
ADD COLUMN IF NOT EXISTS updated_by BIGINT;
COMMENT ON COLUMN biz.cfg_task_generator_params.updated_by IS '最近修改人 user_id用于审计追溯';
```
### 7.3 无需新建表
P18 不新增业务表。所有数据来源于 P17 已创建的:
- `biz.coach_task_transfer_log`(转移日志)
- `biz.cfg_task_generator_params`(参数配置)
- `biz.coach_tasks`(任务表,含 `pending_review` 状态)
---
## 8. 优先级排序
| 优先级 | 功能 | 理由 | 预估工时 |
|--------|------|------|----------|
| P0 | 待审核任务页面 | `pending_review` 任务需要人工介入,无此页面则转移超限的任务无法处理 | 后端 4h + 前端 4h |
| P1 | 参数管理页面 | 门店级参数调整是日常运营需求,否则每次改参数都要直接改数据库 | 后端 3h + 前端 4h |
| P1 | 转移日志页面 | 运营需要追踪转移效果,但短期可通过数据库查询替代 | 后端 3h + 前端 3h |
| P1 | trigger-jobs last_stats 展示 | 任务引擎上线后运营最直接的监控手段,能看到 created/replaced/skipped/transferred 数字 | 后端 1h + 前端 1h |
| P2 | trigger-jobs 启用/禁用开关 | 低频操作,可通过数据库修改 | 后端 0.5h + 前端 0.5h |
**建议实施顺序**
1. 后端:先建 router + schema 骨架(`admin_task_engine.py`),再逐个实现端点
2. 前端:先注册路由和导航,再逐个实现页面组件
3. P0 → P1参数管理→ P1转移日志→ P2 → P3
---
## 9. 开放问题决策汇总
| # | 问题 | 决策 | 理由 |
|---|------|------|------|
| O1 | 重新分配候选助教列表获取方式 | 复用 P17 转移候选逻辑(仅 POOL 助教POOL 为空时返回空列表 + 提示,紧急情况提供"强制指定"(需二次确认 + manual_override 标记) | 严格遵守 P17 的 UNASSIGNED 永不分配约束 |
| O2 | 参数修改是否需要审批流程 | 直接生效,不需要审批 | 修改频率极低,`updated_at` + `updated_by` 可追溯 |
| O3 | 转移日志是否关联 WBI/NCI/RS 快照 | 不关联,通过 `guard_checks` JSONB 间接推断 | 避免表膨胀,历史指数可从 DWS 层回溯 |
| O4 | 是否需要运行总览 Dashboard | 暂不需要,后续根据运营反馈评估 | 当前数据量不足以支撑有意义的趋势图 |
| O5 | 权限模型 | 双层super_admin 全局 + site_admin 本店只读 | 与现有 JWT roles 体系一致,实现成本最低 |
| O6 | trigger-jobs 执行历史是否需要新表 | 暂不新增,用 `last_stats` JSONB 字段过渡 | 短期够用,中期再评估完整历史表 |
> **决策O4**:暂不建设运行总览 Dashboard。原因(1) P17 刚上线,历史数据不足以生成有意义的趋势图;(2) 三个功能页面已覆盖核心运营需求;(3) 后续积累 1-2 个月数据后,可作为 P19 或 P20 独立评估。
---
## 10. 验收标准
| # | 验收项 | 判定方式 |
|---|--------|----------|
| AC1 | 转移日志页面可按门店/时间/助教筛选 | 手动验证筛选条件组合 |
| AC2 | 门店管理员只能看到本店转移日志 | 用 site_admin 角色登录验证 |
| AC3 | 待审核任务页面展示所有 pending_review 任务 | 数据库插入测试数据后验证 |
| AC4 | 重新分配操作正确创建新任务并标记原任务 | 执行后检查 coach_tasks + transfer_log |
| AC5 | 关闭任务操作正确更新状态和原因 | 执行后检查 coach_tasks.status + abandon_reason |
| AC6 | 参数管理页面展示全局默认 + 门店覆盖 | 插入门店覆盖数据后验证 |
| AC7 | 权重参数之和校验生效 | 尝试设置不等于 1.0 的权重组合 |
| AC8 | 超级管理员可执行所有写操作 | 用 super_admin 角色验证 |
| AC9 | 门店管理员无法执行写操作 | 用 site_admin 角色验证 403 |
| AC10 | 不允许删除全局默认参数 | 尝试删除 site_id IS NULL 的记录 |
---
## 11. 与现有 PRD/模块的关系
| 文档/模块 | 关系说明 |
|-----------|----------|
| P17 | 本 PRD 是 P17 的管理后台配套P17 提供数据P18 提供可视化和操作入口 |
| P10租户管理后台 | P10 是面向门店管理员的独立应用P18 是面向平台运营的 admin-web 扩展。两者共享 JWT 认证体系(同一套 `roles` 字段),`site_admin` 在 P18 为只读、在 P10 可能有写权限。详见第 5.2 节说明 |
| trigger-jobs 现有页面 | 本 PRD 评估其扩展需求,但不强制改动,保持现有功能稳定 |
| AI 监控模块 | 参考其 API 设计模式JWT + admin 角色、分页列表 + 详情)|
---
## 附录 A后端路由注册
`apps/backend/app/main.py` 中注册新路由:
```python
from app.routers import admin_task_engine
app.include_router(admin_task_engine.router)
```
## 附录 B变更历史
| 版本 | 日期 | 变更内容 |
|------|------|----------|
| v1.0-draft | 2026-03-24 | 初始草稿,定义 3 个页面 + 9 个 API + 6 个开放问题 |
| v2.0-ready-for-review | 2026-03-24 | 关闭全部 6 个开放问题;补充 Pydantic schema、权限矩阵、前端实现方案、SQL 示例、校验规则、验收标准;升级为待评审 |
| v2.1-reviewed | 2026-03-24 | 评审修正:(1) DDL 移除已存在的 last_error/description仅新增 last_stats(2) defaultOpenKeys 补充 task-engine-group(3) last_stats 优先级 P2→P1(4) 候选助教降级方案改为空结果+提示+强制指定,不放开到全店助教;(5) 权重参数改为卡片整体编辑+联合校验;(6) 明确姓名关联走 Python 层批量合并;(7) 补充 site_admin 与 P10 账号体系关系说明 |

View File

@@ -0,0 +1,125 @@
# P19历史指数回测 + 任务引擎模拟
> 版本v1.0 | 日期2026-03-29 | 来源:本轮对话需求讨论
---
## 1. 背景
任务引擎P17 + OS 分级分配)已实现,但缺乏历史验证手段。需要:
- 回测过去一个月的指数变化,验证指数算法的稳定性
- 模拟任务引擎运行一个月,验证分级分配、升级、转移逻辑的合理性
- 最终数据落库(`test_zqyy_app`),可在小程序和管理后台中查看
## 2. 需求拆分
### 2.1 指数历史回测Phase 1
**目标**:给 4 个指数任务加 `as_of_date` 参数,支持"假装今天是 X 日"重算指数。
**涉及任务**
| 任务 | 输入数据源 | 时间依赖点 |
|------|-----------|-----------|
| `DWS_WINBACK_INDEX` (WBI) | `dws_member_visit_detail` + `dws_member_consumption_summary` | 距上次到店天数、到店频率衰减 |
| `DWS_NEWCONV_INDEX` (NCI) | `dws_member_visit_detail` + `dws_member_consumption_summary` | 新客首次到店后的天数 |
| `DWS_RELATION_INDEX` (RS/OS/MS/ML) | `dwd_assistant_service_log` | 服务记录的时间衰减halflife |
| `DWS_SPENDING_POWER_INDEX` (SPI) | `dws_member_consumption_summary` | 消费金额时间窗口 |
**改动要点**
- 每个指数任务的 `_do_extract()` 中,将 `NOW()` / `CURRENT_DATE` 替换为 `as_of_date` 参数
- 衰减计算中的"距今天数"改为"距 as_of_date 天数"
- 输出表新增 `calc_date` 字段(或复用 `calc_time`),标记是哪天的快照
**回测参数**
- 时间范围:过去 30 天2026-02-27 ~ 2026-03-29
- 回测间隔:每 6 小时一个快照(共 120 个快照点)
- 数据落库:每个快照覆盖写入 ETL 测试库的指数表delete-before-insert by calc_date
**CLI 接口设计**
```bash
# 单次回测(指定日期)
python -m cli.main --tasks DWS_WINBACK_INDEX --as-of-date "2026-03-01"
# 批量回测(日期范围 + 间隔)
python scripts/ops/backtest_indexes.py \
--start "2026-02-27" --end "2026-03-29" \
--interval-hours 6 \
--store-id 2790685415443269
```
### 2.2 任务引擎模拟Phase 2
**目标**:基于回测的指数快照,模拟任务引擎运行一个月,数据落入业务测试库。
**模拟参数**
- 时间范围2026-02-27 ~ 2026-03-29
- 模拟粒度每小时一次720 次循环)
- 指数数据:使用 Phase 1 回测的快照(每 6 小时更新一次,中间小时复用最近快照)
- 回店判定:使用 DWD 真实服务记录(`dwd_assistant_service_log.create_time`
**模拟流程(每小时)**
```
1. 设置模拟时钟 sim_time
2. 如果 sim_time 是 6 小时整点 → 切换到对应的指数快照
3. 检查 DWD 服务记录中 create_time 在 [sim_time-1h, sim_time] 的记录
→ 匹配 active 召回任务 → 标记 completedcompleted_at = sim_time
4. 检查过期任务expires_at < sim_time→ 标记 abandoned
5. 执行任务生成逻辑OS 分级分配):
a. MAIN 助教:生成召回/关系构建任务
b. COMANAGE仅生成关系构建检查升级条件升级倍数 ≥ 3
c. 转移检查(升级倍数 ≥ 5
6. 已完成的召回任务 → 生成 follow_up_visit48h 保留期)
7. 记录当小时的任务快照
```
**数据落库**
- 所有任务写入 `biz.coach_tasks``created_at` 用模拟时钟值)
- 历史记录写入 `biz.coach_task_history`
- 转移日志写入 `biz.coach_task_transfer_log`
**期望输出**
1. 每天的任务数量变化(按类型分组)
2. 一个月后各类型任务的最终分布
3. COMANAGE 升级触发次数和时间点
4. POOL 转移触发次数和时间点
5. 回访任务的生成数量和完成率
6. 每个助教的任务负载分布
### 2.3 输出报告
**脚本输出**
- 控制台:每天一行摘要(日期 | 新增 | 完成 | 升级 | 转移 | 总 active
- CSV 文件:`export/backtest/task_simulation_daily.csv`(每天快照)
- JSON 文件:`export/backtest/task_simulation_summary.json`(最终统计)
## 3. 技术约束
- 指数回测和任务模拟都在测试库执行(`test_etl_feiqiu` / `test_zqyy_app`
- 模拟脚本放 `scripts/ops/`,遵循现有脚本规范
- 环境变量从根 `.env` 加载(`load_dotenv`
- 指数回测需要 ETL 库连接(`PG_DSN`),任务模拟需要业务库连接(`APP_DB_DSN`
- 模拟前清空 `coach_tasks` / `coach_task_history` / `coach_task_transfer_log`
## 4. 实施顺序
1. Phase 1a`RelationIndexTask`RS/OS/MS/ML`as_of_date` 支持
2. Phase 1b`WinbackIndexTask`WBI`as_of_date` 支持
3. Phase 1c`NewconvIndexTask`NCI`as_of_date` 支持
4. Phase 1d`SpendingPowerIndexTask`SPI`as_of_date` 支持
5. Phase 1e编写批量回测脚本 `backtest_indexes.py`
6. Phase 2编写任务模拟脚本 `simulate_task_engine.py`
## 5. 依赖
- P17助教客户归属与任务生成引擎已完成
- OS 分级分配改动(本轮已完成)
- DWS_TASK_ENGINE ETL 任务(本轮已完成)
- DWD 层服务记录数据(已有)
## 6. 关键文件参考
- 指数任务:`apps/etl/connectors/feiqiu/tasks/dws/index/`
- 任务生成器:`apps/backend/app/services/task_generator.py`
- 召回检测器:`apps/backend/app/services/recall_detector.py`
- FDW 查询:`apps/backend/app/services/fdw_queries.py`
- 参数配置:`biz.cfg_task_generator_params`16 条参数)

View File

@@ -41,7 +41,7 @@
| 0 | 高优先召回 | max(WBI,NCI) > 7 | 助教为该客户服务ETL 检测) |
| 0 | 优先召回 | max(WBI,NCI) > 5 | 同上 |
| 1 | 客户回访 | 完成召回后未备注 | 助教为该客户提交备注AI 评分仅绩效用途) |
| 2 | 关系构建 | RS < 6 | 无自动完成条件(手动标记或指数变化) |
| 2 | 关系构建 | 1 < RS < 6 | 无自动完成条件(手动标记或指数变化) |
### 任务类型与任务状态的关系

View File

@@ -44,7 +44,7 @@ P5 的 8 个 AI 应用中,应用 3/4/5/6/7 的首条 Prompt JSON 结构包含
- AC3应用 3 维客线索在客户新增消费时自动更新,返回 JSON 维客线索(分类标签限 3 个枚举:客户基础/消费习惯/玩法偏好),提供者统一为"系统"
- AC4应用 4 在助教参与新结算/优先召回任务分配/高优先召回任务分配时触发,读取应用 8 最新缓存
- AC5应用 5 联动应用 4提供沟通话术
- AC6应用 6 在每个备注提交后自动分析,返回 JSON评分 1-10 + 维客线索 0-N 条,分类标签 6 个枚举:客户基础/消费习惯/玩法偏好/促销偏好/社交关系/重要反馈),提供者为当前备注提供人
- AC6应用 6 在每个备注提交后自动分析,返回 JSON评分 1-10 + 维客线索 0-N 条,分类标签 6 个枚举:客户基础/消费习惯/玩法偏好/促销接受/社交关系/重要反馈),提供者为当前备注提供人
- AC7应用 7 在消费事件链中应用 8 完成后触发(串行),基于客户全量信息生成运营策略
- AC8应用 8 在应用 3 或应用 6 有新内容产生后立即触发,整合去重维客线索
- AC9所有 AI 调用记录持久化conversation_id, message_id, app_id, user_id/系统, role, content, tokens_used, nickname, created_at, site_id
@@ -175,7 +175,7 @@ biz.ai_cache
}
```
- 分类标签枚举:客户基础 / 消费习惯 / 玩法偏好 / 促销偏好 / 社交关系 / 重要反馈
- 分类标签枚举:客户基础 / 消费习惯 / 玩法偏好 / 促销接受 / 社交关系 / 重要反馈
- 评分规则6 分为标准分,重复信息/低价值/时效性低酌情扣分,高价值信息酌情加分
- 输入:当前备注内容 + 客户消费数据 + 所有助教对该客户的全部备注
- 参考信息:应用 3 的线索结果 + 最近 2 套应用 8 的历史信息(附生成时间)
@@ -215,7 +215,7 @@ biz.ai_cache
}
```
- 分类标签枚举:客户基础 / 消费习惯 / 玩法偏好 / 促销偏好 / 社交关系 / 重要反馈
- 分类标签枚举:客户基础 / 消费习惯 / 玩法偏好 / 促销接受 / 社交关系 / 重要反馈
- 输入:当前最新应用 3 + 应用 6 的全部线索内容
- 原则:合并相似线索(多提供者逗号分隔),其余原文返回,最小改动

View File

@@ -0,0 +1,618 @@
# 看板 & 详情页差距分析与实施指南
> 调研日期2026-03-28
> 范围:小程序看板(助教/客户列表)、助教详情页、客户详情页
> 目的:全面梳理前后端完成度差距,为后续联调实施提供完整依据
---
## 一、总体完成度
| 模块 | 后端接口 | 前端页面 | 真实数据对接 | 完成度 | 主要差距 |
|------|---------|---------|-------------|--------|---------|
| 助教看板 BOARD-1 | ✅ | ✅ | ❌ Mock | ~60% | 前端用 Mockskills 为空;环比字段为 None |
| 客户看板 BOARD-2 | ✅ | ✅ | ❌ Mock | ~60% | 前端用 Mockloyal 缺 coachDetails排序规则需修正 |
| 助教详情 COACH-1 | ✅ | ✅ | ⚠️ 部分 | ~40% | API 已调用但 Mock 覆盖大部分字段 |
| 客户详情 CUST-1 | ✅ | ✅ | ⚠️ 部分 | ~35% | API 已调用但只映射了 id/name/phone |
| 客户服务记录 CUST-2 | ✅ | ✅ | ✅ 真实 | ~90% | 已完成联调 |
| 看板 Tab 切换 | N/A | ✅ | N/A | 需重构 | 当前是页面跳转,需改为同页切换 |
---
## 二、问题清单(按优先级排列)
### P1看板 Tab 切换改为同页切换
**现状**三个看板页面board-finance / board-customer / board-coach各自独立通过 `wx.navigateTo()``wx.switchTab()` 跳转。
**问题**
- `board-coach.ts` 第 245 行 `onTabChange()``wx.navigateTo` / `wx.switchTab`
- `board-customer.ts` 第 277 行 `onTabChange()`:同上
- `board-finance.ts` 第 473 行 `onTabChange()`:同上
**目标**:改为同一页面内的 tab 切换(无页面跳转感),类似 `wx:if``hidden` 控制显示/隐藏。
**实施方案选项**
1. **方案 A合并为单页**:将三个看板合并到一个页面(如 `board/board`),用 `wx:if="{{activeTab === 'finance'}}"` 切换内容区。优点:切换无延迟;缺点:单页代码量大,首次加载慢。
2. **方案 B`wx.redirectTo` 替代**:用 `wx.redirectTo` 替代 `wx.navigateTo`,避免页面栈堆积。优点:改动最小;缺点:仍有页面切换闪烁。
3. **方案 C组件化**:将三个看板内容抽为自定义组件,在一个容器页面中按 tab 切换。优点:代码隔离好、切换流畅;缺点:需要重构。
**建议**:方案 C组件化兼顾代码隔离和切换体验。
**影响范围**
- 前端:`pages/board-finance/``pages/board-customer/``pages/board-coach/` → 抽为组件
- 路由:`app.json` 页面注册需调整
- 权限守卫:`checkPageAccess` 需适配新路由
- custom-tab-bar如果 board-finance 是 tabBar 页面,需要调整
---
### P2助教详情页 Mock → 真实 API 映射
**现状**`coach-detail.ts` 第 297-370 行 `loadData()`
- 已调用 `fetchCoachDetail(id)` 获取后端数据
- 但只映射了 `id``name`,其余全部用 `mockCoachDetail` 覆盖
- 档位节点硬编码为 `[0, 100, 130, 160, 190, 220]`(实际应为 `[0, 120, 150, 180, 210]`
**后端 Schema**`CoachDetailResponse`)已返回的完整字段:
```
id, name, avatar, level, skills, work_years, customer_count, hire_date,
performance { monthly_hours, monthly_salary, customer_balance, tasks_completed,
perf_current, perf_target },
income { this_month[], last_month[] },
tier_nodes[],
visible_tasks[], hidden_tasks[], abandoned_tasks[],
top_customers[], service_records[], history_months[], notes[]
```
**前端需要映射但当前被 Mock 覆盖的字段**
| 字段 | Mock 变量 | 说明 |
|------|----------|------|
| `tier_nodes` | 硬编码 `[0, 100, 130, 160, 190, 220]` | 档位节点,后端从 `cfg_performance_tier` 读取 |
| `visible_tasks` | `mockVisibleTasks` | 可见任务列表 |
| `hidden_tasks` | `mockHiddenTasks` | 隐藏任务列表 |
| `abandoned_tasks` | `mockAbandonedTasks` | 已放弃任务列表 |
| `top_customers` | `mockTopCustomers` | TOP 客户列表 |
| `service_records` | `mockServiceRecords` | 近期服务记录 |
| `history_months` | `mockHistoryMonths` | 历史月份数据 |
| `notes` | Mock 内嵌 | 备注列表(已部分映射) |
| `income` | Mock 内嵌 | 收入明细(本月/上月) |
| `performance` 全部子字段 | `mockCoachDetail.performance` | 绩效数据 |
**实施要点**
1. 移除 `mockCoachDetail` 及所有 `mock*` 变量
2. 直接使用 `fetchCoachDetail(id)` 返回的完整对象
3. 注意 camelCase 转换(后端 snake_case → 前端 camelCase 自动转换)
4. `tier_nodes` 使用后端返回值fallback 用 `_FALLBACK_TIER_NODES = [0, 120, 150, 180, 210]`
5. 数值字段传原始数字给 `setData`WXML 中用 WXS 格式化TS 与 WXS 格式化互斥规则)
6. null 值清洗:`?? 0` / `?? ''`(组件 property 收到 null 不走默认值)
---
### P3客户详情页 Mock → 真实 API 映射
**现状**`customer-detail.ts` 第 97-120 行 `loadDetail()`
- 已调用 `fetchCustomerDetail(id)` 获取后端数据
- 只映射了 `id``name``phone` 三个字段
- 其余模块Banner、AI 洞察、维客线索、助教任务、最亲密助教、消费记录、备注)全部使用 `data` 中的初始空值
**后端 Schema**`CustomerDetailResponse`)已返回的完整字段:
```
id, name, phone, phone_full, avatar, member_level, relation_index, tags[],
balance, consumption_60d, ideal_interval, days_since_visit,
ai_insight { summary, strategies[] },
coach_tasks[], favorite_coaches[], retention_clues[],
consumption_records[], notes[]
```
**前端需要映射但当前未映射的字段**
| 字段 | 前端 data key | 说明 |
|------|-------------|------|
| `phone_full` | `detail.phone` | 完整手机号(用于复制) |
| `avatar` | 未使用 | 头像 URL |
| `member_level` | 未使用 | 会员等级 |
| `relation_index` | 未使用 | 关系指数 |
| `tags` | 未使用 | 标签数组 |
| `balance` | `detail.balance` | 卡余额 |
| `consumption_60d` | `detail.consumption60d` | 60天消费 |
| `ideal_interval` | `detail.idealInterval` | 理想到店间隔 |
| `days_since_visit` | `detail.daysSinceVisit` | 距上次到店天数 |
| `ai_insight` | `aiInsight` | AI 洞察(暂不处理) |
| `coach_tasks` | `coachTasks` | 关联助教任务 |
| `favorite_coaches` | `favoriteCoaches` | 最亲密助教 |
| `retention_clues` | `clues` | 维客线索 |
| `consumption_records` | `consumptionRecords` | 消费记录 |
| `notes` | `sortedNotes` | 备注 |
**实施要点**
1. `loadDetail` 中将 `fetchCustomerDetail(id)` 返回的完整对象映射到 `data`
2. AI 相关字段(`ai_insight`)暂不处理,保持空值
3. Banner 四项指标balance / consumption60d / idealInterval / daysSinceVisit直接映射
4. 子模块coachTasks / favoriteCoaches / clues / consumptionRecords / notes直接映射
5. null 值清洗规则同 P2
---
### P4助教看板 Mock → 真实 API
**现状**`board-coach.ts` 第 195-215 行 `loadData()`
- 使用 `setTimeout` + `MOCK_COACHES` 模拟加载
- 未调用任何 API
**后端接口**`GET /api/xcx/board/coaches?sort=perf_desc&skill=ALL&time=month`
**后端返回结构**
```json
{
"items": [
{
"id": 123,
"name": "张三",
"initial": "张",
"avatar_gradient": "",
"level": "senior",
"skills": [],
"top_customers": ["李四", "王五"],
"perf_hours": 156.5,
"perf_hours_before": null,
"perf_gap": null,
"perf_reached": false,
"salary": 18500.0,
"salary_perf_hours": 156.5,
"salary_perf_before": null,
"sv_amount": 45000.0,
"sv_customer_count": 12,
"sv_consume": 8500.0,
"task_recall": 5,
"task_callback": 8
}
],
"dim_type": "perf"
}
```
**实施要点**
1. 替换 `setTimeout` + `MOCK_COACHES` 为真实 API 调用
2. 筛选参数sort / skill / time传给 API
3. 筛选变更时重新请求(当前只在前端过滤 Mock 数据)
4. 注意 `skills` 字段当前为空数组(见 P6
5. `perf_hours_before` / `salary_perf_before` 当前为 null见 P7
---
### P5客户看板 Mock → 真实 API
**现状**`board-customer.ts` 第 218-235 行 `loadData()`
- 使用 `setTimeout` + `MOCK_CUSTOMERS` 模拟加载
- 未调用任何 API
**后端接口**`GET /api/xcx/board/customers?dimension=recall&project=ALL&page=1&page_size=20`
**实施要点**
1. 替换 Mock 为真实 API 调用
2. 维度切换时重新请求(不同维度调用不同 FDW 查询函数)
3. 项目筛选传给 API
4. 分页支持page / page_size
5. 注意前端字段名与后端返回的映射camelCase 自动转换)
**客户看板前端字段 → 后端字段映射**
| 前端字段 | 后端字段 | 维度 |
|---------|---------|------|
| `idealDays` | `ideal_days` | recall |
| `elapsedDays` | `elapsed_days` | recall |
| `overdueDays` | `overdue_days` | recall |
| `visits30d` | `visits_30d` | recall |
| `balance` | `balance` | recall / balance |
| `recallIndex` | `recall_index` | recall |
| `spend30d` | `spend_30d` | potential |
| `avgVisits` | `avg_visits` | potential |
| `avgSpend` | `avg_spend` | potential |
| `lastVisit` | `last_visit` | balance / recent |
| `monthlyConsume` | `monthly_consume` | balance |
| `availableMonths` | `available_months` | balance |
| `lastRecharge` | `last_recharge` | recharge |
| `rechargeAmount` | `recharge_amount` | recharge |
| `recharges60d` | `recharges_60d` | recharge |
| `currentBalance` | `current_balance` | recharge |
| `spend60d` | `spend_60d` | spend60 |
| `visits60d` | `visits_60d` | spend60 / freq60 |
| `avgInterval` | `avg_interval_days` | freq60 |
| `weeklyVisits` | `weekly_visits` | freq60 |
| `intimacy` | `intimacy` | loyal |
| `topCoachName` | `top_coach_name` | loyal |
| `topCoachHeart` | `top_coach_heart` | loyal |
| `coachDetails` | ❌ 后端未返回 | loyal |
| `daysAgo` | `days_ago` | recent |
| `assistants` | `assistants` | 所有维度 |
---
### P6助教 Skills 字段为空
**现状**
- 后端 `get_coach_board()` 返回 `"skills": []`(第 316 行注释:`v_dim_assistant 无 skill 列,暂返回空`
- 后端 `get_coach_detail()` 同样返回空 skills
- 前端 WXML 已有 skills 标签渲染逻辑
**调研发现——"技能"的两层含义**
1. **课程类型skill_id**:指助教能教的课程类型
- 基础课(陪打/PD`skill_id = 2791903611396869`
- 附加课(超休/CX`skill_id = 2807440316432197`
- 包厢课:归入基础课口径
- 配置表:`cfg_skill_type``skill_id → course_type_code: BASE/BONUS/ROOM`
- 这不是看板需要展示的"技能"
2. **项目类型area_category**:指助教擅长的运动项目
- BILLIARD🎱 中式/追分、SNOOKER斯诺克、MAHJONG🀄 麻将/棋牌、KTV🎤 团建/K歌
- 配置表:`cfg_area_category`(台桌 → 区域 → 项目类型映射)
- 视图:`app.v_cfg_area_category`(去重到 category 级别)
- **这才是看板需要展示的"技能标签"**
**看板中 skills 的业务含义**
- 前端 `SKILL_CLASS` 映射:`'🎱' → 'skill--chinese'``'斯' → 'skill--snooker'``'🀄' → 'skill--mahjong'``'🎤' → 'skill--karaoke'`
- 即:助教擅长哪些项目类型(台球/斯诺克/麻将/KTV
**数据来源方案**
-`dwd_assistant_service_log` 按助教聚合历史服务的 `area_category_code`,取去重后的项目类型列表
- SQL 示例:
```sql
SELECT DISTINCT asl.area_category_code
FROM dwd.dwd_assistant_service_log asl
WHERE asl.assistant_id = %s
AND asl.is_delete = 0
AND asl.area_category_code IS NOT NULL
```
- 或者:在 `v_dim_assistant` 视图中新增 `skills` 列(从服务记录聚合)
**实施建议**
1. 在 `fdw_queries` 中新增 `get_assistant_skills_batch(conn, site_id, assistant_ids)` 函数
2. 从 `v_dwd_assistant_service_log` 按助教聚合 `area_category_code`
3. 映射为前端需要的格式:`["BILLIARD", "SNOOKER"]`
4. 在 `get_coach_board()` 和 `get_coach_detail()` 中调用并填充 `skills` 字段
---
### P7助教看板"折前"字段perf_hours_before / salary_perf_before
**现状**
- 后端 `get_coach_board()` 返回 `perf_hours_before: None`、`salary_perf_before: None`
- 前端 WXML 已有条件渲染:`wx:if="{{item.perfHoursBeforeLabel}}"` 显示"折前 XX.Xh"
**业务含义**
- `perf_hours` = 折算后的定档业绩课时effective_hours
- `perf_hours_before` = 折算前的原始课时base_hours + bonus_hours + room_hours
- 折算规则:同一台桌同一时段 >2 名助教重叠挂台时,计算 `per_hour_contribution = base_ledger_amount / base_hours / overlap_count`,若 < 24 元/小时则按比例扣减 `penalty_minutes`
- 豁免条件:`is_exempt = true`(客人真实需求,前台核实并绑定客人信息)
- 只要 `effective_hours != raw_hours` 就显示折前课时,与新入职无关
**注意**:这不是"环比",而是"折算前 vs 折算后"的对比。前端 WXML 中的 `perfHoursBeforeLabel` 显示的是"折前 XX.Xh",不是"上期 XX.Xh"。
**数据来源**
- `dws_assistant_salary_calc` 表中应有 `raw_hours`(折算前)和 `effective_hours`(折算后)
- 需确认 DWS 表是否已有 `raw_hours` 字段
- 如果没有,需要在 DWS 任务中补充计算
---
### P8客户看板 loyal 维度缺少 coachDetails 子数组
**现状**
- 前端 WXML 已有完整的助教服务明细表渲染(`board-customer.wxml` 第 254-277 行)
- 表头:助教 | 次均时长 | 服务次数 | 助教消费 | 关系指数
- 数据绑定:`wx:for="{{item.coachDetails}}"`
- 但后端 `get_customer_board_loyal()` 只返回了 `top_assistant_id` 和 `top_coach_name`,没有返回 `coach_details` 数组
**用户明确的排列规则**
- 客户-助教对RSI 从高到低排列
- 客户不重复,排重规则是放在最高对的位置
- 每个卡片展示客户信息 + 该客户对应所有助教 RSI 值从高到低排列
- 右上位置展示最高 RSI 值助教
**当前后端实现**`fdw_queries.py` 第 2272-2340 行):
```sql
WITH member_top AS (
SELECT ri.member_id,
MAX(ri.rs_display) AS max_rs,
(ARRAY_AGG(ri.assistant_id ORDER BY ri.rs_display DESC))[1] AS top_assistant_id,
(ARRAY_AGG(ri.rs_display ORDER BY ri.rs_display DESC))[1] AS top_rs
FROM app.v_dws_member_assistant_relation_index ri
GROUP BY ri.member_id
ORDER BY MAX(ri.rs_display) DESC
LIMIT %s OFFSET %s
)
```
- ✅ 排序正确:按 `max_rs DESC`(最高 RSI 降序)
- ✅ 客户不重复:`GROUP BY ri.member_id`
- ✅ 右上角最高 RSI 助教:`top_assistant_id` + `top_coach_name`
- ❌ 缺少:每个客户对应的所有助教明细
**需要补充的后端逻辑**
在 `get_customer_board_loyal()` 返回 items 后,批量查询每个客户的所有助教 RSI 明细:
```sql
SELECT ri.member_id,
ri.assistant_id,
COALESCE(da.real_name, da.nickname, '') AS name,
ri.rs_display,
ri.service_count,
ri.total_hours,
ri.total_income
FROM app.v_dws_member_assistant_relation_index ri
LEFT JOIN app.v_dim_assistant da
ON ri.assistant_id = da.assistant_id AND da.scd2_is_current = 1
WHERE ri.member_id = ANY(%s)
ORDER BY ri.member_id, ri.rs_display DESC
```
**前端 coachDetails 字段结构**
```typescript
interface CoachDetail {
name: string // 助教姓名
cls: string // 样式类
heartScore: number // RSI 值0-10
badge?: string // "跟" / "弃"
avgDuration: string // 次均时长
serviceCount: string // 服务次数
coachSpend: string // 助教消费金额
relationIdx: number // 关系指数
}
```
**计算规则**
- `avgDuration` = `total_hours / service_count`(次均时长)
- `serviceCount` = `service_count`(服务次数)
- `coachSpend` = `total_income`(助教消费金额,即该客户在该助教处的消费)
- `relationIdx` = `rs_display`RSI 值)
- `heartScore` = `rs_display`(用于 heart-icon 组件)
- `badge`:跟/弃标记来源待确认(可能来自 `coach_tasks` 表的任务状态)
---
### P9客户看板项目筛选枚举不一致
**现状**
- `board-customer.ts` 的 `PROJECT_OPTIONS` 使用旧枚举值:
```typescript
{ value: 'all', text: '全部' },
{ value: 'chinese', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' },
```
- `board-coach.ts` 的 `SKILL_OPTIONS` 已修正为数据库枚举值2026-03-20 修复):
```typescript
{ value: 'ALL', text: '不限' },
{ value: 'BILLIARD', text: '🎱 中式/追分' },
{ value: 'SNOOKER', text: '斯诺克' },
{ value: 'MAHJONG', text: '🀄 麻将/棋牌' },
{ value: 'KTV', text: '🎤 团建/K歌' },
```
**问题**:客户看板的 `project` 参数值(`all/chinese/snooker/mahjong/karaoke`)与后端枚举(`ALL/BILLIARD/SNOOKER/MAHJONG/KTV`不一致API 调用会失败。
**修复**:将 `PROJECT_OPTIONS` 的 value 改为 `ALL/BILLIARD/SNOOKER/MAHJONG/KTV`。
---
## 三、环比字段分布
根据调研,环比字段的分布如下:
### 3.1 财务看板BOARD-3— 已实现环比
后端 `get_finance_board()` 接受 `compare` 参数0/1当 `compare=1` 时计算环比。
环比字段分布(`xcx_board.py` Schema
- `OverviewPanel`8 项核心指标各有 `*_compare` / `*_down` / `*_flat` 三元组
- `RechargePanel`:储值卡各项 + 全类别余额合计
- `RevenuePanel`:总发生额 / 优惠总计 / 确认收入
- `CashflowPanel`:充值合计
- `ExpensePanel`:支出合计
- `CoachAnalysisPanel`pay / share / hourly 各有环比
前端 WXML 使用 `fmt.compareText()` + `fmt.compareClass()` WXS 函数渲染。
### 3.2 助教看板BOARD-1— 非环比字段
`perf_hours_before` / `salary_perf_before` 不是环比,而是"折算前课时"
- 前端 WXML 显示为"折前 XX.Xh"
- 只有新入职助教才有折算值
- 数据来源DWS `dws_assistant_salary_calc` 的 `raw_hours` 字段
### 3.3 客户看板BOARD-2— 无环比
当前无环比字段设计。
### 3.4 助教详情页COACH-1— 无环比
当前无环比字段设计。
### 3.5 客户详情页CUST-1— 无环比
当前无环比字段设计。
---
## 四、统计规则详解
### 4.1 助教看板四维度
#### 定档业绩维度perf
- 排序字段:`effective_hours`(折算后工时)
- 升序/降序:`perf_desc` / `perf_asc`
- 卡片展示:定档课时、折前课时(新入职才有)、距升档差距、是否达标
- 数据来源:`dws_assistant_salary_calc.effective_hours`
#### 工资维度salary
- 排序字段:`gross_salary`
- 计算公式:`assistant_pd_money_total + assistant_cx_money_total + bonus_money + room_income`
- 卡片展示:工资金额、定档课时、折前课时
- 数据来源:`dws_assistant_salary_calc`
#### 客源储值维度sv
- 排序字段:`sv_amount`(客户余额合计)
- 卡片展示:储值金额、储值客户数、储值消耗
- 数据来源:`fdw_queries.get_coach_sv_data()`
- 互斥规则:`time=last_6m` 时不支持此维度HTTP 400
#### 任务维度task
- 排序字段:`task_total`recall + callback
- 卡片展示:回访完成数、召回完成数
- 数据来源:业务库 `biz.coach_tasks` 按 `task_type` 分类统计
### 4.2 客户看板八维度
#### 最应召回recall
- 排序:自定义排序(综合理想间隔、已过天数、余额等因素)
- 展示理想间隔天数、已过天数、逾期天数、30天到店次数、余额、召回指数
- 数据来源:`v_dws_member_consumption_summary` + `v_dws_member_winback_index`
#### 最大消费潜力potential
- 排序:自定义评分
- 展示潜力标签、30天消费、平均到店频率、平均客单价
- 数据来源:`v_dws_member_consumption_summary`
#### 最高余额balance
- 排序:`total_card_balance DESC`(卡余额快照值)
- 展示:最后到店日期、月消费、可用月数
- 数据来源:`v_dim_member_card_account`(快照值,取最后一天)
- ⚠️ 余额是快照值,禁止 SUM
#### 最近充值recharge
- 排序:`recharge_amount DESC`
- 展示最后充值日期、充值金额、60天充值次数、当前余额
- 数据来源:`v_dws_member_consumption_summary`
#### 最近到店recent
- 排序:`last_visit_date DESC`
- 展示距今天数、60天到店次数
- 数据来源:`v_dws_member_consumption_summary`
#### 最高消费 近60天spend60
- 排序:`consume_amount_60d DESC`
- 展示60天消费金额、60天到店次数、高消费标签
- 数据来源:`v_dws_member_consumption_summary`
#### 最频繁 近60天freq60
- 排序:`visit_count_60d DESC`
- 展示平均间隔天数、8周柱状图
- 数据来源:`v_dws_member_consumption_summary`(汇总)+ `v_dwd_assistant_service_log`(周粒度)
**8 周柱状图统计规则**(已实现,`_get_weekly_visits_batch()`
- 时间范围:最近 56 天8 个自然周)
- 分组:按 ISO 周(`DATE_TRUNC('week', create_time::date)`
- 每周统计到店次数(`COUNT(*)`
- `val`:该周到店次数
- `pct`:相对于 8 周中最高值的百分比(`val / max_val * 100`
- 固定返回 8 个元素,无数据的周 `val=0, pct=0`
#### 最专一 近60天loyal
- 排序:`max_rs DESC`(最高 RSI 降序)
- 展示:最高 RSI 助教(右上角)、助教服务明细表
- 数据来源:`v_dws_member_assistant_relation_index`
**排列规则**(用户明确):
1. 客户-助教对RSI 从高到低排列
2. 客户不重复,排重规则是放在最高对的位置
3. 每个卡片展示客户信息 + 该客户对应所有助教 RSI 值从高到低排列
4. 右上位置展示最高 RSI 值助教
**RSI关系指数**
- 存储:`dws_member_assistant_relation_index` 表
- 展示值:`rs_display`0-10 刻度)
- Emoji 四级映射:`>8.5→💖` / `>7→🧡` / `>5→💛` / `≤5→💙`
- 计算:由 DWS 层 `DWS_RELATION_INDEX` 任务产出RS/OS/MS/ML 四个子指数)
### 4.3 薪酬计算规则DWS 需求文档 3.2 节)
**现行方案2026-03-01 起)**
| 档位 | 总业绩小时数阈值 | 专业课抽成(元/h | 打赏课抽成 | 次月休假 |
|------|-----------------|-------------------|-----------|---------|
| 0档 淘汰压力 | H < 120 | 28 | 50% | 3天 |
| 1档 及格档 | 120 ≤ H < 150 | 18 | 40% | 4天 |
| 2档 良好档 | 150 ≤ H < 180 | 13 | 35% | 5天 |
| 3档 优秀档 | 180 ≤ H < 210 | 10 | 30% | 6天 |
| 4档 销冠竞争 | H ≥ 210 | 8 | 25% | 休假自由 |
- 过档后所有时长按新档位计算
- 新入职助教:按日均 × 30 折算定档25日后入职最高定档至 2档
- 折算仅用于定档,不适用于 Top3 奖
- 档位节点:`[0, 120, 150, 180, 210]`(从 `cfg_performance_tier` 配置表读取)
---
## 五、数据层依赖
### 5.1 DWS 表
| 表名 | 用途 | 使用页面 |
|------|------|---------|
| `dws_assistant_salary_calc` | 助教绩效/工资 | BOARD-1、COACH-1 |
| `dws_member_consumption_summary` | 客户消费汇总 | BOARD-26个维度 |
| `dws_member_assistant_relation_index` | 客户-助教关系指数 | BOARD-2loyal、CUST-1 |
| `dws_member_winback_index` | 客户召回指数 | BOARD-2recall |
### 5.2 DWD 表
| 表名 | 用途 | 使用页面 |
|------|------|---------|
| `dwd_assistant_service_log` | 服务记录明细 | BOARD-2freq60 周粒度、COACH-1 |
| `dim_member` | 会员维度表 | 所有页面JOIN 取姓名) |
| `dim_assistant` | 助教维度表 | 所有页面JOIN 取姓名) |
| `dim_member_card_account` | 会员卡账户 | BOARD-2balance、CUST-1 |
### 5.3 配置表
| 表名 | 用途 |
|------|------|
| `cfg_performance_tier` | 绩效档位节点 |
| `cfg_bonus_rules` | 奖金规则 |
| `cfg_skill_type` | 课程类型映射skill_id → BASE/BONUS/ROOM |
| `cfg_area_category` | 区域-项目类型映射BILLIARD/SNOOKER/MAHJONG/KTV |
### 5.4 业务库表
| 表名 | 用途 | 使用页面 |
|------|------|---------|
| `biz.coach_tasks` | 助教任务 | BOARD-1任务维度、COACH-1、CUST-1 |
| `biz.notes` | 备注 | COACH-1、CUST-1 |
| `biz.ai_cache` | AI 洞察缓存 | CUST-1暂不处理 |
| `public.member_retention_clue` | 维客线索 | CUST-1 |
---
## 六、实施顺序建议
```
Phase 1 — 基础联调(前端 Mock → 真实 API
├── P9: 修复客户看板项目筛选枚举5 分钟)
├── P4: 助教看板 Mock → 真实 API
├── P5: 客户看板 Mock → 真实 API
├── P2: 助教详情页 Mock → 真实 API
└── P3: 客户详情页 Mock → 真实 APIAI 相关暂跳过)
Phase 2 — 数据补全
├── P6: 助教 skills 字段填充
├── P7: 助教看板折前课时字段
└── P8: 客户看板 loyal 维度 coachDetails 子数组
Phase 3 — 交互优化
└── P1: 看板 Tab 切换改为同页切换
```
---
## 七、风险点
1. **TS 与 WXS 格式化互斥**:替换 Mock 时,`setData` 必须传原始数字,禁止用 `formatMoney()` 预格式化
2. **null 值清洗**:后端返回 null 的字段,前端必须 `?? 0` / `?? ''` 清洗后再传给组件
3. **Pydantic 静默丢弃**:后端 service 新增返回字段时,必须同步更新 Schema否则数据被静默丢弃
4. **余额快照值**`balance` 是日末快照,禁止 SUM 聚合
5. **档位节点**:前端硬编码的 `[0, 100, 130, 160, 190, 220]` 与实际 `[0, 120, 150, 180, 210]` 不一致
6. **看板 Tab 重构**:如果 board-finance 是 tabBar 页面,合并后需要调整 `app.json` 和 custom-tab-bar
7. **助教姓名**:所有助教必须显示昵称/花名(`nickname`),禁止显示真实姓名(`real_name`。SQL 统一用 `COALESCE(nickname, real_name, '')`

View File

@@ -0,0 +1,99 @@
# 财务看板助教分析按区域细化 — 执行文档
> 日期2026-03-29 | 状态:待实施
## 背景
财务看板已完成区域维度重构(`board-finance-dws-area-refactor`overview/revenue 支持 9 区域过滤。助教分析板块coachAnalysis当前 area≠all 时隐藏,需要支持按区域过滤。
## 需求确认
- 展示方式A — area≠all 时自动按区域过滤,前端零改动(取消隐藏即可)
- 数据源:新建轻量 DWS 表,不改造现有 salary_calc 链路
- 跨区域:按区域拆分(同一助教在不同区域独立统计 hours
- 前端:取消隐藏,显示按区域过滤后的数据
## 数据链路
现有链路(不改动):
```
dwd_assistant_service_log → dws_assistant_daily_detail → dws_assistant_monthly_summary → dws_assistant_salary_calc
```
唯一键 `(site_id, assistant_id, stat_date/month)` 无区域维度,改造代价过大。
新增链路:
```
dwd_assistant_service_log + dim_table(area_name)
→ [CoachAreaHoursTask] → dws_coach_area_hours (新表)
后端 fdw_queries (area≠all 时 JOIN salary_calc 获取定价)
```
## 实施步骤
### 1. DDL — 创建 dws_coach_area_hours
```sql
CREATE TABLE dws.dws_coach_area_hours (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
stat_month DATE NOT NULL,
assistant_id BIGINT NOT NULL,
area_code VARCHAR(20) NOT NULL,
base_hours NUMERIC(10,2) NOT NULL DEFAULT 0,
bonus_hours NUMERIC(10,2) NOT NULL DEFAULT 0,
room_hours NUMERIC(10,2) NOT NULL DEFAULT 0,
effective_hours NUMERIC(10,2) NOT NULL DEFAULT 0,
trashed_hours NUMERIC(10,2) NOT NULL DEFAULT 0,
base_service_count INTEGER NOT NULL DEFAULT 0,
bonus_service_count INTEGER NOT NULL DEFAULT 0,
room_service_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (site_id, stat_month, assistant_id, area_code)
);
CREATE OR REPLACE VIEW dws.v_dws_coach_area_hours AS
SELECT * FROM dws.dws_coach_area_hours
WHERE site_id = current_setting('app.current_site_id')::bigint;
GRANT SELECT ON dws.v_dws_coach_area_hours TO app_reader;
```
### 2. ETL 任务 — CoachAreaHoursTask
文件:`apps/etl/connectors/feiqiu/tasks/dws/coach_area_hours_task.py`
- extract`dwd_assistant_service_log` + `dwd_assistant_service_log_ex`(is_trash) + `dim_table`(scd2_is_current=1) 提取当月服务记录
- transform纯函数`resolve_area_code(area_name)` 映射区域,按 `(assistant_id, area_code)` 聚合 hours构建 hall/all 汇总行
- loaddelete-before-insert按 site_id + stat_month
- 注册到调度器
### 3. 后端改造
- `fdw_queries.py`:新增 `get_finance_coach_analysis_area(conn, site_id, start_date, end_date, area_code)`
-`v_dws_coach_area_hours` JOIN `v_dws_assistant_salary_calc` 按区域聚合
- SQL 层计算 pay = hours × course_price, share = hours × (price - deduction)
- `board_service.py``_build_coach_analysis` 接收 area_code
- area=all → 现有逻辑不变
- area≠all → 调用新查询
- 取消 `coach_analysis = None` 隐藏逻辑
### 4. 回填 + 联调
- 回填脚本:`scripts/ops/backfill_coach_area_hours.py`
- 联调验证area=all 回归 + area≠all 数据正确
### 5. 收尾
- DDL 合并到基线
- BD 手册
- 审计
## 风险
1. 助教跨区域按区域独立统计area≠all 只显示该区域 hours
2. NULL table_id计入 hall + all不计入具体区域
3. area=all 回归:必须与现有完全一致
4. salary_calc 同一助教同月可能多等级月中升级JOIN 时需注意

View File

@@ -0,0 +1,375 @@
# SPEC: 财务看板 DWS 区域维度重构
> 创建日期2026-03-28
> 前置 SPEC`board-finance-phase2`(已完成)、`board-finance-phase2-validation`(已完成)
> 状态:待确认
> 优先级P1
---
## 一、背景与问题
当前财务看板后端 6 个板块的数据来源分散:
- overview/cashflow/recharge 从 `dws_finance_daily_summary`(全局日汇总,无区域维度)
- revenue 从 `dwd_settlement_head`(结算单级别,有桌台→区域映射)
- expense 从 `dws_finance_expense_summary` + `dws_platform_settlement`
- coach_analysis 从 `dws_assistant_salary_calc`
**核心 bug**area≠all 时,优惠查询仍从全局 DWS 取数,导致 B区发生额 ¥43,049 但优惠 ¥179,884全局优惠优惠占比 417.9%。
## 二、方案概述
两层架构:
- **方案 A原子层**:新建 `dws_finance_area_daily`,按 `(stat_date, area_code)` 日粒度存储ETL 每天计算
- **方案 B缓存层**:新建 `dws_finance_board_cache`,缓存已完成周期的聚合结果,避免重复计算
后端查询逻辑:先查缓存 → 未命中则从日粒度表实时 SUM。
## 三、方案 A — 原子层 `dws_finance_area_daily`
### 3.1 表结构
```sql
CREATE TABLE dws.dws_finance_area_daily (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
area_code VARCHAR(20) NOT NULL, -- all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv
-- ── 收入结构(从 dwd_settlement_head 按区域聚合)──
table_fee_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
goods_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
assistant_pd_amount NUMERIC(14,2) NOT NULL DEFAULT 0, -- 基础课(陪打)
assistant_cx_amount NUMERIC(14,2) NOT NULL DEFAULT 0, -- 激励课(超休)
gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0, -- = 四项之和
-- ── 优惠拆分(从 dwd_settlement_head 按区域聚合6 项恒等式)──
discount_groupbuy NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_vip NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_manual NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_gift_card NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_rounding NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_other NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_total NUMERIC(14,2) NOT NULL DEFAULT 0, -- = 6 项之和
-- ── 确认收入 ──
confirmed_income NUMERIC(14,2) NOT NULL DEFAULT 0, -- = gross_amount - discount_total
-- ── 现金流(仅 area_code='all' 时有值,区域级无法拆分)──
cash_pay_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_paper_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
scan_pay_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
groupbuy_pay_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
recharge_cash_inflow NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_inflow_total NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_outflow_total NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_balance_change NUMERIC(14,2) NOT NULL DEFAULT 0,
-- ── 卡消费(仅 area_code='all')──
card_consume_total NUMERIC(14,2) NOT NULL DEFAULT 0,
recharge_card_consume NUMERIC(14,2) NOT NULL DEFAULT 0,
gift_card_consume NUMERIC(14,2) NOT NULL DEFAULT 0,
-- ── 充值(仅 area_code='all')──
recharge_cash NUMERIC(14,2) NOT NULL DEFAULT 0,
first_recharge_cash NUMERIC(14,2) NOT NULL DEFAULT 0,
renewal_cash NUMERIC(14,2) NOT NULL DEFAULT 0,
-- ── 订单统计 ──
order_count INTEGER NOT NULL DEFAULT 0,
-- ── 元数据 ──
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (site_id, stat_date, area_code)
);
```
### 3.2 area_code 枚举
| area_code | 含义 | 物理区域映射 |
|-----------|------|-------------|
| all | 全部区域 | 所有桌台 |
| hall | 大厅A+B+C+包厢+斯诺克+麻将+团建) | 同 all历史兼容 |
| hallA | A区 | site_table_area_name = 'A区' |
| hallB | B区 | site_table_area_name = 'B区' |
| hallC | C区 | 'C区'/'TV台'/'美洲豹赛台' |
| vip | 台球包厢 | 'VIP包厢' |
| snooker | 斯诺克 | '斯诺克区' |
| mahjong | 麻将房 | '麻将房'/'M7'/'M8'/'666'/'发财' |
| ktv | 团建房 | 'K包'/'k包活动区'/'幸会158' |
### 3.3 ETL 计算逻辑
每次 ETL 运行时,对当天(按营业日切点 `BUSINESS_DAY_START_HOUR=8`
1.`dwd_settlement_head` + `dim_table` 按区域聚合收入和优惠字段
2. `all` 行 = 所有区域之和 + 现金流/充值等全局字段(从现有 `dws_finance_daily_summary` 逻辑复用)
3. 各区域行 = 该区域的结算单聚合,现金流/充值字段为 0无法按区域拆分
4. delete-before-insert 策略:`DELETE WHERE site_id=X AND stat_date=Y; INSERT 9 行`
### 3.4 优惠按区域拆分方案
优惠字段从 `dwd_settlement_head` 按桌台区域直接聚合(不再从 DWS 全局表取):
```sql
-- 每张结算单通过 table_id → dim_table.site_table_area_name → area_code 映射
SELECT area_code,
SUM(coupon_amount) AS discount_groupbuy, -- 团购券抵扣
SUM(member_discount_amount) AS discount_vip, -- 会员折扣
SUM(adjust_amount) AS discount_manual_raw, -- 手动调整(含大客户优惠)
SUM(gift_card_amount) AS discount_gift_card, -- 赠送卡抵扣(= balance_amount - recharge_card_amount
SUM(rounding_amount) AS discount_rounding -- 抹零
FROM dwd_settlement_head h
JOIN dim_table t ON h.table_id = t.table_id AND t.scd2_is_current = 1
WHERE settle_type IN (1, 3)
AND biz_date(create_time, 8) = :stat_date
GROUP BY area_code
```
这样每个区域的优惠就是该区域桌台上实际发生的优惠,不存在分摊问题。
### 3.5 优惠按区域拆分 — 样例验证
以 2026-03 本月 B区为例对比修复前后
| 指标 | 修复前(全局优惠) | 修复后(按结算单归属) |
|------|---------------------|---------------------|
| B区发生额 | ¥43,049 | ¥43,050 |
| B区优惠 | ¥179,884 | ¥31,327 |
| 优惠占比 | 417.9% | 72.8% |
B区 72.8% 的优惠率仍偏高,但查看具体结算单后确认是真实业务现象:
- Top 8 优惠单中 7 张是会员折扣 = 台费全额(会员用储值卡全额抵扣)
- 手动调整和抹零在 B区几乎为 0
- 不存在"整单优惠被错误归属到小区域"的问题——每张结算单对应一张桌台
结论:按结算单直接归属区域是正确的,不需要分摊。
注意:`discount_gift_card` 的口径是赠送卡消费金额ETL 中 `gift_card_consume_amount`),不是结算单的 `gift_card_amount`(赠送卡支付金额)。新表需要复用现有 ETL 的同一计算逻辑。
## 四、方案 B — 缓存层 `dws_finance_board_cache`
### 4.1 表结构
```sql
CREATE TABLE dws.dws_finance_board_cache (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
time_range VARCHAR(20) NOT NULL, -- month/lastMonth/week/lastWeek/quarter/lastQuarter/quarter3/half6
area_code VARCHAR(20) NOT NULL, -- all/hall/hallA/.../ktv
start_date DATE NOT NULL, -- 当期起始日
end_date DATE NOT NULL, -- 当期截止日
prev_start_date DATE, -- 上期起始日(环比用)
prev_end_date DATE, -- 上期截止日
-- ── 经营一览overview──
occurrence NUMERIC(14,2) NOT NULL DEFAULT 0,
discount NUMERIC(14,2) NOT NULL DEFAULT 0,
discount_rate NUMERIC(8,4) NOT NULL DEFAULT 0,
confirmed_revenue NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_in NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_out NUMERIC(14,2) NOT NULL DEFAULT 0,
cash_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
balance_rate NUMERIC(8,4) NOT NULL DEFAULT 0,
-- ── 数据指纹(用于缓存失效检测)──
data_fingerprint VARCHAR(64), -- 源数据 hash用于检测补录导致的数据变化
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- ── 元数据 ──
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (site_id, time_range, area_code)
);
```
### 4.2 缓存策略
| 时间范围 | 缓存行为 | 失效条件 |
|---------|---------|---------|
| month/week/quarter | 不缓存(当期数据每天在变) | — |
| lastMonth/lastWeek/lastQuarter | 缓存(已完成周期) | 数据指纹变化 |
| quarter3/half6 | 缓存(不含本月) | 数据指纹变化 |
### 4.3 数据指纹机制
每次 ETL 计算完日粒度数据后,对已完成周期的源数据计算指纹:
```python
# 指纹 = 该时间范围内所有日粒度行的 (stat_date, gross_amount, discount_total) 的 hash
fingerprint = hashlib.md5(
json.dumps(sorted(rows, key=lambda r: r['stat_date'])).encode()
).hexdigest()
```
ETL 流程:
1. 计算当天日粒度数据(方案 A
2. 对每个已完成周期,计算新指纹
3. 与缓存表中的 `data_fingerprint` 对比
4. 不一致 → 重算该周期的缓存(从日粒度表 SUM
5. 一致 → 跳过
## 五、后端查询改造
### 5.1 查询流程
```
请求: GET /api/xcx/board/finance?time=X&area=Y&compare=Z
1. 判断 time_range 是否为已完成周期
2. 已完成周期 → 查 dws_finance_board_cache
- 命中 → 直接返回缓存数据
- 未命中 → 从日粒度表 SUM写入缓存后返回
3. 当期周期 → 从 dws_finance_area_daily SUM
4. compare=1 → 对上期也执行同样逻辑,然后 calc_compare
```
### 5.2 各板块数据来源改造
| 板块 | 当前来源 | 改造后来源 |
|------|---------|-----------|
| overview | dws_finance_daily_summary全局 | dws_finance_area_daily按 area_code 过滤) |
| recharge | dws_finance_recharge_summary + dws_finance_daily_summary | 不变(仅 area=all 时显示) |
| revenue | dwd_settlement_head实时查 DWD+ dws_finance_daily_summary优惠/渠道) | dws_finance_area_daily收入+优惠+渠道全部预计算) |
| cashflow | dws_finance_daily_summary全局 | dws_finance_area_dailyarea_code='all',现金流无法按区域拆分) |
| expense | dws_finance_expense_summary + dws_platform_settlement | 不变(仅 area=all 时显示) |
| coach_analysis | dws_assistant_salary_calc | 不变(仅 area=all 时显示) |
### 5.3 area≠all 时的行为
area≠all 时,只有 overview 和 revenue 需要按区域过滤:
- overview`dws_finance_area_daily WHERE area_code=Y` SUM
- revenue`dws_finance_area_daily WHERE area_code=Y` SUM构建 structure_rows/discount_items/price_items/channel_items
- cashflow/expense/coach_analysis仍用全局数据前端隐藏这些板块但后端仍返回
- recharge返回 null
### 5.4 接口契约(硬约束 — 前端零改动)
本次重构仅改变后端数据来源(从实时查 DWD/DWS → 查预计算的 `dws_finance_area_daily`**API 签名和返回数据结构完全不变**。
#### 5.4.1 API 签名不变
```
GET /api/xcx/board/finance?time={FinanceTimeEnum}&area={AreaFilterEnum}&compare={0|1}
Authorization: Bearer {token}
Response: FinanceBoardResponse (response_model_exclude_none=True)
```
#### 5.4.2 返回结构不变Pydantic Schema 不改)
以下 Schema 类保持原样,不新增、不删除、不改名任何字段:
| Schema 类 | 说明 |
|-----------|------|
| `FinanceBoardResponse` | 顶层overview + recharge? + revenue + cashflow + expense + coach_analysis |
| `OverviewPanel` | 8 项核心指标 + 8 组环比字段 |
| `RechargePanel` | 储值卡 5 指标 + 赠送卡 3×4 矩阵 + 全卡余额 |
| `RevenuePanel` | structure_rows + price_items + discount_items + channel_items + 总计 |
| `CashflowPanel` | consume_items + recharge_items + total |
| `ExpensePanel` | 4 组 items + total |
| `CoachAnalysisPanel` | basic + incentive各含 rows + 总计) |
#### 5.4.3 字段值语义不变
| 字段 | 语义约束 |
|------|---------|
| `overview.discountRate` | area=all 时 0~1area≠all 时可能 > 1区域级优惠占比 |
| `overview.occurrence/discount/confirmedRevenue` | area≠all 时 = revenue 板块的对应值(后端覆盖逻辑保留) |
| `recharge` | area≠all 时为 null |
| `revenue.discount_items` | 固定 5 项(团购/会员折扣/手动调整/赠送卡/其他) |
| `revenue.channel_items` | 固定 3 项(储值卡结算冲销/现金线上支付/团购核销) |
| `cashflow.total` | ≥ SUM(consume_items) + SUM(recharge_items)(可能包含额外项) |
| 环比字段 | compare=1 时非空("X.X%"/"持平"/"新增"compare=0 时为 null |
#### 5.4.4 前端验证清单
重构完成后,用现有 `scripts/ops/validate_board_finance.py` 跑 39 组合验证:
- area=all 时所有板块数据与重构前完全一致(回归测试)
- area≠all 时优惠数据合理discountRate 不再出现 400%+ 的异常值)
- 环比数据基于该区域的历史数据(不是全局对比)
## 六、ETL 任务设计
### 6.1 新增任务DWS_FINANCE_AREA_DAILY
- 位置:`apps/etl/connectors/feiqiu/tasks/dws/finance_area_daily.py`
- 依赖DWD_LOAD_FROM_ODS结算单已入 DWD
- 调度:每小时(与现有 DWS_FINANCE_DAILY 同频)
- 策略delete-before-insert按 site_id + stat_date 删除 9 行后重新插入)
计算步骤:
1.`dwd_settlement_head` + `dim_table` 按区域聚合收入和优惠
2. 从现有 `dws_finance_daily_summary` 取全局现金流/充值/卡消费字段
3. 构建 9 行all + 8 个区域all 行 = 各区域之和 + 全局字段
4. 写入 `dws_finance_area_daily`
### 6.2 新增任务DWS_FINANCE_BOARD_CACHE
- 位置:`apps/etl/connectors/feiqiu/tasks/dws/finance_board_cache.py`
- 依赖DWS_FINANCE_AREA_DAILY
- 调度:每天一次(营业日切点后)
- 策略:指纹对比,不一致则重算
计算步骤:
1. 遍历已完成周期lastMonth/lastWeek/lastQuarter/quarter3/half6
2. 对每个周期 × 9 个区域,计算源数据指纹
3. 与缓存表对比,不一致则从 `dws_finance_area_daily` SUM 重算
4. 写入/更新 `dws_finance_board_cache`
### 6.3 区域映射共享配置
将区域映射从 Python 硬编码抽成共享配置ETL 和后端共用:
```python
# packages/shared/src/neozqyy_shared/area_mapping.py
AREA_LABEL_MAP = {
"hallA": ["A区"],
"hallB": ["B区"],
"hallC": ["C区", "TV台", "美洲豹赛台"],
"vip": ["VIP包厢"],
"snooker": ["斯诺克区"],
"mahjong": ["麻将房", "M7", "M8", "666", "发财"],
"ktv": ["K包", "k包活动区", "幸会158"],
}
# hall = 所有区域之和(不含 all
# all = 所有区域之和
```
## 七、营业日切点
- `.env``BUSINESS_DAY_START_HOUR=8`
- ETL 使用 `biz_date_sql_expr(create_time, cutoff_hour)` 计算 stat_date
- 新表同样使用此切点,确保与现有 DWS 表一致
## 八、实施计划
| 阶段 | 任务 | 预估工时 |
|------|------|---------|
| T1 | 共享区域映射配置 | 0.5h |
| T2 | DDL创建 dws_finance_area_daily + RLS 视图 | 0.5h |
| T3 | ETLDWS_FINANCE_AREA_DAILY 任务 | 3h |
| T4 | DDL创建 dws_finance_board_cache + RLS 视图 | 0.5h |
| T5 | ETLDWS_FINANCE_BOARD_CACHE 任务(含指纹机制) | 2h |
| T6 | 后端:改造 fdw_queries.py 6 个查询函数 | 3h |
| T7 | 后端:改造 board_service.py 缓存查询逻辑 | 2h |
| T8 | 验证:重跑 144 组合验证脚本 | 1h |
| T9 | 历史数据回填:对已有日期范围批量计算 | 1h |
## 九、风险与缓解
1. **区域映射一致性**抽成共享配置T1ETL 和后端共用同一份映射
2. **优惠按区域拆分**:直接从结算单按桌台区域聚合,不做分摊。每张结算单对应一张桌台,优惠归属该桌台所在区域
3. **缓存失效**数据指纹机制T5补录后自动检测并重算
4. **营业日切点**:从 `.env` 读取ETL 和后端共用
5. **向后兼容**:新表是增量,不修改现有 `dws_finance_daily_summary`,可并行运行验证
## 十、验证标准
- 144 组合全量验证脚本通过(复用 `scripts/ops/validate_board_finance.py`
- area=all 时数据与现有逻辑完全一致(回归测试)
- area≠all 时优惠数据合理discountRate ≤ 1 或接近 1
- 已完成周期缓存命中率 100%(第二次请求不触发 SUM 计算)

View File

@@ -0,0 +1,119 @@
# SPEC: 财务看板前后端联调board-finance-integration
> 创建日期2026-03-27
> 状态:进行中
> 优先级P0
## 背景
财务看板页面(`pages/board-finance/board-finance`)当前数据全为空——前端只绑定了赠送卡矩阵,其余 5 个板块的 API 返回数据未做 `setData`。同时走查发现多个数据层问题需要修复。
## 目标
1. 前端完整绑定 API 返回的 6 个板块数据
2. 筛选变更(时间/区域/环比)触发重新加载
3. 修复已确认的数据层问题
4. 优化加载效率
## 任务清单
### 阶段 1DWS 层改造ETL
#### T1.1 DWS 财务日报新增支付方式拆分字段
- 目标表:`dws.dws_finance_daily_summary`
- 新增字段:`cash_paper_amount`(纸币现金)、`scan_pay_amount`(扫码收款)
- 数据来源:`dwd_payment` 表按 `payment_method` 拆分2=现金4=离线/扫码)
- 恒等式:`cash_paper_amount + scan_pay_amount = cash_pay_amount`
- 涉及文件:
- `db/etl_feiqiu/migrations/2026-03-27__add_payment_split_to_finance_daily.sql`DDL
- `apps/etl/connectors/feiqiu/tasks/dws/finance_base_task.py`(新增 extract 方法)
- `apps/etl/connectors/feiqiu/tasks/dws/finance_daily_task.py`transform 使用新字段)
- `docs/database/ddl/etl_feiqiu__dws.sql`DDL 基线更新)
#### T1.2 RLS 视图更新
- 更新 `app.v_dws_finance_daily_summary` 暴露新字段
- 涉及文件:`db/etl_feiqiu/migrations/2026-03-27__update_finance_daily_view.sql`
### 阶段 2后端 API 修复
#### T2.1 修复预收资产卡余额聚合(快照值 SUM → 取最后一天)
- 文件:`apps/backend/app/services/fdw_queries.py``get_finance_recharge()`
- 改动卡余额字段cash_card_balance/gift_card_balance/total_card_balance 及 gift 细分)改为取时间范围内最后一天的值
- 同时填充 `consumed` 字段(从 `dws_finance_daily_summary.card_consume_total` SUM 聚合)
#### T2.2 修复现金流入板块
- 文件:`apps/backend/app/services/fdw_queries.py``get_finance_cashflow()`
- 改动:
- 移除"储值卡消费"项
- 新增"纸币现金"和"扫码收款"两项(从新字段读取)
- 保留"团购平台回款"和"会员充值到账"
#### T2.3 修复助教分析小时均价 + 补充三列数据
- 文件:`apps/backend/app/services/fdw_queries.py``get_finance_coach_analysis()`
- 改动:
- 基础课用 `base_hours`,激励课用 `bonus_hours`
- 新增 `total_pay`(对客收费 = hours × course_price
- 新增 `total_share`(球房分成 = hours × deduction
- SQL 新增 `SUM(base_hours)``SUM(bonus_hours)``SUM(base_course_price)``SUM(bonus_course_price)``SUM(base_deduction)``SUM(bonus_deduction_ratio)` 的加权计算
#### T2.4 区域筛选枚举重建
- 文件:`apps/backend/app/schemas/xcx_board.py``AreaFilterEnum`
- 改动:从 7 项改为 9 项all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv
- 后端 service 层传递 area 参数到 FDW 查询(本次不实现 SQL 过滤,仅传参预留)
#### T2.5 Pydantic Schema 同步
- 文件:`apps/backend/app/schemas/xcx_board.py`
- 确保 FinanceBoardResponse 及子 Panel 的字段与 service 返回一致
### 阶段 3前端联调
#### T3.1 重写数据加载函数
- 文件:`apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`
- 改动:
- `_loadGiftRows()``_loadData()`,绑定全部 6 个板块
- 金额用 `formatMoney()`,百分比用 `toFixed(1) + '%'`
- 后端返回原始数字,前端按位置格式化
#### T3.2 筛选联动
- `onTimeChange`/`onAreaChange`/`toggleCompare` 变更后调用 `_loadData()`
- 添加 loading 态管理
#### T3.3 区域筛选选项更新
- 前端 `areaOptions` 从 7 项改为 9 项
- 非"全部"时隐藏:预收资产、现金流出、助教分析
#### T3.4 微信开发者工具验证
- 截图对比各板块数据
- 验证筛选切换、环比开关
## 区域筛选对照表
| code | 前端显示 | 包含的物理区域 |
|------|---------|-------------|
| all | 全部区域 | 所有 |
| hall | 大厅 | A区+B区+C区+TV台+美洲豹赛台 |
| hallA | A区 | A区 |
| hallB | B区 | B区 |
| hallC | C区 | C区+TV台+美洲豹赛台 |
| vip | 台球包厢 | VIP包厢 |
| snooker | 斯诺克 | 斯诺克区 |
| mahjong | 麻将房 | 麻将房+M7+M8+666+发财 |
| ktv | 团建房 | K包+k包活动区+幸会158 |
## 区域筛选影响板块
| 板块 | 非"全部"时 |
|------|-----------|
| 经营一览 | 按区域过滤 |
| 预收资产 | 隐藏 |
| 应计收入确认 | 按区域过滤 |
| 现金流入 | 按区域过滤 |
| 现金流出 | 隐藏 |
| 助教分析 | 隐藏 |
## 不做
- 区域级 SQL 过滤DWS 视图改造,后续迭代)
- 助教分析按区域统计(已记入 BACKLOG
- 各区域各收费项目对比(已记入 BACKLOG
- 支出数据 Excel 导入功能

View File

@@ -0,0 +1,162 @@
# SPEC: 财务看板 Phase 2 — 144 组合全量验证
> 创建日期2026-03-28
> 前置 SPEC`board-finance-phase2`(已完成 T1-T6 + bugfix
> 状态:已完成 ✅2026-03-28
> 优先级P1
---
## 一、目标
遍历财务看板全部 144 种筛选组合8 时间 × 9 区域 × 2 环比),验证后端 API 返回数据与前端 `page.data` 一致性。
## 二、组合矩阵
### 时间筛选8 种)
| 枚举值 | 显示 | 当期范围 | 上期范围 |
|--------|------|---------|---------|
| month | 本月 | 月首~今天 | 上月首~上月同日 |
| lastMonth | 上月 | 上月首~上月末 | 再上月首~再上月末 |
| week | 本周 | 周一~今天 | 上周一~上周同天 |
| lastWeek | 上周 | 上周一~上周日 | 再上周一~再上周日 |
| quarter3 | 前3个月 | 往前3月首~上月末 | 再往前等长 |
| quarter | 本季度 | 季首~今天 | 上季首~上季同天 |
| lastQuarter | 上季度 | 上季首~上季末 | 再上季首~再上季末 |
| half6 | 最近6个月 | 往前6月首~上月末 | 再往前等长 |
### 区域筛选9 种)
| 枚举值 | 显示 | 影响板块 |
|--------|------|---------|
| all | 全部区域 | 6 板块全显示 |
| hall | 大厅 | 隐藏:预收资产/现金流出/助教分析 |
| hallA | A区 | 同上 |
| hallB | B区 | 同上 |
| hallC | C区 | 同上 |
| vip | 台球包厢 | 同上 |
| snooker | 斯诺克 | 同上 |
| mahjong | 麻将房 | 同上 |
| ktv | 团建房 | 同上 |
### 环比开关2 种)
| 值 | 说明 |
|----|------|
| 0 | 关闭,环比字段为空 |
| 1 | 开启,环比字段有值 |
## 三、验证项清单
每种组合需验证以下字段(共 6 板块):
### 3.1 经营一览overview— 始终显示
| # | 字段 | 验证规则 |
|---|------|---------|
| O1 | occurrence | ≥0数字类型 |
| O2 | discount | ≥0数字类型 |
| O3 | discountRate | 0~1 之间 |
| O4 | confirmedRevenue | = occurrence - discount |
| O5 | cashIn | ≥0 |
| O6 | cashOut | ≥0 |
| O7 | cashBalance | = cashIn - cashOut |
| O8 | balanceRate | cashIn>0 时 = cashBalance/cashIn |
| O9 | area≠all 时 | occurrence/discount/confirmedRevenue 应与 revenue 板块一致 |
| O10 | compare=1 时 | 8 个 xxxCompare 字段非空,格式为 "X.X%" / "持平" / "新增" |
| O11 | compare=0 时 | 8 个 xxxCompare 字段为空/null |
### 3.2 预收资产recharge— 仅 area=all 时返回
| # | 字段 | 验证规则 |
|---|------|---------|
| R1 | area≠all 时 | recharge 为 null |
| R2 | actualIncome | ≥0 |
| R3 | firstCharge + renewCharge | ≈ actualIncome |
| R4 | cardBalance | ≥0快照值 |
| R5 | allCardBalance | ≥ cardBalance |
| R6 | giftRows | 长度 3新增/消费/余额) |
| R7 | compare=1 时 | allCardBalanceCompare 非空 |
### 3.3 应计收入确认revenue— 始终显示
| # | 字段 | 验证规则 |
|---|------|---------|
| V1 | structureRows | 长度 ≥ 3至少有主行+助教+食品) |
| V2 | area≠all 时 | structureRows 只含对应区域 |
| V3 | totalOccurrence | = SUM(structureRows 非 isSub 行的 amount) |
| V4 | discountTotal | = SUM(discountItems 的 amount) |
| V5 | confirmedTotal | = totalOccurrence - discountTotal |
| V6 | discountItems | 长度 5团购/会员折扣/手动调整/赠送卡/其他) |
| V7 | channelItems | 长度 3 |
| V8 | priceItems | 长度 3 |
| V9 | structureRows 优惠列 | 主行 discount = discountTotal子行按占比分摊 |
| V10 | compare=1 时 | totalOccurrenceCompare/confirmedTotalCompare 非空 |
| V11 | compare=1 时 | structureRows 各行 bookedCompare 非空 |
### 3.4 现金流入cashflow— 始终显示
| # | 字段 | 验证规则 |
|---|------|---------|
| C1 | consumeItems | 长度 2-3纸币/线上/团购 或 合并项/团购) |
| C2 | rechargeItems | 长度 1 |
| C3 | total | = SUM(consumeItems) + SUM(rechargeItems) |
| C4 | consumeItems 各项 | desc 非空(柜台现金收款等) |
| C5 | compare=1 时 | totalCompare 非空,各项 compare 非空 |
### 3.5 现金流出expense— 仅 area=all 时显示
| # | 字段 | 验证规则 |
|---|------|---------|
| E1 | operationItems | 长度 ≥ 3 |
| E2 | fixedItems | 长度 ≥ 4 |
| E3 | coachItems | 长度 ≥ 4 |
| E4 | platformItems | 长度 ≥ 3 |
| E5 | total | = SUM(所有 items) |
### 3.6 助教分析coachAnalysis— 仅 area=all 时显示
| # | 字段 | 验证规则 |
|---|------|---------|
| A1 | basic.rows | 长度 1-4初/中/高/星) |
| A2 | basic.totalPay | = SUM(rows.pay) |
| A3 | basic.totalShare | = SUM(rows.share) |
| A4 | basic.avgHourly | 13~28 范围(基础课) |
| A5 | incentive.rows | 长度 0-4 |
| A6 | compare=1 时 | basic/incentive 各行 payCompare/shareCompare 非空 |
## 四、执行方案
### 4.1 后端 API 验证Python 脚本)
`scripts/ops/validate_board_finance.py`,遍历 144 种组合:
1. 登录获取 tokendev-login
2. 循环调用 `GET /api/xcx/board/finance?time=X&area=Y&compare=Z`
3. 对每个响应按验证项清单检查
4. 输出问题清单到 `export/board-finance-validation.md`
### 4.2 前端 page.data 验证(微信开发者工具)
对后端验证发现问题的组合,用 `evaluate_script` 验证:
1. 连接开发者工具ws://127.0.0.1:9420
2. `page.setData({ selectedTime, selectedArea, compareEnabled })` + `page._loadData()`
3. 等待 5 秒后读取 `page.data`
4. 对比后端 API 返回值与 `page.data` 是否一致
### 4.3 优化策略
144 种组合中,很多是等价的:
- area=all 时 6 板块全显示area≠all 时只有 3 板块overview/revenue/cashflow
- compare=0 时不需要验证环比字段
- 不同区域的验证逻辑相同,只是数据不同
可以分层验证:
1. 第一层8 时间 × 2 环比 × area=all = 16 种(全板块)
2. 第二层8 时间 × 1 区域hallA 代表)× 2 环比 = 16 种(验证区域过滤)
3. 第三层:对剩余 7 个区域,只验证 month × compare=0 = 7 种(验证区域数据差异)
4. 总计16 + 16 + 7 = 39 种(覆盖所有逻辑分支)
## 五、执行环境
- 后端:`cd apps/backend && uvicorn app.main:app --reload`
- 微信开发者工具:端口 9420
- 数据库pg-etl-testtest_etl_feiqiu
- 脚本 cwd项目根目录
## 六、产出物
- `scripts/ops/validate_board_finance.py` — 验证脚本
- `export/board-finance-validation.md` — 验证报告(问题清单)
- 如有问题,修复后重跑验证直到全部通过

View File

@@ -0,0 +1,384 @@
# SPEC: 财务看板第二阶段 — Demo 对齐与数据修正board-finance-phase2
> 创建日期2026-03-27
> 前置 SPEC`board-finance-integration`(已完成,实现了基础数据绑定和筛选联动)
> 状态:执行中
> 优先级P0
---
## 一、背景与前提
### 1.1 已完成的工作Phase 1
Phase 1`board-finance-integration` SPEC已完成
- 前端 `_loadData()` 绑定全部 6 个板块数据
- 筛选联动(时间/区域/环比切换触发重新加载)
- 区域筛选枚举重建9 项)
- DWS 新增 `cash_paper_amount`/`scan_pay_amount` 字段(支付方式拆分)
- 预收资产卡余额从 SUM 改为取最后一天快照
- consumed 字段从硬编码 0 改为从 `card_consume_total` 取值
- 助教排序改为初→中→高→星
- 百分比字段 ×100 修复
### 1.2 当前状态
- 后端 API 200 正常返回6 个板块有数据
- DWS 已重跑(含 `DWS_SALARY_ALLOW_OUT_OF_CYCLE=true`),激励课有数据
- 前端数据绑定正常WXS 格式化正常
- 微信开发者工具 Power 已连接(`ws://127.0.0.1:9420`
### 1.3 遗留问题清单(本 SPEC 要解决的)
| # | 问题 | 板块 | 严重程度 |
|---|------|------|---------|
| P1 | 收入结构分类不对,需按区域筛选体系分类 | 应计收入确认 | 高 |
| P2 | 优惠减扣分项名称/结构与 Demo 不一致 | 应计收入确认 | 高 |
| P3 | 优惠减扣总计未在发生额右侧齐平展示 | 应计收入确认 | 中 |
| P4 | 现金流入各项名称/描述与 Demo 不一致 | 现金流入 | 中 |
| P5 | 现金流出各项名称为空(需固定项名对齐 Demo | 现金流出 | 中 |
| P6 | 助教分析数据不准,需从订单级绩效方案重算 | 助教分析 | 高 |
---
## 二、开发环境
### 2.1 技术栈
- 后端Python 3.10+ / FastAPI / psycopg2原生 SQL
- 前端:微信小程序 TypeScript / WXML / WXSS / WXS
- 数据库PostgreSQLETL 测试库 `test_etl_feiqiu`、业务测试库 `test_zqyy_app`
- ETLuv workspace`apps/etl/connectors/feiqiu/`
### 2.2 可用工具
- **微信开发者工具 Power**`ws://127.0.0.1:9420`支持页面导航、截图、JS 执行、元素快照、网络监控
- **PostgreSQL Power**`pg-etl-test`ETL 库)和 `pg-app-test`(业务库),支持 SQL 执行、表结构查看
- **OpenAPI Power**:后端 API 测试
### 2.3 关键文件路径
| 文件 | 说明 |
|------|------|
| `apps/backend/app/services/fdw_queries.py` | FDW 查询层6 个财务看板查询函数) |
| `apps/backend/app/services/board_service.py` | Service 编排层6 个 _build_* 函数 + 环比计算) |
| `apps/backend/app/schemas/xcx_board.py` | Pydantic SchemaFinanceBoardResponse |
| `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts` | 前端页面逻辑 |
| `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml` | 前端模板 |
| `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxss` | 前端样式 |
| `apps/miniprogram/miniprogram/utils/format.wxs` | WXS 格式化函数 |
| `apps/demo-miniprogram/miniprogram/pages/board-finance/` | Demo 原型(对齐目标) |
### 2.4 数据库关键表/视图
| 表/视图 | 说明 |
|--------|------|
| `app.v_dws_finance_daily_summary` | 财务日报RLS 视图) |
| `app.v_dws_finance_income_structure` | 收入结构RLS 视图) |
| `app.v_dws_finance_discount_detail` | 优惠明细RLS 视图) |
| `app.v_dws_finance_expense_summary` | 支出汇总RLS 视图Excel 导入) |
| `app.v_dws_platform_settlement` | 平台结算RLS 视图Excel 导入) |
| `app.v_dws_assistant_salary_calc` | 助教工资计算RLS 视图) |
| `dwd.dwd_settlement_head` / `_ex` | 结算单头表/扩展表 |
| `dwd.dwd_assistant_service_log` / `_ex` | 助教服务流水 |
| `dwd.dwd_payment` | 支付流水payment_method: 2=现金, 4=扫码) |
| `dwd.dwd_groupbuy_redemption` | 团购核销 |
| `ods.site_tables_master` | 桌台主数据areaname 字段) |
---
## 三、Demo 原型数据结构(对齐目标)
### 3.1 应计收入确认
**收入结构表structureRows**
```
开台与包厢 ¥358,600 -¥45,200 ¥313,400
A区 ¥118,200 -¥11,600 ¥106,600 (isSub)
B区 ¥95,800 -¥11,200 ¥84,600 (isSub)
C区 ¥72,600 -¥11,100 ¥61,500 (isSub)
团建区 ¥48,200 -¥6,800 ¥41,400 (isSub)
麻将区 ¥23,800 -¥4,500 ¥19,300 (isSub)
助教 基础课 ¥232,500 - ¥232,500
助教 激励课 ¥112,800 - ¥112,800
食品酒水 ¥119,556 -¥68,136 ¥51,420
```
**发生额构成priceItems**
```
开台消费 ¥358,600
酒水商品 ¥186,420
包厢费用 ¥165,636
助教服务 ¥112,800
```
**优惠减扣discountItems**
```
团购优惠 -¥56,200
手动调整 + 大客户优惠 -¥34,800
赠送卡抵扣 (台桌卡+酒水卡+抵用券) -¥22,336
其他优惠 (免单+抹零) -¥0
```
**收款渠道channelItems**
```
储值卡结算冲销 ¥238,200
现金/线上支付 ¥345,800
团购核销确认收入 (团购成交价) ¥126,120
```
### 3.2 现金流入
```
纸币现金 (柜台现金收款) ¥85,600
线上收款 (微信/支付宝/刷卡 已扣除平台服务费) ¥260,200
团购平台 (美团/抖音回款 已扣除平台服务费) ¥126,120
会员充值到账 (首充/续费实收) ¥352,800
合计 ¥824,720
```
### 3.3 现金流出
```
进货与运营:食品饮料 ¥108,200 / 耗材 ¥21,850 / 报销 ¥10,920
固定支出:房租 ¥125,000 / 水电 ¥24,200 / 物业 ¥11,500 / 人员工资 ¥112,000
助教薪资:基础课分成 ¥116,250 / 激励课分成 ¥23,840 / 充值提成 ¥12,640 / 额外奖金 ¥11,500
平台服务费:汇来米 ¥10,680 / 美团 ¥11,240 / 抖音 ¥10,580
合计 ¥600,400
```
### 3.4 助教分析
```
基础课:
totalPay(客户支付) / totalShare(球房抽成) / avgHourly(小时平均)
初级: pay ¥68,600 / share ¥34,300 / hourly ¥20/h
中级: pay ¥82,400 / share ¥41,200 / hourly ¥25/h
高级: pay ¥57,800 / share ¥28,900 / hourly ¥30/h
星级: pay ¥23,700 / share ¥11,850 / hourly ¥35/h
激励课:同结构
```
---
## 四、任务清单
### T1应计收入确认 — 收入结构按区域分类
**问题**:当前 `get_finance_revenue()``v_dws_finance_income_structure` 读取,按 INCOME_TYPE/AREA 分类。需要改为按物理区域分类。
**方案**:不依赖 `dws_finance_income_structure`,直接从 `dwd_settlement_head` + 桌台维度表聚合。
**数据来源**
- `dwd_settlement_head.table_id``ods.site_tables_master.areaname` 获取物理区域
- 按区域聚合 `table_charge_money`(台费)为"开台与包厢"主行
- 按区域拆分子行A区/B区/C区/团建区/麻将区等)
- 助教行:`assistant_pd_money`(基础课)+ `assistant_cx_money`(激励课)
- 食品酒水行:`goods_money`
**区域映射**(与财务看板区域筛选对照表一致):
```
A区 → A区
B区 → B区
C区 + TV台 + 美洲豹赛台 → C区
VIP包厢 → 台球包厢
斯诺克区 → 斯诺克
麻将房 + M7 + M8 + 666 + 发财 → 麻将区
K包 + k包活动区 + 幸会158 → 团建区
虚拟台 + 补时长 → 排除
```
**涉及文件**
- `apps/backend/app/services/fdw_queries.py``get_finance_revenue()` 重写
- 可能需要在 ETL 库中创建区域映射视图或在后端 SQL 中内联 CASE WHEN
**注意**
- 营业日转换:使用 `biz_date_sql_expr` 或后端等效逻辑
- `settle_type IN (1, 3)` 过滤
- 优惠按区域拆分可能不可行(优惠是订单级的,不是区域级的),需确认
### T2应计收入确认 — 优惠减扣对齐 Demo
**问题**:当前 discountItems 从 `v_dws_finance_discount_detail` 读取,分项名称与 Demo 不一致。
**Demo 目标 4 项**
1. 团购优惠 → `discount_groupbuy`
2. 手动调整 + 大客户优惠 → `discount_manual + discount_other`adjust_amount 的两个子集)
3. 赠送卡抵扣(台桌卡+酒水卡+抵用券)→ `discount_gift_card`
4. 其他优惠(免单+抹零)→ `discount_rounding`
**方案**:不从 `v_dws_finance_discount_detail` 读取,直接从 `v_dws_finance_daily_summary` 的 6 个 discount_* 字段聚合,按 Demo 的 4 项重新组合。
**涉及文件**
- `apps/backend/app/services/fdw_queries.py``get_finance_revenue()` 中 discount 部分重写
### T3应计收入确认 — 优惠总计展示位置
**问题**:优惠减扣总计需在发生额右侧齐平展示。
**方案**WXML 布局调整,在 `totalOccurrence` 行下方添加优惠总计行,右侧对齐。
**涉及文件**
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml`
- 可能需要 WXSS 调整
### T4现金流入对齐 Demo
**问题**:当前项名和描述与 Demo 不一致。
**Demo 目标**
1. 纸币现金desc: 柜台现金收款)→ `cash_paper_amount`
2. 线上收款desc: 微信/支付宝/刷卡 已扣除平台服务费)→ `scan_pay_amount`
3. 团购平台desc: 美团/抖音回款 已扣除平台服务费)→ `groupbuy_pay_amount`
4. 会员充值到账desc: 首充/续费实收)→ `recharge_cash_inflow`
**方案**:修改 `get_finance_cashflow()` 返回的 label 和 desc 字段。
**注意**
- "线上收款"的 desc 说"已扣除平台服务费",但当前 `scan_pay_amount` 是扫码支付原始金额,未扣除平台服务费。需确认是否需要扣除 `platform_fee_amount`
- "团购平台"同理,`groupbuy_pay_amount` 是团购实付金额,是否已扣除平台服务费取决于 `platform_settlement_amount` 是否有值
**涉及文件**
- `apps/backend/app/services/fdw_queries.py``get_finance_cashflow()`
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts``_loadData()` 中 cashflow 映射
### T5现金流出对齐 Demo
**问题**:当前支出数据来自 Excel 导入(`dws_finance_expense_summary` + `dws_platform_settlement`),数据全为 0。但项名和分组结构需要与 Demo 对齐。
**Demo 目标 4 组**
1. 进货与运营:食品饮料 / 耗材 / 报销
2. 固定支出:房租 / 水电 / 物业 / 人员工资
3. 助教薪资:基础课分成 / 激励课分成 / 充值提成 / 额外奖金
4. 平台服务费:按平台名分项(汇来米 / 美团 / 抖音)
**方案**
- 进货运营 + 固定支出:来自 `dws_finance_expense_summary`Excel 导入),按 `expense_category` 分组
- 助教薪资:可从 `dws_assistant_salary_calc` 聚合(基础课分成 = SUM(base_income 对应的球房抽成),激励课分成 = SUM(bonus 对应的球房抽成),充值提成 = SUM(recharge_commission),额外奖金 = SUM(sprint_bonus + top_rank_bonus + other_bonus)
- 平台服务费:来自 `dws_platform_settlement`Excel 导入),按 `platform_name` 分组
**涉及文件**
- `apps/backend/app/services/fdw_queries.py``get_finance_expense()` 重写
- 前端 `_loadData()` 中 expense 映射
### T6助教分析 — 从订单级绩效方案重算
**问题**:当前从 `dws_assistant_salary_calc` 聚合,该表是月度粒度的工资计算结果,使用的是配置表中的标准费率。用户要求从每笔订单的实际绩效方案计算。
**用户要求**
- 客户支付:每笔订单中客户实际支付的助教费用
- 球房抽成:每笔订单中球房实际抽取的金额
- 助教到手 = 客户支付 - 球房抽成
- 小时平均 = 球房抽成 / 总小时数
**数据来源**
- `dwd_assistant_service_log`每笔助教服务记录assistant_id, skill_id, 时长)
- `dwd_settlement_head`每笔结算单assistant_pd_money=基础课客户支付, assistant_cx_money=激励课客户支付)
- 球房抽成需要从配置表(`cfg_assistant_level_price` + `cfg_performance_tier`)按每笔订单的助教等级和档位计算
**方案选择**
- 方案 A直接从 `dwd_assistant_service_log` + `dwd_settlement_head` 关联计算(绕过 DWS
- 方案 B继续用 `dws_assistant_salary_calc`,但验证其数据与订单级计算一致
**建议**:先用方案 B 验证数据一致性。如果一致,保持现有逻辑;如果不一致,再改为方案 A。
**验证方法**
-`dwd_settlement_head` 聚合 `SUM(assistant_pd_money)``SUM(assistant_cx_money)`
-`dws_assistant_salary_calc``SUM(base_hours * base_course_price)``SUM(bonus_hours * bonus_course_price)` 对比
- 如果一致,说明 DWS 数据可靠
**涉及文件**
- `apps/backend/app/services/fdw_queries.py``get_finance_coach_analysis()`
- 可能需要新增 FDW 查询函数
**参考文件**
- `tmp/助理教练流水_朗朗桌球_20260301_20260327.xlsx`(飞球系统导出的助教流水,用于交叉验证)
---
## 五、依赖关系
```
T1收入结构按区域→ 独立,可先做
T2优惠减扣对齐→ 独立,可先做
T3优惠总计展示→ 依赖 T2 完成
T4现金流入对齐→ 独立,可先做
T5现金流出对齐→ 独立,可先做
T6助教分析重算→ 独立,可先做
```
建议执行顺序T6 → T1 → T2 → T3 → T4 → T5先解决数据准确性再对齐展示
---
## 六、权威文档引用
| 文档 | 路径 | 用途 |
|------|------|------|
| DWD-DOC 权威规则 | `.kiro/steering/dwd-doc-authority.md` | consume_money 禁用、支付恒等式、会员字段断档等 |
| DWS 权威规范 | `.kiro/steering/dws-doc-authority.md` | 幂等策略、课程定价、绩效档位等 |
| 前后端联调规范 | `docs/guides/FRONTEND-BACKEND-INTEGRATION.md` | 数据契约、WXS 格式化、快照值 vs 流量值等 |
| 财务看板 Phase 1 SPEC | `docs/prd/specs/board-finance-integration.md` | 已完成的工作和区域对照表 |
| Demo 原型 | `apps/demo-miniprogram/miniprogram/pages/board-finance/` | 对齐目标 |
| 支付方式 BD 手册 | `apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_dwd_payment.md` | payment_method 枚举 |
---
## 七、DWD-DOC 强制规则速查
1. `consume_money` 禁止直接用于计算 → 用 `items_sum` 拆分字段
2. 助教费用必须拆分:`assistant_pd_money`(陪打/基础课)+ `assistant_cx_money`(超休/激励课)
3. 支付恒等式:`balance_amount = recharge_card_amount + gift_card_amount`
4. `settle_type IN (1, 3)` 过滤正向交易
5. 支付方式拆分从 `dwd_payment`payment_method: 2=现金, 4=扫码),不从 `settlement_head_ex`
6. 快照值(卡余额)禁止 SUM取最后一天
7. TS 层传原始数字WXS 负责格式化(禁止双重格式化)
---
## 八、区域筛选对照表
| code | 前端显示 | 包含的物理区域areaname |
|------|---------|--------------------------|
| all | 全部区域 | 所有 |
| hall | 大厅 | A区+B区+C区+TV台+美洲豹赛台 |
| hallA | A区 | A区 |
| hallB | B区 | B区 |
| hallC | C区 | C区+TV台+美洲豹赛台 |
| vip | 台球包厢 | VIP包厢 |
| snooker | 斯诺克 | 斯诺克区 |
| mahjong | 麻将房 | 麻将房+M7+M8+666+发财 |
| ktv | 团建房 | K包+k包活动区+幸会158 |
排除:虚拟台、补时长
---
## 九、区域筛选影响板块
| 板块 | 非"全部"时 |
|------|-----------|
| 经营一览 | 按区域过滤 |
| 预收资产 | 隐藏 |
| 应计收入确认 | 按区域过滤 |
| 现金流入 | 按区域过滤 |
| 现金流出 | 隐藏 |
| 助教分析 | 隐藏 |
---
## 十、验证方法
每个任务完成后:
1. 用 PostgreSQL Power 直接查询 DWS/DWD 数据验证计算正确性
2. 用微信开发者工具 Power 的 `evaluate_script` 检查前端 `setData` 后的值
3.`get_page_snapshot` 检查 DOM 渲染结果
4.`screenshot` 截图对比 Demo 原型
---
## 十一、收尾
- 所有改动添加 CHANGE 注释(日期 + Prompt + 直接原因)
- 更新 `docs/prd/specs/board-finance-integration.md` 的状态
- 如涉及 DDL 变更,同步更新 `docs/database/ddl/` 基线和 BD 手册
- 如涉及新的踩坑模式,沉淀到 `.kiro/steering/``docs/guides/FRONTEND-BACKEND-INTEGRATION.md`

View File

@@ -0,0 +1,238 @@
# 小程序多页面联调改造任务书
> 创建日期2026-03-27
> 来源:本轮对话排查 customer-service-records 页面加载问题后,用户提出的 4 页面改造需求
> 状态:第 1 步后端改造已完成,第 2-4 步待执行
---
## 一、背景与前提
### 1.1 已完成的工作(本轮对话)
1. **权限守卫修复**`auth-guard.ts``PAGE_ROLES``customer-detail``customer-service-records``coach-detail` 三个页面加入了 `coach` 角色,解决了 coach 进入后被 `reLaunch` 踢回首页的问题。
2. **ETL 连接复用**`fdw_queries.py``_fdw_context` 新增 `etl_conn` 可选参数,`customer_service.py``get_customer_records` 中只创建一个 ETL 连接传给所有子查询,连接开销从 ~10s 降到 ~2.6s。
3. **后端 assistant_id 过滤**
- `customer_service.py` 新增 `_get_assistant_id()``auth.user_assistant_binding` 获取 assistant_id
- `get_customer_records` 签名新增 `user_id` 参数,内部获取 assistant_id 后传给所有 fdw_queries 调用
- `fdw_queries.get_customer_service_records``get_total_service_count` 加入可选 `assistant_id` 过滤(`site_assistant_id = %s`
- `_get_month_aggregation` 同步加入 `assistant_id` 过滤
- 路由层 `xcx_customers.py` 传入 `user.user_id`
4. **后端到手收入计算**
- `get_customer_records` 中查询 `v_dws_assistant_salary_calc` 获取当月费率
- 基础课到手 = hours × (base_course_price - base_deduction)
- 激励课到手 = hours × bonus_course_price × (1 - bonus_deduction_ratio)
- 充值提成到手 = ledger_amount直接取
- Schema 新增 `month_income: float` 字段
5. **BACKLOG 更新**`docs/roadmap/BACKLOG.md` 新增 AI BudgetTracker 和 admin_db_health UnicodeDecodeError 两条待办。
6. **联调规范更新**`docs/guides/FRONTEND-BACKEND-INTEGRATION.md``.kiro/steering/frontend-backend-integration.md` 新增 ETL 连接复用规则。
### 1.2 已识别但未修复的数据问题
通过开发者工具读取页面 data 发现以下问题:
| # | 问题 | 原因 | 影响 |
|---|------|------|------|
| 1 | `customerName` 为空 | `loadCustomerInfo` 调的 `get_customer_detail` 还没做 ETL 连接复用优化,可能超时 | 顶部不显示客户名 |
| 2 | `totalServiceCount: 0` | 后端 `get_total_service_count` 返回 0需排查 assistant_id 是否正确传入 | 顶部"服务X次"显示 0 |
| 3 | `monthHours: "0.0h"` | 后端返回 `duration` 是小时数(如 1.1),前端 `loadMonthRecords``rawRecords.reduce` 累加的是 `r.duration`,但后端字段名是 `duration`CamelModel 输出 camelCase需确认字段映射 | 月度时长显示 0 |
| 4 | `income: 0` | 后端到手收入计算中 `salary_calc` 可能没查到数据assistant_id 为 None 或费率为 0 | 列表收入全部显示 0 |
| 5 | `displayDate` 格式错误 | 前端 `generateTimeRange``r.duration`(小时数)当分钟数处理,导致 `19:1.116...` | 时间显示乱码 |
| 6 | `table` 显示 ID | 后端返回 `table_id`(数字 ID前端直接显示应该显示台桌名称 | 台桌号显示为长数字 |
| 7 | `durationHours: 0` | 前端 `(r.duration \|\| 0) / 60` 把小时数再除以 60 变成了接近 0 的值 | 列表时长显示 0 |
### 1.3 开发环境
- Windows + PowerShell
- 后端:`start-admin.bat` 启动uvicorn --reload 端口 8000
- 前端:微信开发者工具(自动化端口 9420
- 数据库ETL 库PG_DSN+ 业务库APP_DB_DSNETL 连接耗时 ~2.6s
- 可用调试工具weixin-devtools MCP power连接 `ws://127.0.0.1:9420`
### 1.4 关键文件位置
| 文件 | 说明 |
|------|------|
| `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` | 前端页面逻辑 |
| `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.wxml` | 前端页面模板 |
| `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.wxss` | 前端页面样式 |
| `apps/miniprogram/miniprogram/services/api.ts` | 前端 API 调用层 |
| `apps/miniprogram/miniprogram/utils/request.ts` | 前端请求封装(含 ResponseWrapper 解包) |
| `apps/miniprogram/miniprogram/utils/auth-guard.ts` | 权限守卫 |
| `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts` | 任务详情页参考60天服务记录列表格式 |
| `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml` | 任务详情页模板参考service-record-card |
| `apps/miniprogram/miniprogram/pages/performance/performance.ts` | 绩效页 |
| `apps/miniprogram/miniprogram/pages/performance/performance.wxml` | 绩效页模板 |
| `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` | 任务列表页 |
| `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml` | 任务列表页模板 |
| `apps/backend/app/services/customer_service.py` | 后端客户服务 |
| `apps/backend/app/services/fdw_queries.py` | 后端 FDW 查询 |
| `apps/backend/app/routers/xcx_customers.py` | 后端客户路由 |
| `apps/backend/app/schemas/xcx_customers.py` | 后端响应 Schema |
| `apps/backend/app/routers/xcx_auth.py` | 后端认证路由(/api/xcx/me |
| `apps/backend/app/auth/dependencies.py` | JWT 解析CurrentUser |
### 1.5 关键数据口径
- **到手收入**:基础课 = hours × (base_course_price - base_deduction);激励课 = hours × bonus_course_price × (1 - bonus_deduction_ratio);充值提成 = ledger_amount
- **费率来源**`v_dws_assistant_salary_calc`,按 `assistant_id + salary_month` 查询
- **DWD 层助教字段**`site_assistant_id`(与 `auth.user_assistant_binding.assistant_id` 相同)
- **废单排除**`is_delete = 0`
- **会员信息**:通过 `member_id` JOIN `v_dim_member (scd2_is_current=1)`
- **CamelModel**:后端 Pydantic schema 自动将 snake_case 转 camelCase 输出
- **ResponseWrapper**:后端中间件将 2xx JSON 包装为 `{ code: 0, data: <原始body> }`,前端 `request.ts` 自动解包
---
## 二、待执行任务
### 任务 Acustomer-service-records 前端改造(优先级最高)
#### A1. 修复数据转换逻辑
前端 `loadMonthRecords` 中的字段映射需要对齐后端 CamelModel 输出:
```
后端返回camelCase 前端当前读取 问题
─────────────────────────────────────────────────
duration (float, 小时) r.duration / 60 多除了一次 60
durationRaw (float, 小时) 未使用 应直接用
income (float, 到手元) r.amount 字段名不匹配
timeRange (string|null) 未使用 后端已返回,前端用 generateTimeRange 自己算
courseType (string) r.courseType ✓ 正确
table (string|null) r.table 后端返回 table_id 数字,需要改为台桌名
recordType (string) 前端自己判断 后端已返回,应直接用
isEstimate (bool) r.isEstimate ✓ 正确
drinks (string|null) r.drinks 后端当前返回 null后续需补齐
```
修改要点:
- `durationHours` 直接用 `r.duration`(已是小时数),不再 `/60`
- `durationRaw` 直接用 `r.durationRaw`
- `income``r.income`(后端已计算到手)
- `displayDate` 优先用后端返回的 `r.timeRange`,无值时才用 `generateTimeRange`
- `generateTimeRange` 的参数是小时数不是分钟数,需修正或移除
- `table` 字段:后端当前返回 `table_id`(数字),需要后端改为返回台桌名称,或前端做映射
#### A2. 修复月度统计
- `monthHours` 累加逻辑:`rawRecords.reduce((sum, r) => sum + (r.duration || 0), 0)` — 后端返回的 `duration` 已是小时数,不需要再 `/60`
- 新增 `monthIncome`:从后端返回的 `monthIncome` 字段读取并展示
#### A3. 修复顶部信息
- `customerName``loadCustomerInfo` 调的 `fetchCustomerDetail` 超时问题 — 方案 1`get_customer_detail` 也做 ETL 连接复用;方案 2`get_customer_records` 的返回值中直接取 `customerName`(后端已返回)
- `totalServiceCount`:从 `get_customer_records` 返回值中取(后端已返回 `totalServiceCount`
- 手机号查看/复制交互:对齐 task-detail 页面的实现
- 首字母头像:使用通用颜色规则(`nameToAvatarColor`
#### A4. 列表记录格式对齐 task-detail
- 对齐 task-detail 的"60天内服务记录"列表的 `service-record-card` 组件格式
- 确认 `service-record-card` 组件是否已存在,还是内联在 task-detail 的 WXML 中
- 到手收入显示:`¥XXX`
- 课程类型标签:基础课/激励课/充值
- 折前/折后时长:`durationRaw !== durationHours` 时才显示折前
#### A5. 加速加载
- `loadCustomerInfo` 可以去掉(改为从 `loadMonthRecords` 的返回值中取 `customerName``customerPhone`
- 减少一个 API 请求 = 减少一次 ETL 连接开销
---
### 任务 Btask-detail 折前时长条件显示
文件:`apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- 找到服务记录列表中显示"折前X.Xh"的位置
- 加条件判断:`wx:if="{{item.durationRaw && item.durationRaw !== item.durationHours}}"`
- 折前和折后相等时隐藏折前标签
---
### 任务 Cperformance 页面改造
文件:`apps/miniprogram/miniprogram/pages/performance/performance.ts` + `.wxml`
#### C1. 未知客户点击拦截
- 找到客户卡片点击跳转逻辑
- 判断 `memberId <= 0`(散客/未知客户)时,`wx.showToast({ title: '未知客户不提供查看客户任务详情', icon: 'none' })` 并 return
- 非散客正常跳转
#### C2. 角色标签对齐 task-list
- 查看 task-list 页面的角色标签展示方式WXML + WXSS
- 对齐 performance 页面顶部的角色标签样式
---
### 任务 D头像修复performance + task-list
文件:`performance.ts``task-list.ts`、对应 `.wxml`
- 后端 `/api/xcx/me` 已返回 `avatarUrl` 字段apply 时上传的头像)
- 前端 `fetchMe()` 返回的 `data.avatarUrl` 需要绑定到页面 data
- WXML 中头像 `<image>``src` 应优先用 `avatarUrl`,无值时 fallback 到默认占位图或首字母头像
- 确认 task-list 和 performance 两个页面的 banner 区域头像都使用此逻辑
---
### 任务 E后端补充优化可选
#### E1. get_customer_detail ETL 连接复用
-`get_customer_records` 相同的模式:创建一个 ETL 连接传给所有 fdw_queries 调用
- 涉及的子查询:`get_member_info``get_member_balance``get_consumption_60d``get_last_visit_days``_build_consumption_records``_build_coach_tasks``_build_favorite_coaches`
#### E2. 台桌名称返回
- 当前 `get_customer_service_records` 返回 `site_table_id`(数字 ID
- 需要 JOIN `v_dim_table`(或类似维度表)获取台桌名称
- 或者前端做映射(不推荐,增加前端复杂度)
#### E3. 到手收入排查
- 确认 `_get_assistant_id` 返回值是否正确(可能 `user_assistant_binding` 中没有对应记录)
- 确认 `get_salary_calc` 是否能查到当月数据(可能该月还没有 salary_calc 记录)
- 费率为 0 时的兜底处理
---
## 三、执行顺序建议
1. **E3**(排查到手收入为 0 的原因)→ 确认后端数据正确性
2. **A1 + A2 + A3**(前端数据转换修复)→ 页面能正确展示数据
3. **A4**(列表格式对齐 task-detail→ UI 一致性
4. **A5**(加速加载)→ 去掉多余 API 请求
5. **B**task-detail 折前时长)→ 小改动
6. **C**performance 页面)→ 独立改动
7. **D**(头像修复)→ 独立改动
8. **E1 + E2**(后端补充优化)→ 可选
---
## 四、验证方式
每步完成后:
1. 微信开发者工具编译Ctrl+B
2. 操作路径:任务列表 → 轩哥 → 任务详情 → 查看全部服务记录
3. 用 weixin-devtools MCP power 的 `evaluate_script` 读取页面 data 验证字段值
4.`list_console_messages` 检查是否有错误
5. 后端改动通过 uvicorn `--reload` 自动加载,看后端窗口有无报错
---
## 五、参考文档
- `docs/guides/FRONTEND-BACKEND-INTEGRATION.md` — 联调规范字段命名、到手收入口径、ETL 连接复用等)
- `.kiro/steering/frontend-backend-integration.md` — 联调规范摘要
- `.kiro/steering/feiqiu-data-rules.md` — 飞球数据规范索引
- `apps/backend/app/schemas/xcx_customers.py` — 后端响应 SchemaCamelModel 自动转 camelCase
- `apps/miniprogram/miniprogram/utils/request.ts` — 前端请求封装ResponseWrapper 解包逻辑)