fix: F1-5b MP-3 + MP-5 沙箱业务日小程序适配 (W1)

MP-3 customer-detail coachTasks.lastService 业务日上界裁剪:
- apps/backend/app/services/customer_service.py
  - import as_runtime_today_param 从 late import 提至模块顶部
  - _build_coach_tasks 开头取 ref_date,供两段 SQL 共用
  - 第一条直查 biz.coach_tasks 加 `AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz`
  - 删除原方法内重复 ref_date 调用
- 业务影响:sandbox=2026-04-20 时,customer-detail 的"上次服务"
  时间不再展示 sandbox 业务日之后的助教任务更新(沙箱不读未来)
- 测试:apps/backend/tests/test_customer_detail_mp3_lastservice.py
  本地通过,因 .gitignore:71 不入仓(同 T1 / af02446 处理方式)

MP-5 coach-service-records 接入 getBusinessClock:
- apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts
  - import getBusinessClock + data 加 clockYear/clockMonth/clockDay 字段
  - onLoad 改 async,await getBusinessClock() 取 business_year/month/date
  - loadData / switchMonth 4 处 new Date() → clockYear/Month/Day
- 业务影响:sandbox=2026-04-20 时,coach-service-records 默认显示
  "2026 年 4 月"业绩(而非 today 月),canGoNext=false 阻止翻到 5 月,
  "前 5 日预估金额"规则按 sandbox business_date 判断

双口径验证(weixin-devtools-mcp + DB 直查):
- MP-3 4a live: lastService 最大 04-19(无未来时间)
- MP-3 4b sandbox=4-20: 5-01 任务 task_id=8348/8347 完全消失
- MP-5 4a live: clockYear/Month/Day=2026/5/5,monthLabel="2026年5月"
- MP-5 4b sandbox=4-20: monthLabel="2026年4月" + 35 笔/¥4,657
   first group=2026-04-20(后端 SQL 上界裁剪生效)

审计:
- docs/audit/changes/2026-05-05__wave1_f1_5b_mp3_lastservice_upper_bound.md
- docs/audit/changes/2026-05-05__wave1_f1_5b_mp5_coach_service_records_clock.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-05 18:43:08 +08:00
parent 3c8d72edd4
commit 96dae0c778
4 changed files with 225 additions and 19 deletions

View File

