Parcourir la source

fix(learning): retain taught students after their last scheduled lesson

彭峰 il y a 9 heures
Parent
commit
3726173cb4

+ 25 - 0
docs/qa/coach-student-history-fix-2026-11-02.md

@@ -0,0 +1,25 @@
+# 2026-11-02 老师学习记录学员列表漏显修复(未上线)
+
+## 问题及真实数据核查
+
+裘斯涵陪练(1714)在学习记录页面无法选择计然高中(1802)。学员账号正常,未停用、未删除。
+
+新系统已保存该学员在此老师名下的 29 条有效学习记录,最近一条为 2026-11-01,学习 48 个单词。新旧系统的最近排课日期均为 2026-11-01,没有 2026-11-02 及以后的排课。本次没有发现后续排课漏同步,也没有通过新增排课或修改课堂状态来恢复显示。
+
+根因是学习记录页面调用 `e_user_list` 的 `current-scheduled` 筛选:只接受今天或以后尚未结束的有效排课。最后一节课结束或跨日后,即使历史学习记录仍在,学员也会从选择列表消失。
+
+## 修改
+
+仅修改 `coachStudentPage`:保留正常学员账号、同帐套、有效师生排课关联等限制;满足当前排课条件,或拥有这位老师的有效学习记录,即可进入学习记录学员列表。既有客户端参数保持兼容。
+
+仍排除停用、删除、非学员账号,已取消或删除的学习记录、其他老师及其他帐套的记录不能作为历史授课依据。未请求此筛选的旧版“我的学员”行为保持不变。分页、去重和选中学员首屏恢复继续有效。
+
+## 验证及发布边界
+
+- 新回归用例先在旧逻辑上失败(应返回 2 人,实际 1 人),修复后通过。
+- 16 项相关测试通过,覆盖学员列表、旧学习记录拉取、旧预约拉取及学习报表查询;11 个云函数定义校验通过。
+- 用候选函数在正式数据库中只读验证:计然可进入首屏,学习记录权限检查通过;该老师的正常学员列表由 2 人变为 14 人,查询约 89 毫秒,未混入停用、删除或非学员账号。这是候选代码验证,不代表正式业务入口已经更新。
+- 核查期间未修改用户、排课、学习记录或同步状态。临时诊断函数执行后已清理。
+- 按用户此前“只推代码、暂不上线”的要求,本次修复仅提交、推送,未更新正式网关或前端。三份报表的待发布字号调整也未上线。
+
+受限本机证据目录:`~/.local/state/xiaoshu/cloud-releases/2026-11-02-coach-student-jiran/`,保存网关基线、只读查询结果、候选代码计划和 `preflight-result.json`。

+ 3 - 1
scripts/deploy-admin-functions.mjs

