Przeglądaj źródła

fix(lessons): support confirmed legacy writing appointments

彭峰 21 godzin temu
rodzic
commit
96c0de6b13

+ 23 - 0
docs/qa/legacy-reading-writing-appointment-fix-2026-11-04.md

@@ -0,0 +1,23 @@
+# 旧阅读写作预约结课兼容(2026-11-04)
+
+## 问题与确认
+
+预约 `885848` 属于熊小俊,预约课程为旧系统课程 `398 / 阅读写作`。课程在系统更新之前创建,没有新系统的课程分类、词库绑定、课时预留或工资快照。学生没有 398 课程绑定,详情因此显示单词词库选择器,无法按写作课开始和结课。
+
+学员资料没有年级、学校或生日,课程列表同时含小学教材和高中词库,不能据此可靠推断学段。用户已明确确认此预约按高中处理;该确认仅适用于本预约,不自动推及学员其他预约。
+
+## 修复
+
+针对来源为旧系统、原课程编号 398、且已有逐预约人工确认的语法写作分类及证据的预约:免去词库绑定和单词数量校验,保留原课程编号。课程开始仍需按对应学段、时长和交付方式取得有效工资及课时规则,核验余额并建立课时预留;未确认学段、课程编号缺失、身份越权、课时不足等情况仍拒绝。
+
+预约详情返回已确认的写作分类后,现有页面会进入阅读写作流程,老师可直接开始并结束课程;结课后沿用写作课的 21 天抗遗忘计划。
+
+## 验证
+
+- 相关预约准备、结课、写作复习和工资规则共 63 项测试通过,覆盖确认/未确认学段、课程编号缺失、课时预留幂等、保留原课程编号以及结课生成 10 次复习。
+- 云函数定义校验 11/11 通过。独立审查发现的空课程编号可能绕过校验的问题已修复并加入回归测试,复审未发现剩余阻塞。
+- 对实际预约 `885848` 在事务保存点内临时加载候选 SQL、标记用户确认高中并模拟开始和结课:按现有规则计算线上 60 分钟课老师费用 65 元、预留 60 分钟课时和陪练时长各 1,生成 10 次复习,日期从 2026-11-05 至 2026-11-25。随后全部回滚,没有永久扣课时、结束课程或生成任务。演练脚本及结果位于 `/Users/mac/.local/state/xiaoshu/qa/2026-11-04-legacy-writing/`。
+
+## 交付边界
+
+本次仅提交并推送代码,未发布线上函数,也未修改正式预约。正式上线时,需要选择性更新 `xs_prepare_legacy_appointment`、`xs_validate_appointment` 和应用网关的 `lessonOrderCapabilities`,保留其他线上函数。随后对预约 `885848` 以用户确认高中为证据,通过现有 `xs_payroll_writing_mapping` 审计入口确认 `high_writing`,复核状态、费用、课时和老师端详情;不要代老师开始或结束课程。尚未上线或确认正式预约前,老师仍无法完成该旧预约。

+ 2 - 2
scripts/cloud/lesson-workflow.js

