member-enrollment.js 14 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. // Appended to the operations gateway; all writes remain behind its authentication.
  2. async function enrollmentSql(sql, args) {
  3. try { return await Psql.one(sql,args); }
  4. catch(error) { if(/does not exist/i.test(text(error.message))) fail(503,'用户开课数据迁移尚未完成'); fail(409,text(error.message).replace(/^.*ERROR:\s*/,'')); }
  5. }
  6. async function creditAccounts(context,input,store=false) {
  7. const target=store?await scopedStoreObject(context,{...input,targetId:input.objectId||input.targetId}):await memberTarget(context,input,text(input.objectId||input.targetId));
  8. const uid=number(target.get('legacyUserId')),co=pointerId(target.get('company')),legacy=target.get('legacyUserData')||{},reserved=await reservedCreditsFor(context,input,uid);
  9. const accounts=Object.entries(TEACHING_CREDIT_LABELS).map(([account,label])=>({account,label,balance:number(legacy[account]),reserved:number(reserved[account]),available:number(legacy[account])-number(reserved[account])}));
  10. const transactions=await Psql.query('SELECT t."objectId",t."kind",t."account",t."amount",t."storeId",t."studentId",t."sourceTransactionId",t."operatorId",t."reason",t."payMethod",t."balances",t."createdAt",t."amount"-COALESCE((SELECT SUM(r."amount") FROM "CreditTransaction" r WHERE r."company"=t."company" AND r."sourceTransactionId"=t."objectId" AND r."kind"=\'refund\'),0) AS "unreturned" FROM "CreditTransaction" t WHERE t."company"=$1 AND '+(store?'t."storeId"':'t."studentId"')+'=$2 ORDER BY t."createdAt" DESC LIMIT 200',[co,uid]);
  11. const parent=store?null:await userByLegacyId(context,input,number(legacy.ParentUserID),2);
  12. return{accounts,transactions,storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
  13. }
  14. async function postCredits(context,input,payload,store=false) {
  15. if(store)requireSuperAdmin(context);else requireRole(context,['ops-manager']);
  16. const target=store?await scopedStoreObject(context,input):await memberTarget(context,input,text(input.targetId));
  17. const account=text(payload.account)||({1:'Purse',2:'SilverCoin',3:'UserExp',4:'UserPoint'})[number(payload.type)],kind=store?'fund':payload.direction==='deduct'?'refund':'transfer';
  18. const result=await enrollmentSql('SELECT xs_credit_post($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) AS result',[pointerId(target.get('company')),context.current.id,kind,target.id,account,number(payload.amount),text(payload.sourceTransactionId),reasonOf(input),text(payload.payMethod),text(input.idempotencyKey)]);
  19. const clean={...result.result};delete clean.request;return clean;
  20. }
  21. async function enrollmentCatalog(context,input) {
  22. const co=companyIdOf(context,input);
  23. // Course roots are direct children of the legacy academic-stage nodes, plus explicitly mapped content roots.
  24. const rows=await Psql.query(`WITH RECURSIVE roots AS (
  25. SELECT n.* FROM "Node" n WHERE n."company"=$1 AND (n."parentId" IN (8,9,10,11,12) OR EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p."company"=n."company" AND p."courseId"=n."nodeId"))) ,
  26. descendants AS (SELECT "nodeId" AS root,"nodeId",ARRAY["nodeId"] AS visited FROM roots UNION ALL SELECT d.root,n."nodeId",d.visited||n."nodeId" FROM descendants d JOIN "Node" n ON n."company"=$1 AND n."parentId"=d."nodeId" WHERE NOT n."nodeId"=ANY(d.visited) AND COALESCE(n."zstatus",99)=99),
  27. counts AS (SELECT d.root,COUNT(DISTINCT c."objectId") AS total FROM descendants d JOIN "CommonModel" c ON c."company"=$1 AND c."nodeId"=d."nodeId" AND c."modelId"=52 AND COALESCE(c."status",99)=99 GROUP BY d.root)
  28. SELECT n."nodeId",n."nodeName",n."parentId",n."zstatus",p."nodeName" AS parent,c.total FROM roots n LEFT JOIN "Node" p ON p."company"=$1 AND p."nodeId"=n."parentId" LEFT JOIN counts c ON c.root=n."nodeId" ORDER BY n."nodeName"`,[co]);
  29. return rows.map(row=>({courseId:number(row.nodeId),courseName:text(row.nodeName),stageKey:courseStageFromDatabase([row.nodeName,row.parent]).key,stageName:courseStageFromDatabase([row.nodeName,row.parent]).name,wordCount:number(row.total),active:number(row.zstatus,99)===99}));
  30. }
  31. async function enrollmentOptions(context,input) {
  32. const categories=await teachingCategoryRows(context,input),categoryKey=text(input.categoryKey||input.payload?.categoryKey||categories[0]?.key),category=categories.find(row=>row.key===categoryKey);if(!category)fail(400,'课程分类无效');
  33. const uid=number(input.studentId||input.payload?.studentId),student=await userByLegacyId(context,input,uid,1);if(!student)fail(404,'学员不存在');
  34. const reserved=await reservedCreditsFor(context,input,uid),rules=await teachingRuleRows(context,input),date=businessDate();
  35. const effective=rules.filter(row=>row.categoryKey===categoryKey&&teachingRuleFor(rules,categoryKey,row.durationMinutes,row.deliveryMode,date)?.objectId===row.objectId);
  36. const options=effective.map(row=>({...row,affordable:row.creditCosts.length>0&&row.creditCosts.every(cost=>number(legacyOf(student)[cost.account])-number(reserved[cost.account])>=cost.amount)}));
  37. const eligible=!student.isDisabled&&options.some(row=>row.affordable),reason=student.isDisabled?'学员已停用':!effective.length?'该分类尚无有效课时规则':!eligible?'对应课时或陪练时长不足,请先充值':'';
  38. const bindings=await enrollmentBindings(context,input,uid),catalog=await enrollmentCatalog(context,input);
  39. return{categories,categoryKey,eligible,reason,rules:options,items:catalog.filter(row=>categoryMatchesStage(categoryKey,row.stageKey)).map(row=>({...row,alreadyOpened:bindings.some(b=>b.courseId===row.courseId&&b.categoryKey===categoryKey),eligible:eligible&&row.active&&row.wordCount>0,reason:!row.active?'内容已停用':!row.wordCount?'课程内容为空':reason}))};
  40. }
  41. async function saveEnrollment(context,input,payload) {
  42. requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),uid=number(payload.studentId),category=text(payload.categoryKey),ids=[...new Set((Array.isArray(payload.courseIds)?payload.courseIds:[payload.courseId]).map(Number))].sort((a,b)=>a-b);
  43. if(!ids.length||ids.length>100||ids.some(id=>!Number.isInteger(id)||id<=0))fail(400,'请选择 1 至 100 项课程内容');
  44. const req={uid,category,courseIds:ids,actor:context.current.id,reason},co=companyIdOf(context,input);
  45. const prior=await Psql.oneOrNone('SELECT "request","result" FROM "EnrollmentCommand" WHERE "company"=$1 AND "idempotencyKey"=$2',[co,text(input.idempotencyKey)]);
  46. if(prior){const same=await Psql.one('SELECT $1::jsonb=$2::jsonb AS same',[JSON.stringify(prior.request),JSON.stringify(req)]);if(!same.same)fail(409,'请求编号已用于其他开课操作');return prior.result;}
  47. const options=await enrollmentOptions(context,{...input,studentId:uid,categoryKey:category});
  48. if(!options.eligible)fail(409,options.reason);
  49. const selected=ids.map(id=>options.items.find(row=>row.courseId===id));if(selected.some(row=>!row||!row.eligible))fail(409,'课程内容为空、已停用或与分类学阶不符');
  50. const categoryName=options.categories.find(row=>row.key===category).name;
  51. return(await enrollmentSql('SELECT xs_enroll($1,$2,$3,$4,$5::jsonb,$6::jsonb,$7,$8) AS result',[companyIdOf(context,input),context.current.id,uid,category,JSON.stringify(selected.map(row=>({...row,categoryName}))),JSON.stringify(options.rules.map(rule=>rule.creditCosts)),reason,text(input.idempotencyKey)])).result;
  52. }
  53. async function enrollmentBindings(context,input,uid=0) {
  54. const rows=await Psql.query('SELECT b.*,c."objectId" AS "targetId",c."status",n."nodeName" AS "courseName" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid"::text WHERE b."company"=$1 AND ($2::numeric=0 OR b."yhid"::text=$2::text) AND c."status"=99 AND b."enrollmentStatus"=\'active\' AND COALESCE(n."zstatus",99)=99',[companyIdOf(context,input),uid]);
  55. return rows.map(row=>({bindingId:text(row.objectId),targetId:text(row.targetId),studentId:number(row.yhid),courseId:number(row.kcid),courseName:text(row.courseName),categoryKey:text(row.categoryKey),categoryName:TEACHING_CATEGORY_LABELS.get(text(row.categoryKey))||text(row.categoryName),learnedCount:number(row.yxx),wordCount:number(row.cksl)}));
  56. }
  57. async function enrollmentTeachingContext(context,input,payload,date) {
  58. const rows=(await enrollmentBindings(context,input,number(payload.studentId))).filter(row=>row.courseId===number(payload.courseId)&&(!payload.bindingId||row.bindingId===text(payload.bindingId)));
  59. if(rows.length!==1)fail(409,rows.length?'该内容有多个开课分类,请选择具体开课记录':'该课程尚未有效开课,请先开课或确认历史分类');
  60. const binding=rows[0],rules=await teachingRuleRows(context,input),rule=teachingRuleFor(rules,binding.categoryKey,appointmentDuration(payload),deliveryMode(payload.deliveryMode||payload.deliveryMethod),date);
  61. if(!rule)fail(409,'该开课分类没有对应时长和授课方式的有效规则');
  62. return{bindingId:binding.bindingId,mapping:{categoryKey:binding.categoryKey,categoryName:binding.categoryName},rule};
  63. }
  64. async function enrollmentSchedule(context,input,data) {
  65. const [bindings,rules]=await Promise.all([enrollmentBindings(context,input),teachingRuleRows(context,input)]),date=text(input.dateFrom)||businessDate();
  66. const courses=bindings.map(b=>{const active=rules.filter(r=>r.categoryKey===b.categoryKey&&teachingRuleFor(rules,b.categoryKey,r.durationMinutes,r.deliveryMode,date)?.objectId===r.objectId);return{...b,courseCategoryKey:b.categoryKey,courseCategoryName:b.categoryName,memberIds:[b.studentId],availableToAll:false,mappingConfirmed:active.length>0,supportedDurations:[...new Set(active.map(r=>r.durationMinutes))],supportedDeliveryModes:[...new Set(active.map(r=>r.deliveryMode))],categoryName:b.categoryName};});
  67. return{...data,courses};
  68. }
  69. async function toggleEnrollment(context,input,restore) {
  70. requireRole(context,['ops-manager']);const reason=reasonOf(input),co=companyIdOf(context,input),target=await scopedObject('CommonModel',text(input.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);
  71. if(number(target.get('modelId'))!==58)fail(400,'目标不是开课记录');
  72. const b=await findAddon('CourseBinding',target.get('company'),target.get('itemId'));if(!b)fail(404,'开课记录不存在');
  73. let costs=[];if(restore){const opt=await enrollmentOptions(context,{...input,studentId:b.get('yhid'),categoryKey:b.get('categoryKey')});if(!opt.items.some(row=>row.courseId===number(b.get('kcid'))&&row.eligible))fail(409,opt.reason||'课程内容不可用');costs=opt.rules.map(rule=>rule.creditCosts);}
  74. return(await enrollmentSql('SELECT xs_enrollment_status($1,$2,$3,$4,$5,$6::jsonb) AS result',[co,b.id,restore,context.current.id,reason,JSON.stringify(costs)])).result;
  75. }
  76. async function saveEnrollmentSchedule(context,input,payload,editing=false) {
  77. requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),co=companyIdOf(context,input),idem=text(input.idempotencyKey),editId=editing?text(input.targetId):'',req={actor:context.current.id,operation:editing?'update':'create',targetId:editId,payload,reason};
  78. if(idem.length<8)fail(400,'缺少排课请求编号');
  79. const prior=await Psql.oneOrNone('SELECT "request","result" FROM "EnrollmentCommand" WHERE "company"=$1 AND "idempotencyKey"=$2',[co,idem]);
  80. if(prior){const same=await Psql.one('SELECT $1::jsonb=$2::jsonb AS same',[JSON.stringify(prior.request),JSON.stringify(req)]);if(!same.same)fail(409,'请求编号已用于其他排课操作');return prior.result;}
  81. const check=await preflightAppointments(context,input,payload,editId),skip=payload.skipConflicts===true&&!editing,conflicts=new Set(check.conflictIndexes),selected=check.candidates.filter(row=>!skip||!conflicts.has(row.index));
  82. if(check.conflictCount&&!skip)fail(409,'排课存在时间冲突');if(!selected.length)fail(409,'没有可保存的排课');
  83. const coach=number(payload.coachId)?await userByLegacyId(context,input,payload.coachId,3):null,student=await userByLegacyId(context,input,payload.studentId,1);
  84. const entries=selected.map(item=>{const start=new Date(item.startsAt),date=businessDate(start),time=start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'});return{common:{title:displayName(student)+'('+check.courseCategoryName+')',subtitle:check.courseName},addon:{szyh:String(payload.studentId),pl:coach?String(payload.coachId):'',fxpl:coach?String(payload.coachId):'',plxm:coach?displayName(coach):'',kcid:String(payload.courseId),yykcid:String(payload.courseId),dslx:String(legacyClassTypeFor(check.courseCategoryKey,check.durationMinutes)),jffs:check.deliveryLabel,bindingId:check.bindingId,courseCategoryKey:check.courseCategoryKey,courseCategoryName:check.courseCategoryName,durationMinutes:check.durationMinutes,deliveryMode:check.deliveryMode,teachingRuleId:item.teachingRule.objectId,teacherPaySnapshot:item.teachingRule.teacherPay,creditCostsSnapshot:item.teachingRule.creditCosts,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}};});
  85. const result=(await enrollmentSql('SELECT xs_schedule_save($1,$2,$3::jsonb,$4::jsonb,$5,$6,$7) AS result',[co,context.current.id,JSON.stringify(req),JSON.stringify(entries),idem,editId,text(input.expectedUpdatedAt)])).result;return{...result,skipped:check.candidates.length-selected.length,warnings:check.warnings};
  86. }
  87. async function cancelEnrollmentSchedule(context,input,restore){
  88. requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),pair=await appointmentPair(context,input,text(input.targetId));assertVersion(pair.common,input.expectedUpdatedAt);
  89. return(await enrollmentSql('SELECT xs_schedule_cancel($1,$2,$3,$4,$5,$6) AS result',[pointerId(pair.common.get('company')),pair.common.id,restore,context.current.id,reason,text(input.expectedUpdatedAt)])).result;
  90. }