@@ -24,6 +24,7 @@ from fastapi import HTTPException
from decimal import Decimal
from app.services import fdw_queries
from app.services.runtime_context import as_runtime_today_param
from app.services.task_generator import compute_heart_icon
from app.trace.decorators import trace_service
@@ -458,7 +459,12 @@ def _build_coach_tasks(
构建 coachTasks 模块。
CHANGE 2026-03-29 | 性能优化:所有助教信息改为批量查询,消除 N+1
CHANGE 2026-05-05 | F1-5b MP-3: lastService (updated_at) 加 business_date 上界,沙箱不读未来
"""
# 业务日sandbox 取 sandbox_business_datelive 取 CURRENT_DATE。
# 该值同时用于第一条 SQLcoach_tasks 上界)和后续 60 天统计ref_date
ref_date = as_runtime_today_param(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
@@ -466,9 +472,10 @@ def _build_coach_tasks(
FROM biz.coach_tasks
WHERE member_id = %s
AND status IN ('active', 'inactive')
AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz
ORDER BY created_at DESC
""",
(customer_id,),
(customer_id, ref_date),
)
rows = cur.fetchall()
@@ -502,8 +509,7 @@ def _build_coach_tasks(
# 批量查询 60 天统计(一次 FDW 查询)
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
# ref_date 已在方法开头取得,此处直接复用
stats_map: dict = {}
try:
with fdw_queries._fdw_context(conn, site_id) as cur:

View File

@@ -12,6 +12,8 @@
* - 后端 /api/xcx/performance/records?coach_id=xxx权限码 view_board_coach
*/
import { checkPageAccess } from '../../utils/auth-guard'
// CHANGE 2026-05-05 | F1-5b MP-5: 业务时钟接入,sandbox 模式按 sandbox_date 判断"当前月/预估"
import { getBusinessClock } from '../../utils/runtime-clock'
import { fetchPerformanceRecords, fetchCoachBanner } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
@@ -61,13 +63,18 @@ Page({
/** Banner 主标题:用助教名生成"<助教名>的业绩" */
pageTitle: '业绩明细',
/** 月份切换 */
/** 月份切换onLoad 内会用 getBusinessClock 覆盖) */
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthLabel: '',
canGoPrev: true,
canGoNext: false,
/** 业务时钟缓存onLoad 内由 getBusinessClock 写入,sandbox 模式取 sandbox_date */
clockYear: new Date().getFullYear(),
clockMonth: new Date().getMonth() + 1,
clockDay: new Date().getDate(),
/** 当月预估判断 */
isCurrentMonth: false,
@@ -86,7 +93,7 @@ Page({
hasMore: false,
},
onLoad(options: Record<string, string | undefined>) {
async onLoad(options: Record<string, string | undefined>) {
const coachIdNum = Number(options?.coachId)
const coachId = Number.isFinite(coachIdNum) && coachIdNum > 0 ? coachIdNum : 0
if (coachId === 0) {
@@ -95,12 +102,20 @@ Page({
setTimeout(() => wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/board-finance/board-finance' }) }), 1000)
return
}
const now = new Date()
// CHANGE 2026-05-05 | 业务时钟取代 new Date(),sandbox 模式下显示 sandbox_date 所在月
const clock = await getBusinessClock()
const clockYear = clock.business_year
const clockMonth = clock.business_month
// business_date 形如 "2026-04-20",取 day 用于"当月前 5 日预估"判断
const clockDay = Number(clock.business_date.slice(8, 10)) || new Date().getDate()
this.setData({
coachId,
currentYear: now.getFullYear(),
currentMonth: now.getMonth() + 1,
monthLabel: `${now.getFullYear()}${now.getMonth() + 1}`,
currentYear: clockYear,
currentMonth: clockMonth,
monthLabel: `${clockYear}${clockMonth}`,
clockYear,
clockMonth,
clockDay,
})
this.loadBanner()
this.loadData()
@@ -149,11 +164,11 @@ Page({
}
wx.showLoading({ title: '加载中...', mask: true })
const now = new Date()
const { currentYear, currentMonth } = this.data
const isCurrentMonth = currentYear === now.getFullYear()
&& currentMonth === now.getMonth() + 1
&& now.getDate() <= 5
// CHANGE 2026-05-05 | 业务时钟替代 new Date(),sandbox 模式下"当月预估"按 sandbox_date 判断
const { currentYear, currentMonth, clockYear, clockMonth, clockDay } = this.data
const isCurrentMonth = currentYear === clockYear
&& currentMonth === clockMonth
&& clockDay <= 5
try {
const res = await fetchPerformanceRecords({
@@ -253,11 +268,10 @@ Page({
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
}
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5
// CHANGE 2026-05-05 | 业务时钟替代 new Date(),sandbox 模式下"未来月"按 sandbox_date 判断
const { clockYear, clockMonth, clockDay } = this.data
const canGoNext = currentYear < clockYear || (currentYear === clockYear && currentMonth < clockMonth)
const isCurrentMonth = currentYear === clockYear && currentMonth === clockMonth && clockDay <= 5
this.setData({
currentYear,

View File

@@ -0,0 +1,87 @@
# 2026-05-05 · F1-5b MP-3 customer-detail lastService 业务日上界裁剪
> Wave 1 / F1-5b Wave A 第 4 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2)
>
> 工作量评估 M / 2h(实际 ~ 1h),按 §3 五步流程完成。
## 背景
- 路径:小程序 `pages/customer-detail/customer-detail` → 后端 `_build_coach_tasks``biz.coach_tasks`
- 问题:F1-5b 排查中发现该方法第一条 SQL(直查 `biz.coach_tasks`)无任何时间过滤,sandbox 业务日下会读到"未来"更新的助教任务,违反 P20 沙箱契约"沙箱不读未来"。
- 现象:sandbox=2026-04-20 时,coachTasks lastService 仍能展示 2026-05-01 的助教任务更新时间(实测有 2 条 task_id=8348/8347 落在 04-21 之后)。
## 改动清单
### Step 1 调研(Explore agent)
确认目标方法:`apps/backend/app/services/customer_service.py:454-599 _build_coach_tasks`,模块级函数,被 `get_customer_detail()` 调用。第二条 SQL(60 天统计)已正确使用 `as_runtime_today_param`,但第一条 SQL 完全无时间过滤。
### Step 2 TDD 红
新增 `test_build_coach_tasks_business_date_upper_bound`(模块顶部 import + mock + 断言 SQL + 断言 ref_date 出现在 params),首次运行 RED:`AttributeError: module 'app.services.customer_service' does not have the attribute 'as_runtime_today_param'`
### Step 3 实施
`apps/backend/app/services/customer_service.py`:
1. **import 提升**:`from app.services.runtime_context import as_runtime_today_param` 从方法内 late import 提至模块顶部。
2. **ref_date 提前**:`ref_date = as_runtime_today_param(site_id, conn=conn)` 移至方法第一行(供两段 SQL 共用)。
3. **第一条 SQL 加上界**:`AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz`,params 加 `ref_date`
4. 删除原 505-506 行的方法内 import 与重复 ref_date 调用。
### Step 4 双口径验证
**目标 member**: `2799207406946053`(15 条 active/inactive 任务,updated_at 跨 04-13 ~ 05-01)。
| 维度 | 4a live (today=05-05) | 4b sandbox=2026-04-20 |
|------|----------------------|----------------------|
| SQL probe 行数 | 15 | 13(裁剪 2 条) |
| SQL probe latest updated_at | 2026-05-01 01:12 | 2026-04-19 16:36 |
| UI coachTasks 数(RSI 过滤后) | 3 | 7 |
| UI lastService 最大值 | 04-19 16:36 | 04-19 16:36 |
| 是否出现 05-01 | 否(被 RSI 过滤) | 否(被 SQL 上界裁剪) |
**关键证据**:5-01 的两条 task(8348/8347)在 sandbox=4-20 模式下完全消失于结果集。验证脚本:
- `_DEL/walkthrough_f1_5b/step_mp3_4a_probe.py` — 数据探针
- `_DEL/walkthrough_f1_5b/step_mp3_4a_4b_sql_verify.py` — SQL 双口径
- `_DEL/walkthrough_f1_5b/step_mp3_4b_switch_sandbox.py` — sandbox 切换
- `_DEL/walkthrough_f1_5b/step_mp3_4c_restore_live.py` — 切回 live
### Step 5 审计
本文件。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 后端 `customer_service` | _build_coach_tasks 第一条 SQL 加 1 个 SQL 参数 | unit test + Playwright 走查 |
| 小程序 customer-detail | coachTasks 列表 / lastService 显示 | weixin-devtools-mcp 实地走查 PASS |
| FDW 链路 | 无变化(依然用既有 _fdw_context) | — |
| 性能 | ref_date 调用提前 1 次(get_runtime_context 无缓存,每请求都查 biz.site_runtime_context),影响可忽略 | — |
| admin-web | 无影响 | — |
## 测试
- 新增:`apps/backend/tests/tests/unit/test_customer_detail.py::test_build_coach_tasks_business_date_upper_bound` PASS
- 既有:`test_build_coach_tasks_metrics` PASS(回归)
- 全文件 9 passed,1 failed (`test_build_consumption_records_nesting` 预先存在的 coaches 嵌套 0 != 2,与 MP-3 无关,本次不修)
## 风险与未覆盖
- **预存 bug**:`test_build_consumption_records_nesting` 失败,登记到 Wave B 范围排查。
- **同类待排查**:`_build_favorite_coaches``total_service_count`(全历史累计)在 sandbox 下也未裁未来,Wave B 一并处理(不在本任务范围)。
- **RSI 视图**:`app.v_dws_member_assistant_relation_index` 在 sandbox 下取值会随 GUC `app.current_business_date` 变化,这正是 sandbox 任务数 7 ≠ live 任务数 3 的原因(由 `_fdw_context` 注入,非本次改动)。
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后第一条 SQL 恢复无时间过滤;不涉及 DB schema / 配置,无副作用。
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>

View File

@@ -0,0 +1,99 @@
# 2026-05-05 · F1-5b MP-5 coach-service-records 接入业务时钟
> Wave 1 / F1-5b Wave A 第 5 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2)
>
> 工作量评估 M / 2h(实际 ~ 1h),按 §3 五步流程完成。
## 背景
- 路径:小程序 `pages/coach-service-records/coach-service-records`(管理者视角的助教业绩明细)
- 问题:页面以 `new Date()` 取"当前年月",sandbox 模式下:
- 默认月份是真实 today 而不是 sandbox_date 所在月,展示"未来月"业绩(空)
- `canGoNext` 防翻规则按真实 today 计算,允许跳到 sandbox business_date 之后的月份(违反"沙箱不读未来")
- "当月预估金额规则"(每月前 5 日)按真实日期判断,sandbox=4-20 时仍按 5-05 触发,逻辑错乱
## 改动清单
### Step 1 调研
确认目标:`coach-service-records.ts` 共 4 处 `new Date()` 调用(L65/89/152/256),分别用于 data 默认值、onLoad 初始化、loadData "当月预估"判断、switchMonth 边界判断。
确认对照模板:`customer-service-records.ts` 已在 2026-05-02 接入 getBusinessClock,采用 `async onLoad` + `business_year/business_month` 字段模式。
### Step 2 TDD
小程序无单测框架,跳过 unit test,以 weixin-devtools-mcp 端到端走查作为验证主线。
### Step 3 实施
`apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts`:
1. **import getBusinessClock**:`from '../../utils/runtime-clock'`,标注 `CHANGE 2026-05-05 | F1-5b MP-5`
2. **data 新增 3 字段**:`clockYear / clockMonth / clockDay`,默认 fallback 为 `new Date()` 各部分。
3. **onLoad 改 async**:`await getBusinessClock()`,从 `business_year` / `business_month` / `business_date` 解析 day,写入 data。`clockDay``business_date.slice(8, 10)` 解析。
4. **loadData**:`isCurrentMonth` 改用 `clockYear/clockMonth/clockDay`,删除 `new Date()`
5. **switchMonth**:`canGoNext / isCurrentMonth` 同上改造。
6. 仅保留 5 处 `new Date()` 在 data 默认值与 onLoad fallback 路径,作为 getBusinessClock 失败时的兜底。
### Step 4 双口径验证
**目标 coach**:assistant_id=3148987180059141(喵喵,4 月 42 条记录 latest=04-23)
| 维度 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 |
|------|---------------------------|----------------------|
| clockYear/Month/Day | 2026 / 5 / 5 | 2026 / 4 / 20 |
| monthLabel | 2026年5月 | 2026年4月 |
| canGoPrev | true | true |
| canGoNext | false(已是当月) | **false(防翻 5 月,关键)** |
| isCurrentMonth | true(05-05 ≤ 5) | false(20 > 5) |
| pageState | empty(5 月无数据) | normal |
| 总记录数 | -- | 35 笔 / ¥4,657 |
| first_group date | — | 2026-04-20 |
**关键证据**:
- sandbox=4-20 时,first dateGroup 是 04-20,后续 04-18 / 04-17(逆序),无任何 04-21 之后的记录(后端 SQL 上界裁剪正确返回)
- `canGoNext=false` 阻止用户切到 5 月,防"读未来"
**走查脚本**:
- `_DEL/walkthrough_f1_5b/step_mp5_probe_coach.py` — 找有 4 月数据的 coach
- 复用 `step_mp3_4b_switch_sandbox.py` 切 sandbox
- 复用 `step_mp3_4c_restore_live.py` 切回 live
**注意事项**:`getBusinessClock` 有 60s 内存缓存,sandbox 切换后必须 relaunch 小程序才能让缓存失效,否则页面仍显示 live 时钟。这是 utils 层面的设计(由 `clearBusinessClockCache()` 提供主动清理),非本任务问题。
### Step 5 审计
本文件。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 小程序 coach-service-records | onLoad 改 async,4 处 new Date() 替换为 clockYear/Month/Day | weixin-devtools-mcp 双口径 PASS |
| 后端 | 无改动(后端已在 F1-5b T1/A1 完成上界裁剪) | — |
| getBusinessClock util | 无改动,沿用既有 60s 缓存 | — |
| admin-web | 无影响 | — |
| 其他小程序页面 | 无影响 | — |
## 测试
- 小程序无单测框架,以端到端走查作为验证主线
- 端到端 4a/4b 双口径全部 PASS
## 风险与未覆盖
- **缓存切换体验**:用户在小程序中切 sandbox 后,需在小程序内显式 relaunch 或等 60 秒缓存过期。Wave B UI-3(AIDashboard sandbox 提示)若引入"切 sandbox 即刷新所有页面"机制,可联动调用 `clearBusinessClockCache()` 改善体验,本任务暂不处理。
- **fallback 路径未实测**:getBusinessClock 失败时降级到 `new Date()`(localFallback),其行为继承既有 utils,本任务未单独验证。
- **同类页面**:`performance-records.ts`(任务 tab 下助教个人页)已在 2026-05-02 接入 getBusinessClock,本次 MP-5 不涉及。
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后 4 处 new Date() 恢复;不涉及后端 / DB / 配置,无副作用。
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>