Преглед изворни кода

fix(learning): retain students through pending review dates

彭峰 пре 9 часа
родитељ
комит
02d76e0745

+ 12 - 7
docs/qa/coach-student-history-fix-2026-11-02.md

@@ -8,18 +8,23 @@
 
 根因是学习记录页面调用 `e_user_list` 的 `current-scheduled` 筛选:只接受今天或以后尚未结束的有效排课。最后一节课结束或跨日后,即使历史学习记录仍在,学员也会从选择列表消失。
 
-## 修改
+## 修改(以用户补充的复习期规则为准)
 
-仅修改 `coachStudentPage`:保留正常学员账号、同帐套、有效师生排课关联等限制;满足当前排课条件,或拥有这位老师的有效学习记录,即可进入学习记录学员列表。既有客户端参数保持兼容。
+仅修改 `coachStudentPage`:保留正常学员账号、同帐套、有效师生排课关联等限制;满足当前排课条件,或仍有分配给这位老师、计划日期为今天或以后且未完成的有效复习任务,即可进入学习记录学员列表。既有客户端参数保持兼容。
 
-仍排除停用、删除、非学员账号,已取消或删除的学习记录、其他老师及其他帐套的记录不能作为历史授课依据。未请求此筛选的旧版“我的学员”行为保持不变。分页、去重和选中学员首屏恢复继续有效。
+直接读取 `MemoryPracticeRecord` 的实际计划日期 `kywrq`(为空时使用 `kywsj`)和完成状态 `fxzt`,不再凭“存在历史授课记录”长期保留。最后复习当天仍显示;全部复习完成,或剩余任务的计划日期均已过去且没有新排课时移出。其他课仍有未结束的复习时继续保留,按北京时间判断当天。
+
+计然 2026-11-01 课程对应的复习计划为 2026-11-02 至 2026-11-22。按目前尚未完成的计划,应覆盖到 11 月 22 日;没有新增排课或后续复习时,11 月 23 日自动移出。
+
+仍排除停用、删除、非学员账号,已取消、删除、标记为重复的复习任务,以及其他老师、其他帐套的任务不作为保留依据。未请求此筛选的旧版“我的学员”行为保持不变。分页、去重和选中学员首屏恢复继续有效。
 
 ## 验证及发布边界
 
-- 新回归用例先在旧逻辑上失败(应返回 2 人,实际 1 人),修复后通过。
-- 16 项相关测试通过,覆盖学员列表、旧学习记录拉取、旧预约拉取及学习报表查询;11 个云函数定义校验通过。
-- 用候选函数在正式数据库中只读验证:计然可进入首屏,学习记录权限检查通过;该老师的正常学员列表由 2 人变为 14 人,查询约 89 毫秒,未混入停用、删除或非学员账号。这是候选代码验证,不代表正式业务入口已经更新。
+- 补充规则的 4 项回归用例先在上一版逻辑上失败,修改后通过。覆盖复习期保留、最后一天和过期边界、全部完成、多课复习衔接、日期格式兼容、无效和跨老师任务排除。
+- 24 项相关测试通过,覆盖学员列表、复习分配权限、旧复习/学习/预约拉取及学习报表查询;11 个云函数定义校验通过。
+- 用候选函数在正式数据库中只读验证:计然可进入首屏,学习记录权限检查通过;该老师的正常学员列表由 2 人变为 3 人,查询约 99 毫秒,未混入停用、删除或非学员账号。上一版无限保留历史学员的逻辑已替换。
+- 用现有真实数据只读模拟 11 月 2 日、11 月 22 日和 11 月 23 日,计然分别为显示、显示、不显示;未修改正式服务时间、复习任务或完成状态。这是候选代码验证,不代表正式业务入口已经更新。
 - 核查期间未修改用户、排课、学习记录或同步状态。临时诊断函数执行后已清理。
 - 按用户此前“只推代码、暂不上线”的要求,本次修复仅提交、推送,未更新正式网关或前端。三份报表的待发布字号调整也未上线。
 
-受限本机证据目录:`~/.local/state/xiaoshu/cloud-releases/2026-11-02-coach-student-jiran/`,保存网关基线、只读查询结果、候选代码计划和 `preflight-result.json`。
+受限本机证据目录:`~/.local/state/xiaoshu/cloud-releases/2026-11-02-coach-student-jiran/`,保存网关基线、只读查询结果、候选代码计划、`preflight-result.json`、`review-data.json` 和 `review-boundary-result.json`。

+ 2 - 2
scripts/deploy-admin-functions.mjs