@@ -2568,7 +2568,9 @@ async function coachStudentPage(input,current) {
   base += " WHERE c.\"company\"=$1 AND c.\"modelId\"=54 AND c.\"status\"=99 AND a.\"pl\"=$2 AND COALESCE(a.\"szyh\",'')<>''";
   if (scheduledOnly) {
     values.push(appOverviewToday().replace(/-/g,''));
-    base += " AND eligible.\"isDisabled\" IS NOT TRUE AND eligible.\"isDeleted\" IS NOT TRUE\n      AND COALESCE(eligible.\"legacyGroupId\"::text,eligible.\"legacyUserData\"->>'GroupID','0')='1'\n      AND COALESCE(to_jsonb(a)->>'isDeleted','false') NOT IN ('true','1')\n      AND COALESCE(to_jsonb(c)->>'isDeleted','false') NOT IN ('true','1')\n      AND COALESCE(NULLIF(a.\"dszt\"::text,''),'0') IN ('0','10','20')\n      AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(a.\"bxrq\",''),a.\"yysj\",''),'[^0-9]','','g'),8) >= $3";
+    base += " AND eligible.\"isDisabled\" IS NOT TRUE AND eligible.\"isDeleted\" IS NOT TRUE\n      AND COALESCE(eligible.\"legacyGroupId\"::text,eligible.\"legacyUserData\"->>'GroupID','0')='1'\n      AND COALESCE(to_jsonb(a)->>'isDeleted','false') NOT IN ('true','1')\n      AND COALESCE(to_jsonb(c)->>'isDeleted','false') NOT IN ('true','1')";
+    // A completed last lesson must not hide a student's existing learning history.
+    base += " AND ((COALESCE(NULLIF(a.\"dszt\"::text,''),'0') IN ('0','10','20') AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(a.\"bxrq\",''),a.\"yysj\",''),'[^0-9]','','g'),8) >= $3) OR a.\"szyh\" IN (SELECT CAST(d.\"userId\" AS text) FROM \"DailyStudyRecord\" d JOIN \"CommonModel\" history ON history.\"company\"=$1 AND history.\"modelId\"=56 AND history.\"itemId\"=d.\"id\" AND history.\"status\"=99 WHERE d.\"company\"=$1 AND d.\"pl\"=$2 AND COALESCE(to_jsonb(d)->>'isDeleted','false') NOT IN ('true','1') AND COALESCE(to_jsonb(history)->>'isDeleted','false') NOT IN ('true','1')))";
   }
   const countRow = await Psql.one('SELECT COUNT(DISTINCT CAST(a."szyh" AS text))::int AS total ' + base,values);
   // Keep an eligible restored selection on the first page, even with many students.

+ 21 - 2
scripts/tests/coach-student-page.test.mjs

@@ -24,9 +24,10 @@ before(async () => {
       "nickname" text,"nickName" text,"realName" text,"createdAt" timestamptz);
     CREATE TABLE "CourseAppointment"("company" text,"id" int,"szyh" text,"pl" text,"dszt" text,"bxrq" text,"yysj" text,"isDeleted" boolean);
     CREATE TABLE "CommonModel"("company" text,"modelId" int,"itemId" int,"status" int,"updatedAt" timestamptz,"isDeleted" boolean);
+    CREATE TABLE "DailyStudyRecord"("company" text,"id" int,"userId" numeric,"pl" text,"isDeleted" boolean);
   `);
 });
-beforeEach(() => db.exec('TRUNCATE "_User","CourseAppointment","CommonModel"'));
+beforeEach(() => db.exec('TRUNCATE "_User","CourseAppointment","CommonModel","DailyStudyRecord"'));
 after(() => db.close());
 
 async function user(id, { disabled = false, deleted = false, group = 1, company = 'co' } = {}) {
@@ -38,7 +39,7 @@ async function appointment(id, student, { date = '2026-10-31', yysj = '', status
   await db.query('INSERT INTO "CommonModel" VALUES($1,54,$2,$3,$4,$5)', [company, id, commonStatus, '2026-10-30T00:00:00Z', commonDeleted]);
 }
 
-test('仅列出正常学员账号且在当前老师名下仍有今天或未来排课,先筛选再分页', async () => {
+test('没有授课记录时仅列出正常账号且仍有今天或未来排课的学员,先筛选再分页', async () => {
   for (let id = 1; id <= 13; id++) await user(id, { disabled: id === 2, deleted: id === 3, group: id === 4 ? 3 : 1 });
   await appointment(1, 1);
   await appointment(2, 2);
@@ -72,6 +73,24 @@ test('日期兼容紧凑日期、斜线日期及预约时间回退,空日期
   assert.deepEqual(ids(await page()).sort((a, b) => a - b), [1, 2, 3]);
 });
 
+test('有授课记录的正常学员在最后一节课结束后仍可查看历史,过滤停用、删除、取消及其他老师记录', async () => {
+  for (let id = 1; id <= 9; id++) {
+    await user(id, { disabled: id === 2, deleted: id === 3, group: id === 4 ? 3 : 1 });
+    await appointment(id, id, { date: '2026-10-29', status: '30' });
+    await db.query('INSERT INTO "DailyStudyRecord" VALUES($1,$2,$3,$4,$5)',
+      [id === 5 ? 'other' : 'co', id, String(id), id === 6 ? '301' : '300', id === 7]);
+    await db.query('INSERT INTO "CommonModel" VALUES($1,56,$2,$3,$4,$5)',
+      [id === 5 ? 'other' : 'co', id, id === 8 ? -2 : 99, '2026-10-29T00:00:00Z', id === 9]);
+  }
+  await user(10); await appointment(10, 10); // Future lesson remains eligible before first study.
+  await user(11); await appointment(11, 11, { date: '2026-10-29', status: '30' }); // No study record.
+  const first = await page({ selectedStudentId: 1, psize: 1 });
+  const second = await page({ selectedStudentId: 1, psize: 1, cpage: 2 });
+  assert.equal(first.page.itemCount, 2);
+  assert.deepEqual(ids(first), [1]);
+  assert.deepEqual(ids(second), [10]);
+});
+
 test('恢复有效学员时将其放在首屏,停用或无排课的旧选择不能绕过筛选', async () => {
   for (let id = 1; id <= 4; id++) { await user(id, { disabled: id === 4 }); await appointment(id, id); }
   await user(5);