@@ -59,7 +59,7 @@ async function appLessonReview(input, current) {
 async function lessonOrderCapabilities(order, current) {
   assertLessonTenant(current);
   const id = String(order.GeneralID), uid = ownLegacyId(current), status = number(order.dszt);
-  const stateSql='SELECT c."objectId",a."sourceKey",a.kcid,a."courseCategoryKey",a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."creditHoldReason",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."generalId"::text=$2';
+  const stateSql='SELECT c."objectId",a."sourceKey",a.kcid,a."courseCategoryKey",to_jsonb(a)->>\'categoryEvidence\' AS "categoryEvidence",a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."creditHoldReason",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."generalId"::text=$2';
   let state = await Psql.oneOrNone(stateSql, [DEFAULT_COMPANY_ID,id]);
   if (!state) fail(404, '预约不存在');
   let preparationReason='';
@@ -73,7 +73,7 @@ async function lessonOrderCapabilities(order, current) {
   const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
   const submittedReport = reviewed && [30,40].includes(status) ? await Psql.oneOrNone('SELECT "feedbackStatus" FROM "LessonCompletionReport" WHERE company=$1 AND "appointmentGeneralId"::text=$2 LIMIT 1',[DEFAULT_COMPANY_ID,id]) : null;
   const feedbackLocked = Boolean(submittedReport && submittedReport.feedbackStatus !== 'pending');
-  const freeWriting = ['primary_writing','middle_writing','high_writing'].includes(state.courseCategoryKey) && String(state.kcid)==='0' && !state.bindingId;
+  const freeWriting = ['primary_writing','middle_writing','high_writing'].includes(state.courseCategoryKey) && (String(state.kcid)==='0' || (String(state.kcid)==='398' && /^legacy[:\-]/.test(state.sourceKey||'') && String(state.categoryEvidence||'').trim().length>=2)) && !state.bindingId;
   const teacher = uid === number(order.pl), student = uid === number(order.szyh), actions = [];
   let blockedReason = '';
   if (order.cancelled) blockedReason = '预约已取消';

+ 9 - 3
scripts/sql/legacy-appointment-preparation.sql

@@ -3,7 +3,7 @@
 CREATE OR REPLACE FUNCTION xs_prepare_legacy_appointment(co text,aid text,actor text,seed_rules jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE c "CommonModel";a "CourseAppointment";b "CourseBinding";u "_User";op "_User";
  category text;category_name text;course text;day date;minutes numeric;mode text;rule jsonb;costs jsonb;cost jsonb;
- matches integer;existing "CourseCreditReservation";before_data jsonb;changed boolean=false;
+ matches integer;notes_legacy boolean=false;existing "CourseCreditReservation";before_data jsonb;changed boolean=false;
 BEGIN
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT * INTO op FROM "_User" WHERE company=co AND "objectId"=actor;
@@ -23,8 +23,13 @@ BEGIN
  -- Do not overwrite either identifier while filling missing credentials.
  course=a.kcid::text;
  IF NULLIF(a.yykcid::text,'') IS NOT NULL AND a.yykcid::text<>course THEN RAISE EXCEPTION '预约课程编号与排课课程不一致,请管理员核实';END IF;
- PERFORM xs_course_size(co,course);
  category=NULLIF(a."courseCategoryKey",'');
+ -- The legacy writing course has no vocabulary binding; only a per-appointment human confirmation is sufficient.
+ notes_legacy=COALESCE(course='398' AND category IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(to_jsonb(a)->>'categoryEvidence','')))>=2 AND NULLIF(a."bindingId",'') IS NULL,false);
+ IF notes_legacy THEN
+  category_name=COALESCE(NULLIF(a."courseCategoryName",''),CASE category WHEN 'primary_writing' THEN '小学语法写作课' WHEN 'middle_writing' THEN '中学语法写作课' WHEN 'high_writing' THEN '高中语法写作课' END);
+ ELSE
+ PERFORM xs_course_size(co,course);
  SELECT count(*) INTO matches FROM "CourseBinding" cb JOIN "CommonModel" bc ON bc.company=cb.company AND bc."modelId"=58 AND bc."itemId"=cb.id
  WHERE cb.company=co AND cb.yhid::text=a.szyh::text AND cb.kcid::text=course AND bc.status=99
  AND COALESCE(cb."enrollmentStatus",'pending') IN ('active','pending') AND (category IS NULL OR cb."categoryKey"=category OR cb."categoryKey" IS NULL)
@@ -44,6 +49,7 @@ BEGIN
  IF COALESCE(b."enrollmentStatus",'pending')='pending' AND (COALESCE(b."sourceKey",'') !~ '^legacy[:\-]' OR NOT EXISTS(
  SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"=category)
  OR EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"<>category)) THEN RAISE EXCEPTION '历史开课分类需管理员核实';END IF;
+ END IF;
  IF EXISTS(SELECT 1 FROM "TeachingCourseCategory" tc WHERE tc.company=co AND tc.key=category AND tc.active=false) THEN RAISE EXCEPTION '该课程分类已停用';END IF;
  minutes=COALESCE(a."durationMinutes",CASE a.dslx WHEN '1' THEN 30 ELSE 60 END);
  mode=CASE WHEN COALESCE(NULLIF(a."deliveryMode",''),a.jffs,'') ~ 'offline|线下|到店|面授' THEN 'offline' ELSE 'online' END;
@@ -65,7 +71,7 @@ BEGIN
  END LOOP;
  SELECT * INTO existing FROM "CourseCreditReservation" WHERE company=co AND "appointmentId"=aid AND state IN ('reserved','consumed') FOR UPDATE;
  IF FOUND AND (existing.state IS DISTINCT FROM 'reserved' OR existing."studentId"::text IS DISTINCT FROM a.szyh::text OR existing."creditCosts"::jsonb IS DISTINCT FROM costs) THEN RAISE EXCEPTION '现有课时凭据冲突,请管理员核实';END IF;
- IF COALESCE(b."enrollmentStatus",'pending')='pending' OR b."categoryKey" IS NULL THEN
+ IF NOT notes_legacy AND (COALESCE(b."enrollmentStatus",'pending')='pending' OR b."categoryKey" IS NULL) THEN
   UPDATE "CourseBinding" SET "enrollmentStatus"='active',"categoryKey"=category,"categoryName"=category_name,"updatedAt"=now() WHERE "objectId"=b."objectId";changed=true;
  END IF;
  IF a."bindingId" IS DISTINCT FROM b."objectId" OR a.kcid::text IS DISTINCT FROM course OR a."courseCategoryKey" IS NULL OR a."teacherPaySnapshot" IS NULL OR COALESCE(jsonb_array_length(a."creditCostsSnapshot"::jsonb),0)=0 OR a."creditHoldStatus" IS DISTINCT FROM 'ready' THEN

+ 1 - 1
scripts/sql/member-enrollment.sql

@@ -231,7 +231,7 @@ BEGIN
  SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
  -- Writing lessons use a category and credit rule, not a vocabulary binding.
- IF NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND COALESCE(a."kcid"::text,'')='0' AND COALESCE(a."bindingId",'')='') THEN
+ IF NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND (COALESCE(a."kcid"::text,'')='0' OR (COALESCE(a."kcid"::text,'')='398' AND COALESCE(a."sourceKey",'') ~ '^legacy[:\\-]' AND length(trim(COALESCE(to_jsonb(a)->>'categoryEvidence','')))>=2)) AND COALESCE(a."bindingId",'')='') THEN
  SELECT count(*) INTO matches FROM "CourseBinding" b JOIN "CommonModel" bc ON bc."company"=b."company" AND bc."modelId"=58 AND bc."itemId"=b."id" WHERE b."company"=co AND b."yhid"::text=a."szyh"::text AND b."kcid"::text=a."kcid"::text AND b."enrollmentStatus"='active' AND bc."status"=99 AND (NULLIF(a."bindingId",'') IS NULL OR b."objectId"=a."bindingId");
  PERFORM xs_course_size(co,a."kcid"::text);
  IF matches<>1 THEN RAISE EXCEPTION '预约必须关联唯一有效开课记录,请先确认分类';END IF;

+ 11 - 1
scripts/tests/legacy-appointment-preparation.test.mjs

@@ -5,7 +5,7 @@ const prepare=async(id,actor='teacher')=>(await one('SELECT xs_prepare_legacy_ap
 before(async()=>{
  await db.exec(`CREATE TABLE "_User"("objectId" text primary key,company text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDisabled" boolean,"isDeleted" boolean,"adminRoleKey" text);
  CREATE TABLE "CommonModel"("objectId" text primary key,company text,"modelId" numeric,"itemId" numeric,"generalId" numeric,status numeric,"sourceKey" text);
- CREATE TABLE "CourseAppointment"("objectId" text primary key,company text,id numeric,szyh text,pl text,kcid text,yykcid text,dszt text,dslx text,jffs text,bxrq text,yysj text,"sourceKey" text,"teachingManaged" boolean,"bindingId" text,"courseCategoryKey" text,"courseCategoryName" text,"durationMinutes" numeric,"deliveryMode" text,"teachingRuleId" text,"teacherPaySnapshot" numeric,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"creditHoldReason" text,"updatedAt" timestamptz);
+ CREATE TABLE "CourseAppointment"("objectId" text primary key,company text,id numeric,szyh text,pl text,kcid text,yykcid text,dszt text,dslx text,jffs text,bxrq text,yysj text,"sourceKey" text,"teachingManaged" boolean,"bindingId" text,"courseCategoryKey" text,"courseCategoryName" text,"categoryEvidence" text,"durationMinutes" numeric,"deliveryMode" text,"teachingRuleId" text,"teacherPaySnapshot" numeric,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"creditHoldReason" text,"updatedAt" timestamptz);
  CREATE TABLE "CourseBinding"("objectId" text primary key,company text,id numeric,yhid text,kcid text,"categoryKey" text,"categoryName" text,"enrollmentStatus" text,"sourceKey" text,"updatedAt" timestamptz);
  CREATE TABLE "CourseTeachingProfile"(company text,"courseId" numeric,"categoryKey" text,confirmed boolean);
  CREATE TABLE "TeachingCourseCategory"(company text,key text,active boolean);
@@ -50,3 +50,13 @@ test('新导入的空分类开课只补明确的非写作分类;全局写作
  await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"=null,"categoryKey"=null,"categoryName"=null`);const id=await fixture();await prepare(id);assert.equal((await one('SELECT "categoryKey" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).categoryKey,'word');
  await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"=null,"categoryKey"=null,"categoryName"=null;UPDATE "CourseTeachingProfile" SET "categoryKey"='high_writing'`);await assert.rejects(prepare(await fixture()),/按学员/);await db.exec(`UPDATE "CourseTeachingProfile" SET "categoryKey"='word'`);
 });
+
+test('旧阅读写作预约经逐人确认高中后免词库核验,保留原课程并预留课时',async()=>{
+ const id=await fixture({kcid:'398',yykcid:'398',courseCategoryKey:'high_writing',categoryEvidence:'用户确认高中'});
+ const r=await prepare(id);assert.equal(r.changed,true);
+ const a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]);
+ assert.equal(a.kcid,'398');assert.equal(a.bindingId,null);assert.equal(a.creditHoldStatus,'ready');assert.equal(Number(a.teacherPaySnapshot),65);
+ assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1);
+ assert.equal((await prepare(id)).changed,false);
+});
+test('旧阅读写作未确认学段不得自动预留',async()=>{const id=await fixture({kcid:'398',yykcid:'398'});await assert.rejects(prepare(id),/单词|分类|课程/);});

+ 18 - 1
scripts/tests/lesson-workflow.test.mjs

@@ -412,6 +412,23 @@ test('教师可以开始免教材语法写作,单词课仍要求有效开课
  vm.createContext(context);vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
  const teacher={get:k=>k==='company'?{id:'co'}:null},order={GeneralID:1,pl:30,szyh:20,dszt:0};
  for(const category of ['primary_writing','middle_writing','high_writing']){state.courseCategoryKey=category;assert.ok((await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));}
- state.courseCategoryKey='word';assert.ok(!(await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));
+ state.kcid='398';state.courseCategoryKey='high_writing';state.sourceKey='legacy:model:54:test';state.categoryEvidence='确认高中';context.prepareLegacyAppointment=async()=>({});assert.ok((await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));state.categoryEvidence='';assert.ok(!(await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));
+ state.kcid='0';state.courseCategoryKey='word';assert.ok(!(await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));
  state.courseCategoryKey='high_writing';state.creditHoldStatus='insufficient';assert.ok(!(await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));
 });
+test('确认学段的旧阅读写作课保留原课程编号,开始结课后生成十轮复习',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec('ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "categoryEvidence" text');
+  const a=await appointment(false,'',{kcid:'0',yykcid:'0',bindingId:'',courseCategoryKey:'high_writing'});
+  await db.query(`UPDATE "CourseAppointment" SET kcid=NULL,"sourceKey"='legacy:model:54:general:test',"categoryEvidence"='用户确认高中' WHERE "objectId"=$1`,[a.objectId]);
+  await db.exec('SAVEPOINT missing_course');await assert.rejects(db.query(`SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)`,[a.common]));await db.exec('ROLLBACK TO SAVEPOINT missing_course');
+  await db.query(`UPDATE "CourseAppointment" SET kcid='398',yykcid='398',"sourceKey"='legacy:model:54:general:test',"categoryEvidence"=NULL WHERE "objectId"=$1`,[a.objectId]);
+  await db.exec('SAVEPOINT unconfirmed');await assert.rejects(db.query(`SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)`,[a.common]));await db.exec('ROLLBACK TO SAVEPOINT unconfirmed');
+  await db.query(`UPDATE "CourseAppointment" SET "categoryEvidence"='用户确认高中' WHERE "objectId"=$1`,[a.objectId]);
+  await db.query(`SELECT xs_start_appointment_for_actor('co',$1,'teacher',30)`,[a.common]);
+  const started=await one('SELECT *,"updatedAt"::text version FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId]);
+  assert.equal(started.dszt,'10');await complete(a.gid,started.version,'legacy-writing-complete');
+  assert.equal((await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid])).n,10);
+  assert.equal((await one('SELECT kcid FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).kcid,'398');
+ }finally{await db.exec('ROLLBACK');}
+});