Jelajahi Sumber

fix: resolve writing lesson types from confirmed appointment evidence

彭峰 4 jam lalu
induk
melakukan
a431b035bc

+ 29 - 6
scripts/cloud/lesson-records.js

@@ -31,11 +31,34 @@ function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...inp
  for(const key of ['dateFrom','dateTo'])if(f[key]&&!/^\d{4}-\d{2}-\d{2}$/.test(f[key]))fail(400,'日期格式应为 YYYY-MM-DD');
  add('"actualAt">=($?::date::timestamp AT TIME ZONE \'Asia/Shanghai\')',f.dateFrom);add('"actualAt"<(($?::date+1)::timestamp AT TIME ZONE \'Asia/Shanghai\')',f.dateTo);
  return conditions.length?' WHERE '+conditions.join(' AND '):'';}
+// Resolve only the displayed page; preserve the original completed-lesson snapshot when present.
+async function lessonHistoryTypeRows(rows) {
+ const wanted=rows.map(row=>({company:row.company,appointmentId:text(row.yyds),studentId:text(row.xymz)})).filter(row=>row.company&&/^\d+$/.test(row.appointmentId)&&row.studentId);
+ if(!wanted.length)return rows;
+ const saved=await Psql.query(`WITH wanted AS (SELECT DISTINCT * FROM jsonb_to_recordset($1::jsonb) AS w(company text,"appointmentId" numeric,"studentId" text))
+ SELECT w.company,w."appointmentId"::text AS "appointmentId",w."studentId",
+ r."courseCategoryKey" AS "reportKey",r."courseCategoryName" AS "reportName",r."durationMinutes" AS "reportMinutes",
+ a."courseCategoryKey" AS "appointmentKey",a."courseCategoryName" AS "appointmentName",a."durationMinutes" AS "appointmentMinutes"
+ FROM wanted w
+ LEFT JOIN LATERAL (SELECT a.* FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" AND a.szyh::text=w."studentId"
+ WHERE c.company=w.company AND c."modelId"=54 AND c."generalId"=w."appointmentId" ORDER BY c."objectId" LIMIT 1) a ON true
+ LEFT JOIN LATERAL (SELECT r.* FROM "LessonCompletionReport" r WHERE r.company=w.company AND r."appointmentGeneralId"=w."appointmentId" AND r."studentId"::text=w."studentId"
+ ORDER BY r."submittedAt" DESC NULLS LAST,r."objectId" DESC LIMIT 1) r ON true`,[JSON.stringify(wanted)]);
+ const key=row=>[row.company,row.appointmentId,row.studentId].join(':'),byAppointment=new Map(saved.map(row=>[key(row),row]));
+ return rows.map(row=>{
+  const source=byAppointment.get(key({company:row.company,appointmentId:text(row.yyds),studentId:text(row.xymz)}))||{};
+  const own=text(row.courseCategoryKey)||text(row.courseCategoryName),report=text(source.reportKey)||text(source.reportName);
+  return {...row,courseCategoryKey:own?row.courseCategoryKey:report?source.reportKey:source.appointmentKey,
+   courseCategoryName:own?row.courseCategoryName:report?source.reportName:source.appointmentName,
+   durationMinutes:[source.reportMinutes,source.appointmentMinutes,row.durationMinutes].map(value=>number(value)).find(value=>value>0)};
+ });
+}
 function lessonHistoryClassType(row) {
- const categoryKey=text(row.courseCategoryKey),categoryName=text(row.courseCategoryName);
- const minutes=({'1':30,'2':60})[text(row.kclx)];
- const isWord=categoryKey==='word'||categoryName==='单词课'||(!categoryKey&&!categoryName&&number(row.kcid)>0&&number(row.kcid)!==398);
- return minutes&&isWord?'单词课 '+minutes+' 分钟':categoryName||classType(row.kclx);
+ const categoryKey=text(row.courseCategoryKey),categoryName=text(row.courseCategoryName),type=text(row.kclx);
+ const minutes=number(row.durationMinutes)||({'1':30,'2':60,'3':60})[type];
+ const isWord=categoryKey==='word'||categoryName==='单词课'||(!categoryKey&&!categoryName&&number(row.kcid)>0&&number(row.kcid)!==398&&['1','2'].includes(type));
+ const name=categoryName||TEACHING_CATEGORY_LABELS.get(categoryKey)||(type==='3'?'体验课':isWord?'单词课':number(row.kcid)===398?'语法写作课':'');
+ return name?name+(minutes?' '+minutes+' 分钟':''):classType(row.kclx);
 }
 function lessonHistoryRow(row){return {targetId:text(row.targetId||row.objectId),lessonId:row.objectId,generalId:number(row.generalId||row.id),appointmentId:text(row.yyds),studentId:number(row.xymz),studentName:row.studentNameResolved,coachId:number(row.jsmz),coachName:row.coachNameResolved,storeId:number(row.storeIdResolved),storeName:row.storeName,courseId:number(row.kcid),courseName:row.courseNameResolved,classTypeLabel:lessonHistoryClassType(row),lessonAt:dateIso(row.actualAt),score:number(row.pf),comment:text(row.pldp),coachComment:text(row.pldp),studentComment:text(row.pjnr),feedbackSummary:text(row.feedbackSummary||row.pldp),duplicateCount:number(row.duplicates),pendingReviews:number(row.pendingReviews),reviewCoaches:row.reviewCoaches||[],reviewCoachLabel:(row.reviewCoaches||[]).length>1?'多位陪练':row.reviewCoaches?.[0]?.name||'无待复习任务'};}
 async function lessonHistoryPage(context,input){
@@ -50,7 +73,7 @@ async function lessonHistoryPage(context,input){
  FROM xs_review_task_link link JOIN "MemoryPracticeRecord" m ON m.company=link.company AND m."objectId"=link."taskId"
  LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true
  WHERE link.company=p.company AND link."studentId"=p.xymz AND link."appointmentId"=p.yyds AND COALESCE(m.fxzt,'0')<>'1' AND NOT COALESCE(m."rewardDuplicate",false) GROUP BY m.plid,u."realName",u.nickname,u."legacyUserData",u.username) grouped) rv ON true),'[]'::jsonb) items`;
- const result=await Psql.one(q,args);return {page:paging.page,pageSize:paging.pageSize,total:number(result.total),cumulativeTotal:number(result.cumulativeTotal),items:(result.items||[]).map(lessonHistoryRow),refreshedAt:new Date().toISOString(),version:LESSON_RECORDS_VERSION+':'+result.historyVersion};
+ const result=await Psql.one(q,args);return {page:paging.page,pageSize:paging.pageSize,total:number(result.total),cumulativeTotal:number(result.cumulativeTotal),items:(await lessonHistoryTypeRows(result.items||[])).map(lessonHistoryRow),refreshedAt:new Date().toISOString(),version:LESSON_RECORDS_VERSION+':'+result.historyVersion};
 }
 async function lessonHistoryDetail(context,input){
  requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input),id=text(input.lessonId||input.targetId),appointment=text(input.appointmentId);
@@ -63,7 +86,7 @@ async function lessonHistoryDetail(context,input){
  Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"::text=$2 LIMIT 1',[co,row.yyds]),
  Psql.query('SELECT m."objectId",m.plid AS "coachId",m.kywrq AS "plannedDate",m.fxzt AS status,m.wcsj AS "completedAt",COALESCE(NULLIF(u."realName",\'\'),NULLIF(u.nickname,\'\'),u."legacyUserData"->>\'HoneyName\',u.username,\'陪练 \'||m.plid::text) AS "coachName" FROM xs_review_task_link l JOIN "MemoryPracticeRecord" m ON m.company=l.company AND m."objectId"=l."taskId" LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true WHERE l.company=$1 AND l."studentId"=$2 AND l."appointmentId"=$3 ORDER BY m.kywrq,m."objectId"',[co,row.xymz,row.yyds]),
  Psql.query('SELECT a.*,c.reason,c."actorId" FROM "ReviewTransferAudit" a JOIN "ReviewTransferCommand" c ON c."objectId"=a."commandId" WHERE a.company=$1 AND a."studentId"=$2 AND a."appointmentId"=$3 ORDER BY a."createdAt" DESC',[co,row.xymz,row.yyds])]);
- return {...lessonHistoryRow(row),report:report||null,coachComment:text(report?.coachComment||row.pldp),studentReview:review||{score:number(row.pf),comment:text(row.pjnr)},tasks,audit:auditRows,canTransfer:!context.storeId&&roleOf(context)==='ops-manager',hasReport:Boolean(report)};
+ return {...lessonHistoryRow((await lessonHistoryTypeRows([row]))[0]),report:report||null,coachComment:text(report?.coachComment||row.pldp),studentReview:review||{score:number(row.pf),comment:text(row.pjnr)},tasks,audit:auditRows,canTransfer:!context.storeId&&roleOf(context)==='ops-manager',hasReport:Boolean(report)};
 }
 async function lessonTransferPreview(context,input){
  requireRole(context,['ops-manager']);const co=companyIdOf(context,input),student=text(input.studentId),appointment=input.scope==='student'?'':text(input.appointmentId),coach=text(input.coachId);

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

@@ -3456,8 +3456,9 @@ function learningReportMetadata(row){
 function learningReportClassTypeLabel(row){
   const metadata=learningReportMetadata(row),key=text(row.courseCategoryKey),name=text(row.courseCategoryName),legacy=row.legacyLesson||{},type=text(row.lessonClassType||legacy.classType||row.classType||metadata.classType),courseId=number(row.courseId)||number(legacy.courseId)||number(metadata.courseId);
   const isWord=row.isWriting!==true&&(key==='word'||name==='单词课'||(!key&&!name&&courseId>0&&courseId!==398&&['1','2'].includes(type)));
-  const minutes=number(row.durationMinutes)>0?number(row.durationMinutes):number(metadata.durationMinutes)>0?number(metadata.durationMinutes):({'1':30,'2':60})[type];
-  return isWord&&minutes?'单词课 '+minutes+' 分钟':text(metadata.categoryName)&&minutes?text(metadata.categoryName)+' '+minutes+' 分钟':classType(type);
+  const minutes=number(row.durationMinutes)>0?number(row.durationMinutes):number(metadata.durationMinutes)>0?number(metadata.durationMinutes):({'1':30,'2':60,'3':60})[type];
+  const label=name||TEACHING_CATEGORY_LABELS.get(key)||text(metadata.categoryName)||(type==='3'?'体验课':isWord?'单词课':courseId===398||row.isWriting===true&&['1','2'].includes(type)?'语法写作课':'');
+  return label?label+(minutes?' '+minutes+' 分钟':''):classType(type);
 }
 async function learningReportClassTypeRows(rows){
   const wanted=rows.map(row=>({company:row.recordCompany,appointmentId:text(row.dsid),studentId:text(row.userId),missingAppointment:!row.appointmentObjectId})).filter(row=>row.company&&row.appointmentId&&row.studentId);

+ 2 - 0
scripts/tests/fixtures/lesson-records-schema.sql

@@ -9,3 +9,5 @@ CREATE TABLE "_User" ("objectId" text PRIMARY KEY, "createdAt" timestamp with ti
  CREATE TABLE "PayrollLine" ("objectId" text PRIMARY KEY,"company" text,"batchId" text,"kind" text,"excluded" boolean,"coachId" double precision,"coachName" text,"studentName" text,"courseName" text,"storeId" double precision,"storeName" text,"amount" double precision,"rateAmount" double precision,"durationHours" double precision,"originalMonth" text,"settlementMonth" text,"lessonAt" timestamptz,"exception" text,"classType" double precision,"courseCategoryName" text);
 CREATE TABLE "Node" (company text,"nodeId" numeric,"nodeName" text);
 CREATE TABLE "LessonReview" (company text,"appointmentGeneralId" text,score numeric,comment text,"submittedAt" timestamptz);
+
+CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,company text,id numeric,szyh text,"courseCategoryKey" text,"courseCategoryName" text,"durationMinutes" double precision);

+ 7 - 1
scripts/tests/learning-report-query.test.mjs

@@ -115,7 +115,13 @@ test('单词学习报表统一课型格式,优先显示保存时长并兼容
   assert.equal(gateway.learningReportProjection({legacyLesson:{classType:'2',courseId:'161'}}).classTypeLabel,'单词课 60 分钟');
   assert.equal(gateway.learningReportProjection({classType:'1',courseId:203}).classTypeLabel,'单词课 30 分钟');
   assert.equal(gateway.learningReportProjection({classType:'2',courseId:203}).classTypeLabel,'单词课 60 分钟');
-  assert.equal(gateway.learningReportProjection({classType:'2',courseId:398,isWriting:true}).classTypeLabel,'60 分钟');
+  assert.equal(gateway.learningReportProjection({classType:'2',courseId:398,isWriting:true}).classTypeLabel,'语法写作课 60 分钟');
+});
+
+test('写作报表使用确认分类与实际时长,未知学段不按当前学生档案推测',()=>{
+ assert.equal(gateway.learningReportProjection({classType:'2',courseId:398,isWriting:true,courseCategoryKey:'high_writing',courseCategoryName:'高中语法写作课'}).classTypeLabel,'高中语法写作课 60 分钟');
+ assert.equal(gateway.learningReportProjection({classType:'2',courseId:398,isWriting:true,durationMinutes:30,courseCategoryKey:'middle_writing'}).classTypeLabel,'中学语法写作课 30 分钟');
+ assert.equal(gateway.learningReportProjection({classType:'2',courseId:398,isWriting:false,courseCategoryKey:'word',courseCategoryName:'单词课'}).classTypeLabel,'单词课 60 分钟');
 });
 
 test('批量读取已保存时长和旧课型时同时匹配帐套、预约与学员', async () => {

+ 22 - 4
scripts/tests/lesson-records.test.mjs

@@ -4,7 +4,7 @@ import {readFileSync} from 'node:fs';
 import {PGlite} from '@electric-sql/pglite';
 import vm from 'node:vm';
 const db=new PGlite(),query=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await query(s,p))[0];
-const ctx={Psql:{query,one,oneOrNone:one},text:v=>String(v??'').trim(),number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,dateIso:v=>v?new Date(v).toISOString():'',classType:v=>({'2':'60 分钟'})[v]||v,companyIdOf:c=>c.company,roleOf:c=>c.role,requireRole:(c,r)=>{if(!r.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},pageInput:i=>({page:Math.max(1,Number(i.page)||1),pageSize:Math.min(100,Math.max(1,Number(i.pageSize)||20))})};
+const ctx={TEACHING_CATEGORY_LABELS:new Map([['word','单词课'],['high_writing','高中语法写作课'],['middle_writing','中学语法写作课'],['trial','体验课']]),Psql:{query,one,oneOrNone:one},text:v=>String(v??'').trim(),number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,dateIso:v=>v?new Date(v).toISOString():'',classType:v=>({'2':'60 分钟'})[v]||v,companyIdOf:c=>c.company,roleOf:c=>c.role,requireRole:(c,r)=>{if(!r.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},pageInput:i=>({page:Math.max(1,Number(i.page)||1),pageSize:Math.min(100,Math.max(1,Number(i.pageSize)||20))})};
 vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/lesson-records.js',import.meta.url),'utf8'),ctx);
 const manager={company:'c',current:{id:'manager'},role:'ops-manager'};
 const input={studentId:20,appointmentId:'1000',scope:'lesson',coachId:11};
@@ -109,7 +109,25 @@ test('新旧单词课统一在课型后显示预约时长,并保留其他课
    assert.equal(ctx.lessonHistoryRow({kclx,kcid:'203',...category}).classTypeLabel,'单词课 '+minutes+' 分钟');
   }
  }
- assert.equal(ctx.lessonHistoryRow({kclx:'2',kcid:'0',courseCategoryKey:'high_writing',courseCategoryName:'高中语法写作课'}).classTypeLabel,'高中语法写作课');
- assert.equal(ctx.lessonHistoryRow({kclx:'2',kcid:'398'}).classTypeLabel,'60 分钟');
- assert.equal(ctx.lessonHistoryRow({kclx:'3',kcid:'405',courseCategoryKey:'trial',courseCategoryName:'体验课'}).classTypeLabel,'体验课');
+ assert.equal(ctx.lessonHistoryRow({kclx:'2',kcid:'0',courseCategoryKey:'high_writing',courseCategoryName:'高中语法写作课'}).classTypeLabel,'高中语法写作课 60 分钟');
+ assert.equal(ctx.lessonHistoryRow({kclx:'2',kcid:'398'}).classTypeLabel,'语法写作课 60 分钟');
+ assert.equal(ctx.lessonHistoryRow({kclx:'3',kcid:'405',courseCategoryKey:'trial',courseCategoryName:'体验课'}).classTypeLabel,'体验课 60 分钟');
+});
+
+test('已确认预约补齐历史写作课型,按原学员和帐套关联,保留完课快照与实际时长',async()=>{
+ await query(`INSERT INTO "CommonModel" ("objectId",company,"generalId","itemId","modelId") VALUES ('writingBooking','c',6000,600,54),('foreignBooking','d',6000,601,54),('wrongStudentBooking','c',6001,602,54)`);
+ await query(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,"courseCategoryKey","courseCategoryName","durationMinutes") VALUES ('writing','c',600,'20','high_writing','高中语法写作课',60),('foreign','d',601,'20','middle_writing','中学语法写作课',30),('wrongStudent','c',602,'21','high_writing','高中语法写作课',60)`);
+ const base={company:'c',yyds:'6000',xymz:'20',kcid:'398',kclx:'2'};
+ await query(`INSERT INTO "LessonRecord" ("objectId",company,id,xymz,jsmz,kcid,yyds,kclx,plpjsj) VALUES ('writingHistory','c',600,'20','10','398','6000','2','2026-11-09 23:11')`);
+ assert.equal((await ctx.lessonHistoryPage(manager,{studentId:20})).items[0].classTypeLabel,'高中语法写作课 60 分钟');
+ assert.equal((await ctx.lessonHistoryDetail(manager,{lessonId:'writingHistory'})).classTypeLabel,'高中语法写作课 60 分钟');
+
+ assert.equal(ctx.lessonHistoryClassType((await ctx.lessonHistoryTypeRows([base]))[0]),'高中语法写作课 60 分钟');
+ assert.equal(ctx.lessonHistoryClassType((await ctx.lessonHistoryTypeRows([{...base,yyds:'6001'}]))[0]),'语法写作课 60 分钟');
+ assert.equal(ctx.lessonHistoryClassType((await ctx.lessonHistoryTypeRows([{...base,company:'other'}]))[0]),'语法写作课 60 分钟');
+ assert.equal(ctx.lessonHistoryClassType((await ctx.lessonHistoryTypeRows([{...base,courseCategoryKey:'word',courseCategoryName:'单词课'}]))[0]),'单词课 60 分钟');
+ await query(`INSERT INTO "LessonCompletionReport" ("objectId",company,"appointmentGeneralId","studentId","durationMinutes","courseCategoryKey","courseCategoryName") VALUES ('writingSnapshot','c',6000,20,30,'middle_writing','中学语法写作课')`);
+ assert.equal(ctx.lessonHistoryClassType((await ctx.lessonHistoryTypeRows([base]))[0]),'中学语法写作课 30 分钟');
+ assert.equal(ctx.lessonHistoryClassType({kcid:'30',kclx:'3'}),'体验课 60 分钟');
+ assert.equal(ctx.lessonHistoryClassType({kcid:'30',kclx:'1'}),'单词课 30 分钟');
 });