Sfoglia il codice sorgente

fix(teaching): prepare legacy writing and trial appointments safely

彭峰 2 ore fa
parent
commit
8a903201fe

+ 89 - 3
scripts/cloud/legacy-appointment.js

@@ -1,8 +1,94 @@
-async function prepareLegacyAppointment(order,current) {
+async function readLegacyWritingOrder(id) {
+  const config=await appLegacyConfig();
+  if(!config.url||!config.apiId||!config.apiKey)throw Error('暂时无法核对旧系统写作预约,请刷新后重试');
+  const params=new URLSearchParams({action:'content_list',modelId:'54',ids:String(id),cpage:'1',psize:'2',apiId:config.apiId,apiKey:config.apiKey});
+  try {
+    const url=config.url+(config.url.includes('?')?'&':'?')+params.toString();
+    const response=await fetch(url,{headers:{accept:'application/json'},signal:AbortSignal.timeout(8000)});
+    const payload=await response.json();
+    const rows=parseArray(payload.result);
+    if(response.ok&&[0,1].includes(Number(payload.retcode))&&rows.length===1&&
+       Number(payload.page?.itemCount)===1&&Number(payload.page?.pageCount)===1)return rows[0];
+  } catch (_) { /* The legacy URL contains credentials. Do not expose fetch errors. */ }
+  throw Error('暂时无法核对旧系统写作预约,请刷新后重试');
+}
+
+function assertLegacyWritingPending(order,live) {
+  const same=(key)=>String(live?.[key]??'').trim()===String(order[key]??'').trim();
+  const time=(value)=>String(value??'').trim().replace('T',' ').slice(0,16);
+  const model=live?.ModelID??live?.modelId;
+  const writingBooking=model==null?Number(live?.NodeID??live?.nodeId)===29:Number(model)===54;
+  if(String(live?.GeneralID??live?.generalId)!==String(order.GeneralID)||!writingBooking||
+     !same('szyh')||!same('pl')||!same('kcid')||!same('dslx')||!same('jffs')||!same('bxrq')||
+     time(live.yysj)!==time(order.yysj)||Number(live.Status??live.status??99)!==99){
+    throw Error('旧系统写作预约与新系统不一致,请联系管理员核对');
+  }
+  if(String(live.dszt)!=='0'||String(live.kssj??'').trim()||String(live.jssj??'').trim()){
+    throw Error('旧系统已开始上课或已完课,请等待记录同步,避免重复扣课时');
+  }
+}
+
+async function confirmLegacyWritingStage(db,order,current) {
+  // Keep the exact booking, student profile and the existing preparation in one transaction.
+  const co=DEFAULT_COMPANY_ID,aid=order.objectId;
+  const row=await db.oneOrNone(`SELECT c.title AS "studentName",a.szyh AS "studentId",
+    a."yysj" AS "startsAt",a."updatedAt"::text AS "updatedAt",a."courseCategoryKey" AS category,
+    a."categoryEvidence" AS evidence,a."bindingId",a."teacherPaySnapshot",a."creditCostsSnapshot",
+    to_jsonb(u)->>'grade' AS grade,u."legacyUserData"->>'Position' AS position,
+    u."legacyUserData"->'UserBase'->>'Position' AS "basePosition",
+    u."legacyUserData"->>'HoneyName' AS "oldName",u."legacyUserData"->>'UserName' AS "oldUsername",
+    u."realName",u.nickname,u.username
+    FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId"
+    JOIN "_User" u ON u.company=a.company AND u."legacyUserId"::text=a.szyh::text
+    WHERE c.company=$1 AND c."objectId"=$2 AND c."modelId"=54 AND c."generalId"::text=$3 AND c.status=99
+    AND c."sourceKey"='legacy:model:54:general:'||$3 AND a."sourceKey"=c."sourceKey"
+    AND a.szyh::text=$4 AND a.pl::text=$5 AND a.yysj=$6 AND a.dslx::text=$7 AND a.jffs=$8 AND a.bxrq=$9
+    AND a.kcid::text='398' AND a.yykcid::text='398' AND a.dszt='0'
+    AND NULLIF(trim(a.kssj),'') IS NULL AND NULLIF(trim(a.jssj),'') IS NULL
+    AND NOT COALESCE(a."teachingManaged",false) AND NOT COALESCE(u."isDisabled",false)
+    AND NOT COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false)
+    AND NOT EXISTS(SELECT 1 FROM "LessonRecord" l WHERE l.company=a.company AND l.yyds::text=c."generalId"::text)
+    AND NOT EXISTS(SELECT 1 FROM "LessonCompletionReport" r WHERE r.company=a.company AND r."appointmentGeneralId"::text=c."generalId"::text)
+    AND NOT EXISTS(SELECT 1 FROM "CourseCreditReservation" r WHERE r.company=a.company AND r."appointmentId"=c."objectId" AND r.state='consumed')
+    FOR UPDATE OF c,a,u`,[co,aid,String(order.GeneralID),String(order.szyh),String(order.pl),order.yysj,String(order.dslx),order.jffs,order.bxrq]);
+  if(!row)throw Error('旧写作预约已变更,请刷新后核对');
+  if(['primary_writing','middle_writing','high_writing'].includes(row.category)&&String(row.evidence??'').trim().length>=2)return;
+  if(row.category||row.evidence||row.bindingId||row.teacherPaySnapshot!=null||row.creditCostsSnapshot?.length){
+    throw Error('旧写作预约已有课程或课时凭据,请由管理员核对学段');
+  }
+  const prior=await db.query(`SELECT c."generalId" AS "appointmentGeneralId",a.szyh AS "studentId",
+    a.yysj AS "startsAt",a."courseCategoryKey" AS category,a."categoryEvidence" AS evidence
+    FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId"
+    WHERE c.company=$1 AND c."modelId"=54 AND c.status=99 AND a.szyh::text=$2
+    AND a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing')
+    AND length(trim(COALESCE(a."categoryEvidence",'')))>=2 AND a."categoryEvidence" NOT LIKE '开课自动核对:%'`,[co,String(row.studentId)]);
+  const today=appOverviewToday();
+  const trusted=legacyWritingStageAssessment({studentId:row.studentId,startsAt:row.startsAt},prior,today);
+  if(!trusted.canAutoConfirm)throw Error('缺少一致的本学年已确认写作课记录,请由管理员核对学段');
+  const stage=legacyWritingStageAssessment(row,prior,today);
+  if(!stage.canAutoConfirm)throw Error(stage.assessmentReason);
+  await db.one('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',
+    [co,current.id,aid,stage.suggestedCategoryKey,'开课自动核对:'+stage.evidence,row.updatedAt,'旧写作预约开课前按档案和本学年记录核对学段']);
+}
+
+async function prepareLegacyAppointment(order,current,startWriting=false) {
   // SQL rechecks ownership. Request parameters cannot supply the pricing rules.
+  const prepare=async db=>(await db.one('SELECT xs_prepare_legacy_appointment($1,$2,$3,$4::jsonb) AS result',
+    [DEFAULT_COMPANY_ID,order.objectId,current.id,JSON.stringify(legacyAppointmentSeedRules())])).result;
   try {
-    return (await Psql.one('SELECT xs_prepare_legacy_appointment($1,$2,$3,$4::jsonb) AS result',
-      [DEFAULT_COMPANY_ID,order.objectId,current.id,JSON.stringify(legacyAppointmentSeedRules())])).result;
+    if(String(order.kcid)!=='398')return await prepare(Psql);
+    const uid=ownLegacyId(current);
+    if(uid!==number(order.pl)||number(current.get('legacyGroupId'))!==3)throw Error('仅预约陪练可以核对旧写作课');
+    if(!startWriting)assertLegacyWritingPending(order,await readLegacyWritingOrder(order.GeneralID));
+    return await Psql.tx(async db=>{
+      await db.one("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[DEFAULT_COMPANY_ID]);
+      if(startWriting)assertLegacyWritingPending(order,await readLegacyWritingOrder(order.GeneralID));
+      await confirmLegacyWritingStage(db,order,current);
+      const prepared=await prepare(db);
+      if(startWriting)await db.one('SELECT xs_start_appointment_for_actor($1,$2,$3,$4) AS result',
+        [DEFAULT_COMPANY_ID,order.objectId,current.id,uid]);
+      return prepared;
+    });
   } catch(error) {
     return {blockedReason:String(error.message||error).replace(/^.*ERROR:\s*/,'')};
   }

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

@@ -63,7 +63,7 @@ async function lessonOrderCapabilities(order, current) {
   let state = await Psql.oneOrNone(stateSql, [DEFAULT_COMPANY_ID,id]);
   if (!state) fail(404, '预约不存在');
   let preparationReason='';
-  if(!order.cancelled&&status===0&&state.validToday&&/^legacy[:\-]/.test(state.sourceKey||'')&&(uid===number(order.pl)||uid===number(order.szyh))&&(!state.bindingId||!state.creditCostsSnapshot?.length||state.creditHoldStatus!=='ready')){
+  if(!order.cancelled&&status===0&&state.validToday&&/^legacy[:\-]/.test(state.sourceKey||'')&&(uid===number(order.pl)||(String(state.kcid)!=='398'&&uid===number(order.szyh)))&&(!state.bindingId||!state.creditCostsSnapshot?.length||state.creditHoldStatus!=='ready')){
     const prepared=await prepareLegacyAppointment({...order,objectId:state.objectId},current);
     preparationReason=prepared.blockedReason||'';
     if(!preparationReason)state=await Psql.oneOrNone(stateSql,[DEFAULT_COMPANY_ID,id]);

+ 7 - 1
scripts/cloud/member-enrollment-app.js

@@ -1,7 +1,13 @@
 async function enrollmentOrderTransition(input,current,order,orderId,status){
  const row=await Psql.oneOrNone('SELECT "objectId","sourceKey" FROM "CommonModel" WHERE "company"=$1 AND "modelId"=54 AND "generalId"::text=$2',[DEFAULT_COMPANY_ID,String(orderId)]);if(!row)fail(404,'预约不存在');
  if(status!==10)fail(409,'请使用老师一次结课入口');
- if(number(order.dszt)===0&&/^legacy[:\-]/.test(row.sourceKey||'')){const prepared=await prepareLegacyAppointment(row,current);if(prepared.blockedReason)fail(409,prepared.blockedReason);}
+ if(number(order.dszt)===0&&/^legacy[:\-]/.test(row.sourceKey||'')){
+  const writing=String(order.kcid)==='398';
+  if(writing&&input.learningBindingId)fail(409,'阅读写作课无需选择词库,请刷新后重试');
+  const prepared=await prepareLegacyAppointment({...order,objectId:row.objectId},current,writing);
+  if(prepared.blockedReason)fail(409,prepared.blockedReason);
+  if(writing)return {GeneralID:orderId,status,previousStatus:0};
+ }
  let learning;
  if(input.learningBindingId)learning=await startLessonWithContent(current,row.objectId,input.learningBindingId,input.expectedUpdatedAt);
  else try{await Psql.one('SELECT xs_start_appointment_for_actor($1,$2,$3,$4) AS result',[DEFAULT_COMPANY_ID,row.objectId,current.id,ownLegacyId(current)]);}catch(error){fail(409,String(error.message));}

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

@@ -2,7 +2,7 @@
 import { teacherEarningsSupport } from './teacher-earnings-transform.mjs';
 
 import { readFileSync } from 'node:fs';
-import { legacyAppointmentDefaults } from './legacy-appointment-transform.mjs';
+import { legacyAppointmentDefaults, legacyWritingStageSupport } from './legacy-appointment-transform.mjs';
 const legacyAppointmentCode = readFileSync(new URL('./cloud/legacy-appointment.js', import.meta.url), 'utf8');
 const storeScopeCode = readFileSync(new URL('./cloud/store-scope.js', import.meta.url), 'utf8');
 const creditLedgerCode = readFileSync(new URL('./cloud/member-credit-ledger.js', import.meta.url), 'utf8') + readFileSync(new URL('./cloud/legacy-finance.js', import.meta.url), 'utf8');
@@ -4148,7 +4148,7 @@ const definitions = [
   { name: 'cms.exams.classes', desc: '替代 ZL_Exam_ClassView', path: 'xiaoshu/cms/exams/classes', code: cmsReadCode('exam-classes'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
   { name: 'cms.guest.bar', desc: '替代 ZL_Guest_BarView', path: 'xiaoshu/cms/guest/bar', code: cmsReadCode('guest-bar'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
   { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
-  { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode + supportRequestsAppCode + examHistoryCode + teacherEarningsSupport(operationsGatewayCode,payrollIntegrityCode) + teacherEarningsCode + accountDeletionCode + accountSecurityCode + reviewRewardCode + enrollmentAppCode + mobileTeachingCode + lessonWorkflowCode + lessonAttachmentsCode + wordAudioCode + lessonCoursesCode + lessonUnitProgressCode + lessonStudyCode + legacyAppointmentDefaults(operationsGatewayCode) + legacyAppointmentCode, params: [{ name: 'action', type: 'String', required: true }] },
+  { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode + supportRequestsAppCode + examHistoryCode + teacherEarningsSupport(operationsGatewayCode,payrollIntegrityCode) + teacherEarningsCode + accountDeletionCode + accountSecurityCode + reviewRewardCode + enrollmentAppCode + mobileTeachingCode + lessonWorkflowCode + lessonAttachmentsCode + wordAudioCode + lessonCoursesCode + lessonUnitProgressCode + lessonStudyCode + legacyAppointmentDefaults(operationsGatewayCode) + legacyWritingStageSupport(payrollIntegrityCode) + legacyAppointmentCode, params: [{ name: 'action', type: 'String', required: true }] },
 ];
 
 function validateDefinition(definition) {

+ 7 - 0
scripts/legacy-appointment-transform.mjs

@@ -4,3 +4,10 @@ export function legacyAppointmentDefaults(operationsCode) {
   if(start<0||end<start)throw Error('缺少旧预约核对所需的默认课时规则');
   return '\nfunction legacyAppointmentSeedRules(){function text(value){return String(value==null?\'\':value).trim();}\n'+operationsCode.slice(start,end)+'\nreturn defaultTeachingRules();}\n';
 }
+
+// The admin review and automatic preparation use the same grade and conflict rules.
+export function legacyWritingStageSupport(integrityCode) {
+  const start=integrityCode.indexOf('function writingSchoolYear('),end=integrityCode.indexOf('async function writingMappingPage(',start);
+  if(start<0||end<start)throw Error('缺少旧写作预约的学段核对规则');
+  return '\nconst legacyWritingStageAssessment=(()=>{\nfunction text(value){return String(value??"").trim();}\n'+integrityCode.slice(start,end)+'\nreturn writingStageAssessment;\n})();\n';
+}

+ 21 - 12
scripts/sql/legacy-appointment-preparation.sql

@@ -1,9 +1,9 @@
--- Restore imported appointments only from an existing, unambiguous enrollment.
+-- Restore imported appointments from an unambiguous enrollment or verified writing stage.
 -- No balances, teaching status, lesson records or existing pay snapshots are changed.
 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;notes_legacy boolean=false;legacy_trial boolean=false;existing "CourseCreditReservation";before_data jsonb;changed boolean=false;
+ notes_legacy boolean=false;legacy_trial 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;
@@ -27,21 +27,30 @@ BEGIN
  -- The legacy lesson type identifies a trial; the textbook profile describes its words, not its price.
  legacy_trial=a.dslx::text='3' AND (category IS NULL OR category='trial');
  IF legacy_trial THEN category='trial'; END IF;
- -- The legacy writing course has no vocabulary binding; only a per-appointment human confirmation is sufficient.
+ -- The legacy writing course has no vocabulary binding; the appointment must carry a confirmed stage and its evidence.
  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 OR (legacy_trial AND cb."categoryKey"='word' AND cb."enrollmentStatus"='active'))
- AND (NULLIF(a."bindingId",'') IS NULL OR cb."objectId"=a."bindingId");
- IF matches<>1 THEN RAISE EXCEPTION '预约缺少唯一开课记录,请由管理员确认分类';END IF;
- SELECT cb.* INTO b 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 OR (legacy_trial AND cb."categoryKey"='word' AND cb."enrollmentStatus"='active'))
- AND (NULLIF(a."bindingId",'') IS NULL OR cb."objectId"=a."bindingId") FOR UPDATE OF cb;
+ -- A textbook may have separate trial and word enrollments for the same student.
+ -- Respect an explicit binding and resolve an unbound trial from one locked query.
+ BEGIN
+  SELECT cb.* INTO STRICT b 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 OR (legacy_trial AND cb."categoryKey"='word' AND cb."enrollmentStatus"='active'))
+  AND (NULLIF(a."bindingId",'') IS NULL OR cb."objectId"=a."bindingId")
+  AND (NOT legacy_trial OR NULLIF(a."bindingId",'') IS NOT NULL OR (cb."categoryKey"='trial' AND cb."enrollmentStatus"='active')
+    OR NOT EXISTS(SELECT 1 FROM "CourseBinding" trial
+      JOIN "CommonModel" trial_common ON trial_common.company=trial.company AND trial_common."modelId"=58 AND trial_common."itemId"=trial.id
+      WHERE trial.company=co AND trial.yhid::text=a.szyh::text AND trial.kcid::text=course AND trial_common.status=99
+      AND trial."categoryKey"='trial' AND trial."enrollmentStatus"='active'))
+  FOR UPDATE OF cb;
+ EXCEPTION WHEN no_data_found OR too_many_rows THEN
+  RAISE EXCEPTION '预约缺少唯一开课记录,请由管理员确认分类';
+ END;
  category=COALESCE(category,NULLIF(b."categoryKey",''));category_name=CASE WHEN legacy_trial THEN '体验课' ELSE b."categoryName" END;
  IF category IS NULL THEN
   SELECT min(p."categoryKey") INTO category FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true HAVING count(DISTINCT p."categoryKey")=1;

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

@@ -1,4 +1,4 @@
-import{test,before,after}from'node:test';import assert from'node:assert/strict';import{readFileSync}from'node:fs';import vm from'node:vm';import{PGlite}from'@electric-sql/pglite';import{legacyAppointmentDefaults}from'../legacy-appointment-transform.mjs';
+import{test,before,after}from'node:test';import assert from'node:assert/strict';import{readFileSync}from'node:fs';import vm from'node:vm';import{PGlite}from'@electric-sql/pglite';import{legacyAppointmentDefaults,legacyWritingStageSupport}from'../legacy-appointment-transform.mjs';
 const db=new PGlite(),source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8'),ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],ctx=vm.createContext({});vm.runInContext(legacyAppointmentDefaults(ops),ctx);const seeds=JSON.stringify(ctx.legacyAppointmentSeedRules());
 const q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
 const prepare=async(id,actor='teacher')=>(await one('SELECT xs_prepare_legacy_appointment(\'co\',$1,$2,$3::jsonb) r',[id,actor,seeds])).r;
@@ -78,6 +78,38 @@ test('旧体验课可复用已开通的普通词库,不重写词库分类且
   const b=await one(`SELECT "categoryKey","enrollmentStatus" FROM "CourseBinding" WHERE "objectId"='binding'`);assert.equal(b.categoryKey,'word');assert.equal(b.enrollmentStatus,'active');
  }finally{await db.exec('ROLLBACK');}
 });
+test('同词库同时开通体验课和普通课时,旧体验预约优先使用体验课开课',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":3}' WHERE "objectId"='student';
+  UPDATE "CourseBinding" SET "categoryKey"='word',"categoryName"='单词课',"enrollmentStatus"='active' WHERE "objectId"='binding';
+  INSERT INTO "CourseBinding" VALUES('trial-binding','co',2,'20','100','trial','体验课','active','schedule-textbook:trial-binding',now());
+  INSERT INTO "CommonModel"("objectId",company,"modelId","itemId",status) VALUES('trial-binding-common','co',58,2,99);`);
+  const id=await fixture({dslx:'3'});
+  assert.equal((await prepare(id)).bindingId,'trial-binding');
+  assert.equal((await prepare(id)).changed,false);
+  const a=await one('SELECT "bindingId","courseCategoryKey","creditCostsSnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]);
+  assert.equal(a.bindingId,'trial-binding');
+  assert.equal(a.courseCategoryKey,'trial');
+  assert.deepEqual(a.creditCostsSnapshot.map(c=>[c.account,Number(c.amount)]),[['UserExp',1]]);
+  assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1);
+  const b=await one('SELECT "categoryKey","enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=\'binding\'');
+  assert.deepEqual(b,{categoryKey:'word',enrollmentStatus:'active'});
+  const explicit=await fixture({dslx:'3',bindingId:'binding'});
+  assert.equal((await prepare(explicit)).bindingId,'binding');
+
+  await db.exec(`INSERT INTO "CourseBinding" VALUES('other-trial','co',3,'20','100','trial','体验课','pending','legacy:other-trial',now());
+   INSERT INTO "CommonModel"("objectId",company,"modelId","itemId",status) VALUES('other-trial-common','co',58,3,99);`);
+  const uniqueActive=await fixture({dslx:'3'});
+  assert.equal((await prepare(uniqueActive)).bindingId,'trial-binding');
+
+  await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='active' WHERE "objectId"='other-trial'`);
+  const duplicate=await fixture({dslx:'3'});
+  await db.exec('SAVEPOINT ambiguous_trial');
+  await assert.rejects(prepare(duplicate),/唯一/);
+  await db.exec('ROLLBACK TO SAVEPOINT ambiguous_trial');
+  assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[duplicate])).n,0);
+ }finally{await db.exec('ROLLBACK');}
+});
 test('体验课时不足时拒绝预留,且不激活原有开课记录',async()=>{
  await db.exec('BEGIN');try{
   await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":0,"UserPoint":30,"Purse":30,"SilverCoin":30}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding'`);
@@ -95,3 +127,99 @@ test('先准备体验课再准备同教材普通课,各自使用正确课时
   assert.equal(a.courseCategoryKey,'word');assert.deepEqual(a.creditCostsSnapshot.map(c=>c.account).sort(),['SilverCoin','UserPoint']);
  }finally{await db.exec('ROLLBACK');}
 });
+
+test('旧阅读写作开课以一致的初三档案和本学年记录补齐;旧系统已开始的预约不再重复准备',async()=>{
+ await db.exec('BEGIN');
+ try {
+  await db.exec(`ALTER TABLE "CommonModel" ADD title text,ADD subtitle text;ALTER TABLE "_User" ADD "realName" text,ADD nickname text,ADD username text,ADD "legacyGroupId" numeric;
+   ALTER TABLE "CommonModel" ADD "teachingManaged" boolean,ADD "updatedAt" timestamptz;
+   UPDATE "_User" SET "legacyGroupId"=3 WHERE "objectId"='teacher';
+   ALTER TABLE "CourseAppointment" ADD IF NOT EXISTS kssj text,ADD IF NOT EXISTS jssj text;
+   CREATE TABLE IF NOT EXISTS "LessonRecord"(company text,yyds text,pf numeric,pjnr text,pldp text,"createdAt" timestamptz);
+   CREATE TABLE IF NOT EXISTS "LessonReview"(company text,"appointmentGeneralId" text,score numeric,comment text,"submittedAt" timestamptz);
+   CREATE TABLE "LessonCompletionReport"(company text,"appointmentGeneralId" text);
+   INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyUserData",nickname) VALUES('writing-student','co',25,'{"SilverCoin":10,"UserPoint":10}','测试学员初三');`);
+  const install=async (file,names)=>{
+   const sql=readFileSync(new URL('../sql/'+file,import.meta.url),'utf8');
+   for(const name of names)await db.exec(sql.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0]);
+  };
+  await install('member-enrollment.sql',['xs_validate_appointment','xs_start_appointment']);
+  await install('lesson-workflow.sql',['xs_start_appointment_for_actor']);
+  await install('payroll-integrity.sql',['xs_payroll_writing_mapping']);
+  const gateway=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");
+  const tx=async fn=>{
+   await db.exec('SAVEPOINT writing_prepare');
+   try { const result=await fn({one,oneOrNone:one,query:q});await db.exec('RELEASE SAVEPOINT writing_prepare');return result; }
+   catch(error){await db.exec('ROLLBACK TO SAVEPOINT writing_prepare');await db.exec('RELEASE SAVEPOINT writing_prepare');throw error;}
+  };
+  let legacyStatus='0', legacyStudent='25', reads=0, oldRows=1;
+  const fetch=async url=>{
+   const params=new URL(url).searchParams;
+   assert.equal(params.get('action'),'content_list');
+   assert.equal(params.get('modelId'),'54');
+   assert.equal(params.get('ids'),order.GeneralID);
+   reads++;
+   const row={GeneralID:order.GeneralID,NodeID:29,bxrq:order.bxrq,szyh:legacyStudent,pl:order.pl,kcid:order.kcid,dslx:order.dslx,jffs:order.jffs,yysj:order.yysj,dszt:legacyStatus};
+   return {ok:true,json:async()=>({retcode:1,result:JSON.stringify(oldRows?[row]:[]),page:{itemCount:oldRows,pageCount:oldRows}})};
+  };
+  const c=vm.createContext({Psql:{one:(sql,args)=>tx(db=>db.one(sql,args)),oneOrNone:one,query:q,tx},fetch,URLSearchParams,AbortSignal});
+  const integrity=readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8');
+  vm.runInContext(gateway+legacyAppointmentDefaults(ops)+legacyWritingStageSupport(integrity)+readFileSync(new URL('../cloud/legacy-appointment.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),c);
+  vm.runInContext(readFileSync(new URL('../cloud/member-enrollment-app.js',import.meta.url),'utf8'),c);
+  c.appLegacyConfig=async()=>({url:'https://legacy.test/api',apiId:'fixture-id',apiKey:'fixture-key'});
+  const teacher={id:'teacher',get:k=>({company:{id:'co'},legacyUserId:30,legacyGroupId:3}[k])};
+  const confirmed=await fixture({szyh:'25',kcid:'398',yykcid:'398',dszt:'30',courseCategoryKey:'middle_writing',categoryEvidence:'同一学年审核初中'});
+  const appointment=async()=>{
+   const objectId=await fixture({szyh:'25',kcid:'398',yykcid:'398'});
+   await q('UPDATE "CommonModel" SET title=$2,subtitle=$3 WHERE "objectId"=$1',[objectId,'测试学员初三','阅读写作']);
+   const a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[objectId.slice(0,-1)]);
+   return {...a,objectId,GeneralID:String(serial)};
+  };
+  let order=await appointment();
+  const detail=await c.lessonOrderCapabilities(order,teacher);
+  assert.equal(detail.courseCategoryKey,'middle_writing',detail.blockedReason);
+  assert.equal(detail.creditHoldStatus,'ready');
+  assert.deepEqual(Array.from(detail.allowedActions),['start']);
+  assert.match((await one('SELECT "categoryEvidence" FROM "CourseAppointment" WHERE id=$1',[order.id])).categoryEvidence,/初中/);
+  await c.lessonOrderCapabilities(order,teacher);
+  assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[order.objectId])).n,1);
+  assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE id=$1',[order.id])).dszt,'0');
+
+  // Recheck the live source on the actual start command after the page had offered Start.
+  legacyStatus='11';
+  await assert.rejects(c.enrollmentOrderTransition({},teacher,order,order.GeneralID,10),/旧系统.*完课/);
+  assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE id=$1',[order.id])).dszt,'0');
+
+  order=await appointment();
+  for(const status of ['10','11','30']){
+   legacyStatus=status;
+   const blocked=await c.lessonOrderCapabilities(order,teacher);
+   assert.match(blocked.blockedReason,/旧系统.*(上课|完课)/);
+   assert.equal(blocked.allowedActions.length,0);
+  }
+  legacyStatus='0';legacyStudent='21';
+  const wrongStudent=await c.lessonOrderCapabilities(order,teacher);
+  assert.match(wrongStudent.blockedReason,/旧系统.*核对/);
+  legacyStudent='25';oldRows=0;
+  assert.match((await c.lessonOrderCapabilities(order,teacher)).blockedReason,/旧系统.*核对|核对旧系统/);
+  oldRows=1;
+  await db.exec(`UPDATE "_User" SET nickname='测试学员高一' WHERE "objectId"='writing-student'`);
+  const conflict=await c.lessonOrderCapabilities(order,teacher);
+  assert.match(conflict.blockedReason,/学段证据冲突/);
+  assert.equal((await one('SELECT "courseCategoryKey" FROM "CourseAppointment" WHERE id=$1',[order.id])).courseCategoryKey,null);
+  assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[order.objectId])).n,0);
+  await db.exec(`UPDATE "_User" SET nickname='测试学员初三',"legacyUserData"='{}' WHERE "objectId"='writing-student'`);
+  assert.match((await c.lessonOrderCapabilities(order,teacher)).blockedReason,/可用课时不足/);
+  assert.equal((await one('SELECT "courseCategoryKey" FROM "CourseAppointment" WHERE id=$1',[order.id])).courseCategoryKey,null);
+  assert.equal((await one('SELECT count(*)::int n FROM audits WHERE target=$1',[order.objectId])).n,0);
+  await db.exec(`UPDATE "_User" SET "legacyUserData"='{"SilverCoin":10,"UserPoint":10}' WHERE "objectId"='writing-student'`);
+  const changeEvidence=async evidence=>q('UPDATE "CourseAppointment" SET "categoryEvidence"=$1 WHERE "objectId"=$2',[evidence,confirmed.slice(0,-1)]);
+  await changeEvidence(null);
+  assert.match((await c.lessonOrderCapabilities(order,teacher)).blockedReason,/缺少一致的本学年已确认/);
+  await changeEvidence('同一学年审核初中');
+  const started=await c.enrollmentOrderTransition({},teacher,order,order.GeneralID,10);
+  assert.equal(started.status,10);
+  assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE id=$1',[order.id])).dszt,'10');
+  assert.ok(reads>=5);
+ }finally{await db.exec('ROLLBACK');}
+});