Просмотр исходного кода

fix(teaching): prepare imported appointments with verified enrollment

彭峰 7 часов назад
Родитель
Сommit
6b6a8fcd60

+ 37 - 0
docs/migration/legacy-appointment-preparation-2026-10-12.md

@@ -0,0 +1,37 @@
+# 旧排课在教师端无法开始的修复验收
+
+2026-10-12,北京时间。生产环境 `server.xiaoshu.pro`,应用 `7pIbDBJmKx_main`。
+
+## 原因
+
+预约主附表已导入,不等于新系统的开课、分类、课时预留链路已成立。截图预约的既有开课记录仍处于 pending,预约缺少 bindingId、creditCostsSnapshot 等字段,因此单元接口拒绝访问,详情不提供 start 权限。原历史预留预览和提交还按预约的具体钟点判断过期,漏掉当天已过预约钟点、但仍应可开始的课次。
+
+另发现一名学员的新系统档案完全缺少四个课时余额字段,形成“没有余额”的误判。不是将旧余额覆盖到已有余额的场景。
+
+## 修复行为
+
+- App 详情在预约当天对缺凭据的旧预约进行原子核对;开始入口也会核对,不能依赖一次旧详情的结果。
+- SQL 再验帐套、实际操作者与预约师生关系;仅处理未开始、未取消、未交给新教学流程管理的历史预约。
+- 仅使用同学员、同课程、唯一的已有开课记录。重复、回收、缺内容、未确认分类、课程编号冲突保持阻塞,不新增或猜测开课。
+- 旧开课 pending/null 仅在分类已有可靠确认、课程内容有效且余额足够时恢复。空分类可从唯一已确认的非写作分类补齐;全局写作分类不替代逐人确认。
+- 复用管理端默认规则,正式保存的版本覆盖默认版本。按课程日期匹配;已有工资快照保留。
+- 开课状态、快照、预留、审计在同一事务中完成。补建预留不扣余额、不启动课程、不生成上课记录。失败整体回滚,重试不重复预留或审计。
+- 历史预留预览/提交统一按北京时间预约日判断有效期。
+
+## 生产验收
+
+- 当天有老师、未开始的旧预约共 10 节,修复后 8 节具备绑定、有效快照及唯一预留,状态保持未开始。
+- 截图预约以真实教师现有会话调用 e_order_detail,返回 allowedActions=[start]、validToday=true、blockedReason 为空。node_list 返回 1 个单元、3,087 个词条。
+- 使用生产开始事务做回滚验证,事务中可到达学习中,回滚后仍未开始;未替老师实际开始或结课。
+- 缺余额档案经旧 user_list 两次实时核对,且新库无该学员课时交易、四类流水或新流程完课;只恢复原本缺失的字段:30分钟课时40、60分钟课时0、体验课时0、陪练时长20。保留其余档案字段,审计入库,重复恢复返回 alreadyRestored。
+- 其余学员余额未改动。8条预约凭据修复审计、1条缺余额恢复审计。
+- 剩余2节同一词表课程:旧预约为普通60分钟课,新系统此前人工确认的开课分类为体验课,体验课时为0。保持阻塞,已请求用户确认应按普通单词课还是体验课,不自动改分类或充值。
+- App、运营主网关、运营V3均已部署,并逐字核对线上代码与本地打包代码一致。migration_status版本为 `2026-10-12-legacy-appointment-v3`。
+
+## 验证与发布
+
+70项相关Node/PGlite测试通过,涵盖当天过钟点、权限和空教师ID、跨帐套、取消、过期、重复开课、课时不足、快照保留、详情权限、审计失败回滚、缺失非写作分类等。11个云函数定义语法校验通过。
+
+窄迁移入口:`node scripts/migrate-legacy-appointment-preparation.mjs --apply`。仅安装受影响的两个SQL例程;使用已有帐套管理员会话,备份部署前后的定义,清理临时函数,不创建测试用户、不执行历史全量回填。凭据从执行环境提供,备份目录使用 `XIAOSHU_LEGACY_PREPARE_REPORT_DIR`。
+
+私有证据与备份:`/Users/mac/.local/state/xiaoshu/appointment-repair/2026-10-12/`。含回滚验收、实际教师接口、生产数据回读、旧系统证据、函数部署前后代码等,均不提交Git。未以伪造检查点或成功日志放行工资结算,也未改变夜间同步的验收状态。