@@ -2569,8 +2569,8 @@ async function coachStudentPage(input,current) {
   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')";
-    // 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')))";
+    // Keep students through their assigned review dates, rather than retaining all past students forever.
+    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(m.\"yhid\" AS text) FROM \"MemoryPracticeRecord\" m JOIN \"CommonModel\" review ON review.\"company\"=$1 AND review.\"modelId\"=60 AND review.\"itemId\"=m.\"id\" AND review.\"status\"=99 WHERE m.\"company\"=$1 AND m.\"plid\"=$2::numeric AND COALESCE(NULLIF(m.\"fxzt\"::text,''),'0')='0' AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(m.\"kywrq\",''),m.\"kywsj\",''),'[^0-9]','','g'),8) >= $3 AND COALESCE(to_jsonb(m)->>'isDeleted','false') NOT IN ('true','1') AND COALESCE(to_jsonb(m)->>'rewardDuplicate','false') NOT IN ('true','1') AND COALESCE(to_jsonb(review)->>'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.

+ 55 - 11
scripts/tests/coach-student-page.test.mjs

@@ -25,9 +25,13 @@ before(async () => {
     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);
+    CREATE TABLE "MemoryPracticeRecord"("company" text,"id" numeric,"yhid" numeric,"plid" numeric,"fxzt" text,"kywrq" text,"kywsj" text,"isDeleted" boolean,"rewardDuplicate" boolean);
   `);
 });
-beforeEach(() => db.exec('TRUNCATE "_User","CourseAppointment","CommonModel","DailyStudyRecord"'));
+beforeEach(async () => {
+  gateway.appOverviewToday = () => '2026-10-30';
+  await db.exec('TRUNCATE "_User","CourseAppointment","CommonModel","DailyStudyRecord","MemoryPracticeRecord"');
+});
 after(() => db.close());
 
 async function user(id, { disabled = false, deleted = false, group = 1, company = 'co' } = {}) {
@@ -39,7 +43,14 @@ 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 () => {
+async function review(id, student, { date = '2026-11-01', time = '', status = '0', commonStatus = 99, teacher = 300, company = 'co', deleted = false, commonDeleted = false, duplicate = false } = {}) {
+  await db.query('INSERT INTO "MemoryPracticeRecord" VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)',
+    [company, id, student, teacher, status, date, time, deleted, duplicate]);
+  await db.query('INSERT INTO "CommonModel" VALUES($1,60,$2,$3,$4,$5)',
+    [company, id, commonStatus, '2026-10-30T00:00:00Z', commonDeleted]);
+}
+
+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);
@@ -73,22 +84,55 @@ test('日期兼容紧凑日期、斜线日期及预约时间回退,空日期
   assert.deepEqual(ids(await page()).sort((a, b) => a - b), [1, 2, 3]);
 });
 
-test('有授课记录的正常学员在最后一节课结束后仍可查看历史,过滤停用、删除、取消及其他老师记录', async () => {
-  for (let id = 1; id <= 9; id++) {
+test('无后续排课时按未结束复习保留正常学员,过滤无效账号、任务及其他老师记录', async () => {
+  for (let id = 1; id <= 12; 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 review(id, id, { company: id === 5 ? 'other' : 'co', teacher: id === 6 ? 301 : 300,
+      deleted: id === 7, commonStatus: id === 8 ? -2 : 99, commonDeleted: id === 9,
+      duplicate: id === 10, status: id === 11 ? '1' : '0', date: id === 12 ? '2026-10-29' : '2026-11-01' });
   }
-  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.
+  await user(20); await appointment(20, 20); // Upcoming lessons remain visible before any review is generated.
   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]);
+  assert.deepEqual(ids(second), [20]);
+});
+
+test('最后复习当天仍显示,复习期结束后移出,历史授课记录不会永久保留学员', async () => {
+  await user(1); await appointment(1, 1, { date: '2026-10-01', status: '30' });
+  await db.query('INSERT INTO "DailyStudyRecord" VALUES($1,$2,$3,$4,$5)', ['co', 1, 1, '300', false]);
+  await db.query('INSERT INTO "CommonModel" VALUES($1,56,$2,99,$3,false)', ['co', 1, '2026-10-01T00:00:00Z']);
+  await review(1, 1, { date: '2026-10-30' });
+  await review(2, 1, { date: '2026-11-01' });
+  assert.deepEqual(ids(await page()), [1]);
+  gateway.appOverviewToday = () => '2026-10-31';
+  assert.deepEqual(ids(await page()), [1]);
+  gateway.appOverviewToday = () => '2026-11-01';
+  assert.deepEqual(ids(await page()), [1]);
+  gateway.appOverviewToday = () => '2026-11-02';
+  assert.deepEqual(ids(await page({ selectedStudentId: 1 })), []);
+});
+
+test('复习全部完成后移出,另一节课仍有未结束复习时继续显示', async () => {
+  await user(1); await appointment(1, 1, { date: '2026-10-01', status: '30' });
+  await review(1, 1, { date: '2026-11-05' });
+  await review(2, 1, { date: '2026-11-20', status: '1' });
+  assert.deepEqual(ids(await page()), [1]);
+  await db.query('UPDATE "MemoryPracticeRecord" SET fxzt=$1 WHERE id=$2', ['1', 1]);
+  assert.deepEqual(ids(await page()), []);
+  await review(3, 1, { date: '2026-11-21' });
+  assert.deepEqual(ids(await page()), [1]);
+});
+
+test('复习日期兼容紧凑日期、斜线日期和计划时间回退,无日期任务不能保留学员', async () => {
+  for (let id = 1; id <= 4; id++) { await user(id); await appointment(id, id, { date: '2026-10-01', status: '30' }); }
+  await review(1, 1, { date: '20261030' });
+  await review(2, 2, { date: '2026/10/31' });
+  await review(3, 3, { date: '', time: '2026-10-30 20:00' });
+  await review(4, 4, { date: '', time: '' });
+  assert.deepEqual(ids(await page()).sort((a,b) => a-b), [1,2,3]);
 });
 
 test('恢复有效学员时将其放在首屏,停用或无排课的旧选择不能绕过筛选', async () => {