+ 9 - 0
scripts/cloud/legacy-appointment.js

@@ -0,0 +1,9 @@
+async function prepareLegacyAppointment(order,current) {
+  // SQL rechecks ownership. Request parameters cannot supply the pricing rules.
+  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;
+  } catch(error) {
+    return {blockedReason:String(error.message||error).replace(/^.*ERROR:\s*/,'')};
+  }
+}

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

@@ -42,8 +42,15 @@ 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 state = await Psql.oneOrNone('SELECT a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",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', [DEFAULT_COMPANY_ID,id]);
+  const stateSql='SELECT c."objectId",a."sourceKey",a.kcid,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='';
+  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')){
+    const prepared=await prepareLegacyAppointment({...order,objectId:state.objectId},current);
+    preparationReason=prepared.blockedReason||'';
+    if(!preparationReason)state=await Psql.oneOrNone(stateSql,[DEFAULT_COMPANY_ID,id]);
+  }
   const lesson = await Psql.oneOrNone('SELECT pf,pjnr,pldp FROM "LessonRecord" WHERE company=$1 AND yyds::text=$2 ORDER BY "createdAt" LIMIT 1',[DEFAULT_COMPANY_ID,id]);
   const review = await Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"=$2',[DEFAULT_COMPANY_ID,id]);
   const teacher = uid === number(order.pl), student = uid === number(order.szyh), actions = [];
@@ -51,7 +58,7 @@ async function lessonOrderCapabilities(order, current) {
   if (order.cancelled) blockedReason = '预约已取消';
   else if (teacher) {
     if (status === 0 && !state.validToday) blockedReason = '课程仅在预约当天可开始,其他日期请联系管理员改期';
-    else if (status === 0 && (!state.bindingId || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = '开课记录或课时凭据尚未核实,请联系管理员';
+    else if (status === 0 && (preparationReason||!state.bindingId || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = preparationReason||state.creditHoldReason||'开课记录或课时凭据尚未核实,请联系管理员';
     else if (status === 0) actions.push('start');
     if (status === 10) actions.push('continue','complete');
     if ([11,20].includes(status)) actions.push('complete');

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

@@ -1,6 +1,7 @@
 async function enrollmentOrderTransition(input,current,order,orderId,status){
- const row=await Psql.oneOrNone('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND "modelId"=54 AND "generalId"::text=$2',[DEFAULT_COMPANY_ID,String(orderId)]);if(!row)fail(404,'预约不存在');
+ 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);}
  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));}
  return{GeneralID:orderId,status,previousStatus:number(order.dszt)};
 }

Разница между файлами не показана из-за своего большого размера
+ 2 - 0
scripts/deploy-admin-functions.mjs


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

@@ -0,0 +1,6 @@
+// Reuse the operations defaults instead of maintaining a second mobile price list.
+export function legacyAppointmentDefaults(operationsCode) {
+  const start=operationsCode.indexOf('const TEACHING_STAGE_DEFAULTS='),end=operationsCode.indexOf('async function teachingCategoryRows(',start);
+  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';
+}

+ 18 - 0
scripts/migrate-legacy-appointment-preparation.mjs

@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+// Install only the affected routines, preserving their deployed definitions.
+import{readFileSync,writeFileSync,mkdirSync}from'node:fs';import{randomUUID}from'node:crypto';
+const names=['xs_prepare_legacy_appointment','xs_payroll_reservations'];
+const reservation=readFileSync(new URL('./sql/payroll-integrity.sql',import.meta.url),'utf8').match(/CREATE OR REPLACE FUNCTION xs_payroll_reservations\([\s\S]*?END \$\$;/)[0];
+const sql=readFileSync(new URL('./sql/legacy-appointment-preparation.sql',import.meta.url),'utf8')+'\n'+reservation;
+if(!process.argv.includes('--apply')){console.log('旧预约开课凭据核对事务已就绪;--apply 仅安装函数,不改变预约或余额。');process.exit(0);}
+const key=process.env.XIAOSHU_MASTER_KEY,out=process.env.XIAOSHU_LEGACY_PREPARE_REPORT_DIR,base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
+if(!key||!out)throw Error('需要部署凭据与私有备份目录');mkdirSync(out,{recursive:true,mode:0o700});
+async function api(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':key},...(body?{body:JSON.stringify(body)}:{})});const j=await r.json();if(!r.ok||j.error)throw Error(j.error||'部署请求失败');return j;}
+const sessions=(await api('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results,s=sessions.find(s=>s.user?.adminRoleKey==='super-admin'&&!s.user?.isDisabled&&s.user?.company?.objectId===co&&new Date(s.expiresAt?.iso)>new Date());if(!s)throw Error('缺少有效的帐套管理员会话');
+const path='xiaoshu/system/prepare-legacy-'+randomUUID(),expiry=Date.now()+600000;let id;
+try{
+ const code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(s.user.objectId)}||Date.now()>${expiry})return response.status(403).json({success:false});try{if((request.body.params||{}).apply)await Psql.tx(async t=>t.none(${JSON.stringify(sql)}));return response.json({success:true,data:await Psql.query('SELECT proname,pg_get_functiondef(oid) AS definition FROM pg_proc WHERE proname=ANY($1::text[])',[${JSON.stringify(names)}])});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ new Function(code);id=(await api('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ async function call(apply){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':app},body:JSON.stringify({token:s.sessionToken,params:{apply}})}),j=await r.json();if(!r.ok||!j.success)throw Error(j.message||'安装失败');return j.data;}
+ const stamp=Date.now();writeFileSync(out+'/routine-before-'+stamp+'.json',JSON.stringify(await call(false),null,2),{mode:0o600});const after=await call(true);if(after.length!==names.length)throw Error('函数安装未确认');writeFileSync(out+'/routine-after-'+stamp+'.json',JSON.stringify(after,null,2),{mode:0o600});console.log('Installed legacy preparation and whole-day reservation routines; no appointments changed.');
+}finally{if(id){await api('/classes/Function/'+id,'DELETE');const remaining=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:id}),count:'1',limit:'0'}));if(remaining.count)throw Error('临时函数未清理');}}

+ 84 - 0
scripts/sql/legacy-appointment-preparation.sql

@@ -0,0 +1,84 @@
+-- Restore imported appointments only from an existing, unambiguous enrollment.
+-- 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;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;
+ SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "objectId"=aid FOR UPDATE;
+ SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId";
+ IF op."objectId" IS NULL OR COALESCE(op."isDisabled",false) OR COALESCE(to_jsonb(op)->>'isDeleted','false')='true'
+ OR NOT (COALESCE(op."legacyUserId"::text IN (a.pl::text,a.szyh::text),false) OR COALESCE(to_jsonb(op)->>'adminRoleKey','') IN ('super-admin','ops-manager')) THEN RAISE EXCEPTION '无权核对该预约';END IF;
+ IF a."objectId" IS NULL OR c.status=-2 OR a.dszt IS DISTINCT FROM '0' THEN RAISE EXCEPTION '仅能补齐未开始且未取消的预约';END IF;
+ IF COALESCE(c."sourceKey",'') !~ '^legacy[:\-]' OR COALESCE(a."sourceKey",'') !~ '^legacy[:\-]' OR COALESCE(a."teachingManaged",false) THEN RAISE EXCEPTION '仅能核对导入的旧预约';END IF;
+ day=xs_appointment_day(a.bxrq,a.yysj);
+ IF day IS NULL OR day<(now() AT TIME ZONE 'Asia/Shanghai')::date THEN RAISE EXCEPTION '预约已过期,请联系管理员改期';END IF;
+ 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) OR COALESCE(to_jsonb(u)->>'isDeleted','false')='true' THEN RAISE EXCEPTION '学员不存在或已停用';END IF;
+ SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;
+ before_data=to_jsonb(a);
+ -- A conflicting legacy course pair can also reflect a newer manual edit.
+ -- 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",'');
+ 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)
+ 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)
+ AND (NULLIF(a."bindingId",'') IS NULL OR cb."objectId"=a."bindingId") FOR UPDATE OF cb;
+ category=COALESCE(category,NULLIF(b."categoryKey",''));category_name=b."categoryName";
+ 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;
+  IF category NOT IN ('word','trial','word_self_study') THEN RAISE EXCEPTION '语法写作开课需按学员确认';END IF;
+ END IF;
+ category_name=COALESCE(NULLIF(category_name,''),CASE category WHEN 'word' THEN '单词课' WHEN 'trial' THEN '体验课' WHEN 'word_self_study' THEN '单词自学课' WHEN 'primary_writing' THEN '小学语法写作课' WHEN 'middle_writing' THEN '中学语法写作课' WHEN 'high_writing' THEN '高中语法写作课' END);
+ IF category IS NULL OR category NOT IN ('word','trial','word_self_study','primary_writing','middle_writing','high_writing') THEN RAISE EXCEPTION '开课分类尚未确认';END IF;
+ 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;
+ 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;
+ -- Stored versions suppress the corresponding defaults even when disabled.
+ WITH candidates AS (
+ SELECT to_jsonb(r)||jsonb_build_object('isDefault',false) AS r FROM "TeachingCompensationRule" r WHERE r.company=co
+ UNION ALL SELECT s||jsonb_build_object('isDefault',true) FROM jsonb_array_elements(seed_rules) s WHERE NOT EXISTS(
+ SELECT 1 FROM "TeachingCompensationRule" r WHERE r.company=co AND r."categoryKey"=s->>'categoryKey' AND r."durationMinutes"=(s->>'durationMinutes')::numeric AND r."deliveryMode"=s->>'deliveryMode' AND r."effectiveFrom"=s->>'effectiveFrom'))
+ SELECT r INTO rule FROM candidates WHERE COALESCE((r->>'active')::boolean,true) AND r->>'categoryKey'=category AND (r->>'durationMinutes')::numeric=minutes AND r->>'deliveryMode'=mode AND r->>'effectiveFrom'<=day::text ORDER BY r->>'effectiveFrom' DESC,(r->>'isDefault')::boolean,(r->>'revision')::numeric DESC NULLS LAST LIMIT 1;
+ costs=COALESCE(a."creditCostsSnapshot"::jsonb,'[]');
+ IF jsonb_array_length(costs)=0 THEN costs=rule->'creditCosts';END IF;
+ IF rule IS NULL OR costs IS NULL OR jsonb_array_length(costs)=0 THEN RAISE EXCEPTION '缺少适用的工资与课时规则';END IF;
+ IF (SELECT count(*)<>count(DISTINCT value->>'account') FROM jsonb_array_elements(costs)) THEN RAISE EXCEPTION '课时凭据含重复账户,请管理员核实';END IF;
+ IF NULLIF(a."creditHoldStatus",'') IS NOT NULL AND a."creditHoldStatus" NOT IN ('ready','insufficient') THEN RAISE EXCEPTION '历史预约已标记待处理:%',a."creditHoldStatus";END IF;
+ FOR cost IN SELECT value FROM jsonb_array_elements(costs) LOOP
+  IF COALESCE(cost->>'account','') NOT IN ('Purse','SilverCoin','UserExp','UserPoint') OR COALESCE((cost->>'amount')::numeric,0)<=0 OR (cost->>'amount')::numeric::text IN ('NaN','Infinity','-Infinity') THEN RAISE EXCEPTION '课时凭据无效';END IF;
+  IF (u."legacyUserData"->>(cost->>'account'))::numeric::text IN ('NaN','Infinity','-Infinity') THEN RAISE EXCEPTION '课时余额无效,请管理员核实';END IF;
+  IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId"::numeric,cost->>'account',aid)<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足:%',cost->>'account';END IF;
+ 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
+  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
+  UPDATE "CourseAppointment" SET "bindingId"=b."objectId",kcid=course,"courseCategoryKey"=category,"courseCategoryName"=COALESCE(NULLIF("courseCategoryName",''),category_name),
+   "durationMinutes"=minutes,"deliveryMode"=mode,"teachingRuleId"=COALESCE(NULLIF("teachingRuleId",''),rule->>'objectId'),"teacherPaySnapshot"=COALESCE("teacherPaySnapshot",(rule->>'teacherPay')::numeric),
+   "creditCostsSnapshot"=costs,"creditHoldStatus"='ready',"creditHoldReason"='',"updatedAt"=now() WHERE "objectId"=a."objectId";changed=true;
+ END IF;
+ IF existing."objectId" IS NULL THEN
+  PERFORM xs_insert('CourseCreditReservation',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','legacy-prepare:'||aid,'appointmentId',aid,'appointmentGeneralId',c."generalId",'studentId',u."legacyUserId",'ruleId',COALESCE(NULLIF(a."teachingRuleId",''),rule->>'objectId'),'creditCosts',costs,'state','reserved','reservedAt',now()));changed=true;
+ END IF;
+ IF changed THEN
+  PERFORM xs_payroll_audit(co,actor,'prepare-legacy-appointment',aid,'核对旧预约的有效开课与课时凭据',jsonb_build_object('appointment',before_data,'binding',to_jsonb(b)),jsonb_build_object('bindingId',b."objectId",'creditCosts',costs,'courseId',course));
+ END IF;
+ RETURN jsonb_build_object('changed',changed,'bindingId',b."objectId",'creditHoldStatus','ready');
+END $$;
+REVOKE ALL ON FUNCTION xs_prepare_legacy_appointment(text,text,text,jsonb) FROM PUBLIC;

+ 1 - 1
scripts/sql/payroll-integrity.sql

@@ -248,7 +248,7 @@ BEGIN
   IF NOT FOUND OR c."status"=-2 THEN RAISE EXCEPTION '预约已取消或不存在,请重新核对';END IF;
   SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"::text=c."itemId"::text FOR UPDATE;
   IF a."updatedAt" IS DISTINCT FROM (item->>'expectedUpdatedAt')::timestamptz OR a."dszt" NOT IN ('0','10') THEN RAISE EXCEPTION '预约已更新,请重新核对';END IF;
-  IF a."dszt"='0' AND xs_payroll_time(a."yysj")<now() THEN RAISE EXCEPTION '预约已经到期,请重新核对';END IF;
+  IF a."dszt"='0' AND (xs_appointment_day(a."bxrq",a."yysj") IS NULL OR xs_appointment_day(a."bxrq",a."yysj")<(now() AT TIME ZONE 'Asia/Shanghai')::date) THEN RAISE EXCEPTION '预约已经到期,请重新核对';END IF;
   SELECT * INTO existing FROM "CourseCreditReservation" WHERE "company"=co AND "appointmentId"=c."objectId" AND "state" IN ('reserved','consumed') LIMIT 1;
   IF FOUND THEN CONTINUE;END IF;
   SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;

+ 52 - 0
scripts/tests/legacy-appointment-preparation.test.mjs

@@ -0,0 +1,52 @@
+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';
+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;
+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 "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);
+ CREATE TABLE "TeachingCompensationRule"(company text,"objectId" text,"categoryKey" text,"durationMinutes" numeric,"deliveryMode" text,"effectiveFrom" text,active boolean,"teacherPay" numeric,"creditCosts" jsonb,revision numeric);
+ CREATE TABLE "CourseCreditReservation"("objectId" text primary key,company text,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"appointmentId" text unique,"appointmentGeneralId" numeric,"studentId" numeric,"ruleId" text,"creditCosts" jsonb,state text,"reservedAt" timestamptz);
+ CREATE TABLE "Node"(company text,"nodeId" numeric,"parentId" numeric,zstatus numeric);
+ CREATE TABLE audits(target text,before_data jsonb,after_data jsonb);
+ CREATE FUNCTION xs_payroll_audit(co text,actor text,action text,target text,reason text,before_data jsonb,after_data jsonb) RETURNS void LANGUAGE sql AS $$ INSERT INTO audits VALUES(target,before_data,after_data) $$;
+ INSERT INTO "_User" VALUES('teacher','co',30,'{}',false,false,null),('student','co',20,'{"Purse":100,"SilverCoin":100,"UserPoint":100}',false,false,null),('other','other',30,'{}',false,false,null),('stranger','co',31,'{}',false,false,null);
+ INSERT INTO "Node" VALUES('co',100,null,99),('co',101,null,99);
+ INSERT INTO "CommonModel" VALUES('word','co',52,null,1,99,null),('binding-common','co',58,1,10,99,'legacy:model:58:general:10');UPDATE "CommonModel" SET "itemId"=NULL WHERE "objectId"='word';
+ ALTER TABLE "CommonModel" ADD "nodeId" numeric;UPDATE "CommonModel" SET "nodeId"=100 WHERE "objectId"='word';
+ INSERT INTO "CourseBinding" VALUES('binding','co',1,'20','100','word','单词课','pending','legacy:model:58:general:10',now());
+ INSERT INTO "CourseTeachingProfile" VALUES('co',100,'word',true);`);
+ const enrollment=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');for(const name of ['xs_object_id','xs_insert','xs_appointment_day','xs_appointment_valid_at','xs_reserved','xs_course_size']){const def=enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0];await db.exec(def);}
+ await db.exec(readFileSync(new URL('../sql/legacy-appointment-preparation.sql',import.meta.url),'utf8'));
+});after(()=>db.close());
+let serial=0;async function fixture(values={}){const id='a'+(++serial),day=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;const r={objectId:id,company:'co',id:serial,szyh:'20',pl:'30',kcid:'100',yykcid:'100',dszt:'0',dslx:'2',jffs:'线上',bxrq:day,yysj:day+' 00:00',sourceKey:'legacy:model:54:general:'+serial,...values};await q('INSERT INTO "CourseAppointment" SELECT * FROM jsonb_populate_record(NULL::"CourseAppointment",$1::jsonb)',[JSON.stringify(r)]);await q('INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status,"sourceKey") VALUES($1,\'co\',54,$2,$2,99,$3)',[id+'c',serial,r.sourceKey]);return id+'c';}
+test('当天已过钟点的旧预约恢复唯一开课、工资快照和预留,重试不改余额或状态',async()=>{const id=await fixture(),before=await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\'');assert.equal((await prepare(id)).changed,true);assert.equal((await prepare(id)).changed,false);const a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]);assert.equal(a.bindingId,'binding');assert.equal(a.kcid,'100');assert.equal(a.dszt,'0');assert.equal(Number(a.teacherPaySnapshot),30);assert.equal(a.creditHoldStatus,'ready');assert.deepEqual(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\''),before);assert.equal((await one('SELECT count(*)::int n FROM audits WHERE target=$1',[id])).n,1);assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1);});
+test('越权、跨帐套、过期、取消、新系统预约及已开课不能自动修复',async()=>{const id=await fixture();for(const actor of ['stranger','other'])await assert.rejects(prepare(id,actor),/无权/);await assert.rejects(prepare(await fixture({pl:null}),'stranger'),/无权/);for(const changes of [{bxrq:'2000-01-01'},{dszt:'10'},{sourceKey:'schedule:new'},{kcid:'101'}])await assert.rejects(prepare(await fixture(changes)),/过期|未开始|旧预约|不一致/);const cancelled=await fixture();await q('UPDATE "CommonModel" SET status=-2 WHERE "objectId"=$1',[cancelled]);await assert.rejects(prepare(cancelled),/未取消/);});
+test('课时不足时整个事务回滚,不激活开课、不保存快照或审计',async()=>{await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='pending';UPDATE "_User" SET "legacyUserData"='{}' WHERE "objectId"='student'`);const id=await fixture();await assert.rejects(prepare(id),/不足/);assert.equal((await one('SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).enrollmentStatus,'pending');assert.equal((await one('SELECT "bindingId" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)])).bindingId,null);await db.exec(`UPDATE "_User" SET "legacyUserData"='{"Purse":100,"SilverCoin":100,"UserPoint":100}' WHERE "objectId"='student'`);});
+test('重复开课、回收开课、未确认课程分类不能猜测恢复',async()=>{const id=await fixture();await db.exec(`INSERT INTO "CourseBinding" SELECT 'duplicate',company,2,yhid,kcid,"categoryKey","categoryName","enrollmentStatus","sourceKey",now() FROM "CourseBinding" WHERE "objectId"='binding';INSERT INTO "CommonModel"("objectId",company,"modelId","itemId",status) VALUES('duplicate-common','co',58,2,99)`);await assert.rejects(prepare(id),/唯一/);await db.exec(`DELETE FROM "CourseBinding" WHERE "objectId"='duplicate';DELETE FROM "CommonModel" WHERE "objectId"='duplicate-common';UPDATE "CourseBinding" SET "enrollmentStatus"='recycled'`);await assert.rejects(prepare(id),/唯一/);await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='pending';UPDATE "CourseTeachingProfile" SET confirmed=false`);await assert.rejects(prepare(id),/分类/);await db.exec(`UPDATE "CourseTeachingProfile" SET confirmed=true`);});
+test('新系统已有工资快照保留,正式规则覆盖默认版本,禁用版本不能回退同版本默认价',async()=>{await db.exec(`INSERT INTO "TeachingCompensationRule" VALUES('co','custom','word',60,'online','2026-09-09',true,35,'[{"account":"SilverCoin","amount":1},{"account":"UserPoint","amount":1}]',1)`);const id=await fixture({teacherPaySnapshot:37});await prepare(id);assert.equal(Number((await one('SELECT "teacherPaySnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)])).teacherPaySnapshot),37);const next=await fixture();await prepare(next);assert.equal(Number((await one('SELECT "teacherPaySnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[next.slice(0,-1)])).teacherPaySnapshot),35);});
+test('教师详情自动核对后返回开始权限与实际绑定;学习单元采用同一开课记录',async()=>{
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD kssj text,ADD jssj text;CREATE TABLE "LessonRecord"(company text,yyds text,pf numeric,pjnr text,pldp text,"createdAt" timestamptz);CREATE TABLE "LessonReview"(company text,"appointmentGeneralId" text,score numeric,comment text,"submittedAt" timestamptz);`);
+ const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");
+ const c=vm.createContext({Psql:{one,oneOrNone:one,query:q}});vm.runInContext(app+legacyAppointmentDefaults(ops)+readFileSync(new URL('../cloud/legacy-appointment.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),c);
+ const id=await fixture(),teacher={id:'teacher',get:k=>({company:{id:'co'},legacyUserId:30}[k])};
+ const detail=await c.lessonOrderCapabilities({GeneralID:String(serial),objectId:id,pl:'30',szyh:'20',dszt:'0',kcid:'100'},teacher);
+ assert.equal(detail.bindingId,'binding');assert.equal(detail.kcid,'100');assert.deepEqual(JSON.parse(JSON.stringify(detail.allowedActions)),['start']);assert.equal(detail.blockedReason,'');
+ const binding=await one('SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=$1',[detail.bindingId]);assert.equal(binding.enrollmentStatus,'active');
+});
+test('审计失败回滚已激活的开课、课时预留和快照,修复可重试',async()=>{
+ await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='pending';CREATE FUNCTION reject_audit() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN RAISE EXCEPTION 'audit unavailable';END$$;CREATE TRIGGER reject_audit BEFORE INSERT ON audits FOR EACH ROW EXECUTE FUNCTION reject_audit()`);
+ const id=await fixture();await assert.rejects(prepare(id),/audit unavailable/);
+ assert.equal((await one('SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).enrollmentStatus,'pending');
+ assert.equal((await one('SELECT "bindingId" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)])).bindingId,null);
+ assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,0);
+ await db.exec('DROP TRIGGER reject_audit ON audits');assert.equal((await prepare(id)).changed,true);
+});
+test('新导入的空分类开课只补明确的非写作分类;全局写作映射不能替代逐人确认',async()=>{
+ 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'`);
+});

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

@@ -22,7 +22,7 @@ before(async()=>{
  CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
  for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
  await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
- await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "creditHoldReason" text,ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
  ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
  await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
  CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);

Некоторые файлы не были показаны из-за большого количества измененных файлов