فهرست منبع

fix(learning): create notes reviews for writing lessons

彭峰 10 ساعت پیش
والد
کامیت
5b3713be3b

+ 24 - 0
docs/qa/writing-notes-review-2026-11-02.md

@@ -0,0 +1,24 @@
+# 语法写作课笔记抗遗忘(2026-11-02)
+
+## 行为
+
+小学、中学、高中语法写作课结课后,即使没有单词批次,也会在同一结课事务内创建空的课堂学习来源记录,并生成第 1、2、3、5、7、9、11、14、17、21 天共十轮抗遗忘任务。任务分配给预约的复习老师(无独立复习老师时沿用授课老师);不填充虚构单词。重复结课不生成第二份,结课失败时记录和任务一同回滚。
+
+复习日历、今日任务、学员筛选和汇总均包含这些任务。后端确认来源为同一学员的已结课语法写作预约后,返回笔记复习标记;前端显示课堂语法笔记、写作要点或课后练习提示及“结束复习”。普通缺失单词的记录继续保留原错误处理,不自动认作笔记任务。
+
+结束复习复用现有 `app_review_complete` 和 SQL 计薪入口:30 分钟课按现行 0.5 元,60 分钟课按现行 1 元;计划当天由分配老师完成才计费。学生自主复习、逾期补做及防重复计费规则保持一致。未到计划日不能提前完成,不展示假成功。
+
+## 本地验证
+
+- 后端相关八个文件共 76 项测试通过,含三类课程与 30/60 分钟、十个日期、真实批次 SQL、空来源关联、任务老师、事务回滚、真实结课任务的计薪时长解析、提前完成拒绝、无单词按期完成及重复提交。
+- 移动端学习模块定向集成测试 69/69 通过;完整移动端回归 331/331 通过。
+- `npm run build:mobile` 通过,仅有既有构建预算及 CommonJS 提示。
+- `npm run cloud:validate` 验证 11 个云函数定义通过。
+- 只读独立代码审查未发现阻塞问题。
+- 浏览器使用本地应用和隔离 API,375/320/1024px 均无横向溢出,“结束复习”按钮高 48px;结束只发送一次任务请求,服务端成功响应后显示完成状态,无页面错误。
+
+本地浏览器证据:`/Users/mac/.local/state/xiaoshu/qa/2026-11-02-writing-review/`,含三个宽度的页面截图、完成截图、`verification.json` 和 `browser-check.cjs`。
+
+## 交付边界
+
+本次仅提交并推送代码,不发布前端、不更新线上云函数或 SQL,不补写历史已结课课程的缺失任务。浏览器验证使用隔离数据,不能当作线上真实课程或实际工资入账验收。上线时需要一并更新 `xs_lesson_complete`、应用网关(`recordDetailData`、`notesReviewSourceSql`、`appReviewPage`)及移动端。

+ 6 - 0
docs/superpowers/plans/2026-11-02-writing-review.md

@@ -0,0 +1,6 @@
+# 语法写作课笔记抗遗忘
+
+- [x] 结课创建有来源的空学习记录,按现有第 1/2/3/5/7/9/11/14/17/21 天计划生成任务;同一结课重试不重复。
+- [x] 后端识别语法写作课的笔记复习,前端展示笔记说明及结束复习;普通缺失单词仍保留错误处理。
+- [x] 验证 30/60 分钟沿用现行复习费用、日期和身份规则,测试并截图验证。
+- [x] 仅提交并推送本次代码,暂不上线,不补写历史任务。

+ 24 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -1303,6 +1303,30 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
   });
 
+  it('allows a confirmed notes review to finish once without loading missing vocabulary', async () => {
+    user.set({userId:300,groupId:3}); session.groupId.set(3); session.student.set({userId:100,groupId:1});
+    const api = TestBed.inject(ApiService), post = api.post.bind(api), reply = new Subject<ApiEnvelope<any>>();
+    const submit = spyOn(api, 'post').and.callFake((action,params) => action === 'e_record_detail'
+      ? of({retcode:1,result:[],addon:[{reviewContentMode:'notes',courseCategoryKey:'high_writing'}]} as ApiEnvelope<any>)
+      : action === 'app_review_complete' ? reply : post(action,params));
+    const get = spyOn(api,'get').and.callThrough();
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047'); harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.textContent).toContain('语法写作复习');
+    expect(harness.routeNativeElement?.textContent).toContain('请带领学生复习课堂语法笔记');
+    expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
+    expect(get.calls.allArgs().some(a=>a[0]==='content_get')).toBeFalse();
+    expect(submit.calls.allArgs().some(a=>a[0]==='app_learning_overview')).toBeFalse();
+    const end = harness.routeNativeElement?.querySelector('.sticky-actions button') as HTMLButtonElement;
+    expect(end.textContent).toContain('结束复习'); end.click(); harness.fixture.detectChanges();
+    expect(end.disabled).toBeTrue();end.click();
+    const requests=submit.calls.allArgs().filter(a=>a[0]==='app_review_complete');
+    expect(requests.length).toBe(1);expect(requests[0][1]).toEqual({id:'864047'});
+    expect(harness.routeNativeElement?.querySelector('.review-complete')).toBeNull();
+    reply.next({retcode:1,result:{amount:1,rewardStatus:'earned'}});reply.complete();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');
+  });
+
   it('identifies a legacy writing review with no original words and keeps it incomplete', async () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const post = api.post.bind(api);

+ 10 - 3
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -449,6 +449,12 @@
     } @else {
     @if(canStartPointReading && overdueReview){<p class="record-tip" role="status">这是逾期任务,可以补做,但不计复习费用。</p>}
 
+    @if (recordWordless()) {
+      <section class="record-overview notes-review" aria-labelledby="notes-review-title">
+        <header><div><span>21 天抗遗忘</span><strong id="notes-review-title">语法写作复习</strong></div><lucide-icon [img]="icons.BookOpen" [size]="28" /></header>
+        <p class="record-tip">{{ canStartPointReading ? '请带领学生复习课堂语法笔记、写作要点或课后练习,完成后点击「结束复习」。' : '请结合课堂语法笔记、写作要点或课后练习自主复习,完成后点击「结束复习」。' }}</p>
+      </section>
+    } @else {
     <section class="record-overview">
       <header><div><span>单词复习</span><strong>先回忆,再核对</strong></div><b>{{ seen().size }} / {{ words().length }}</b></header>
       <div><span><strong>{{ seen().size }}</strong><small>已核对</small></span><span><strong>{{ selected().size }}</strong><small>遗忘词</small></span></div>
@@ -466,9 +472,10 @@
         </article>
       }
     </div>
-    <div class="sticky-actions">
-      @if (!isWritingRecord()) { <button type="button" class="secondary-button" (click)="addRecordWords()">加入生词本</button> }
-      <button type="button" class="primary-button" [disabled]="submitting()" (click)="endRecord()">结束本次复习</button>
+    }
+    <div class="sticky-actions" [class.single]="recordWordless()">
+      @if (!recordWordless() && !isWritingRecord()) { <button type="button" class="secondary-button" (click)="addRecordWords()">加入生词本</button> }
+      <button type="button" class="primary-button" [disabled]="submitting()" (click)="endRecord()">{{ recordWordless() ? '结束复习' : '结束本次复习' }}</button>
     </div>
     }
   } @else if (isAntiForgetPage) {

+ 9 - 2
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1941,16 +1941,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const recordId = this.query('id');
     const detailParams = { id: recordId, studentId: learnerId };
     const hasWords = (result: unknown) => this.findList(result).some((item) => this.toWord(item).word);
+    const notesReview = (detail: {addon?: unknown}) => this.findList(detail.addon)[0]?.['reviewContentMode'] === 'notes';
     let originalRecord: SourceItem | undefined;
     this.api.post<unknown>('e_record_detail', detailParams).pipe(
-      switchMap((detail) => hasWords(detail.result) ? of(detail) : this.api.get<unknown>('content_get', { id: recordId }).pipe(
+      switchMap((detail) => hasWords(detail.result) || notesReview(detail) ? of(detail) : this.api.get<unknown>('content_get', { id: recordId }).pipe(
         map(({ result }) => {
           originalRecord = this.findList(result)[0];
           return { ...detail, result: this.parseArray(originalRecord?.['xxqs']) };
         }),
         catchError(() => of(detail)),
       )),
-      switchMap((detail) => hasWords(detail.result) ? of(detail) : this.api.post<unknown>('app_learning_overview', {
+      switchMap((detail) => hasWords(detail.result) || notesReview(detail) ? of(detail) : this.api.post<unknown>('app_learning_overview', {
         studentId: learnerId, page: 1, pageSize: 1, refresh: true,
       }).pipe(
         switchMap(() => this.api.post<unknown>('e_record_detail', detailParams)),
@@ -1961,6 +1962,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       next: ({ result, addon }) => {
         const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
         if (!words.length) {
+          if (notesReview({addon})) {
+            this.orderInfo.set(this.findList(addon)[0] ?? {});
+            this.recordWordless.set(true);
+            this.error.set('');
+            return;
+          }
           const wordless = originalRecord && 'xxqs' in originalRecord
             && !this.parseArray(originalRecord['xxqs']).length
             && Number(originalRecord['learned'] ?? 0) === 0;

+ 10 - 3
scripts/cloud/mobile-teaching.js

@@ -24,12 +24,19 @@ async function appLessonReport(input,current) {
 function reviewStudentNameSql() {
   return `COALESCE(NULLIF(BTRIM(u."realName"),''),NULLIF(BTRIM(u."nickname"),''),NULLIF(BTRIM(u."legacyUserData"->>'HoneyName'),''),CASE WHEN POSITION('@' IN c."title")>0 THEN NULLIF(BTRIM(SPLIT_PART(c."title",'@',1)),'') END,NULLIF(BTRIM(u."username"),''),'学员 '||m."yhid"::text)`;
 }
+function notesReviewSourceSql() {
+  return `EXISTS (SELECT 1 FROM "CommonModel" ac JOIN "CourseAppointment" a ON a.company=ac.company AND a.id=ac."itemId"
+    WHERE ac.company=d.company AND ac."modelId"=54 AND ac."generalId"::text=d.dsid::text AND COALESCE(ac.status,99)<>-2
+    AND a.szyh::text=d."userId"::text AND a.dszt IN ('11','20','30','40')
+    AND a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing'))`;
+}
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input),includeMetadata=input.includeMetadata!==false,today=appOverviewToday();
   const reviewTarget=coach&&number(input.studentId)?await appReviewTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined;
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
-  // A legacy writing lesson can have a ten-round plan but no saved words. Do not list it as a word-review task.
-  clauses.push('NOT EXISTS (SELECT 1 FROM "CommonModel" sc JOIN "DailyStudyRecord" d ON d."company"=sc."company" AND d."id"=sc."itemId" WHERE sc."company"=m."company" AND sc."modelId"=56 AND sc."generalId"::text=m."xxjlid"::text AND COALESCE(d."learned",0)=0 AND COALESCE(NULLIF(BTRIM(d."xxqs"),\'\'),\'[]\')=\'[]\')');
+  // Empty writing records are deliberate notes reviews; other empty sources remain hidden.
+  const notesSource=notesReviewSourceSql();
+  clauses.push('NOT EXISTS (SELECT 1 FROM "CommonModel" sc JOIN "DailyStudyRecord" d ON d."company"=sc."company" AND d."id"=sc."itemId" WHERE sc."company"=m."company" AND sc."modelId"=56 AND sc."generalId"::text=m."xxjlid"::text AND COALESCE(d."learned",0)=0 AND COALESCE(NULLIF(BTRIM(d."xxqs"),\'\'),\'[]\')=\'[]\' AND NOT ('+notesSource+'))');
   const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
   const studentsPromise=coach&&includeMetadata? Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',[...args]):Promise.resolve([]);
   if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId));
@@ -44,7 +51,7 @@ async function appReviewPage(input,current) {
   if(input.status==='completed')clauses.push('m."fxzt"=\'1\'');
   if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');
   const where=clauses.join(' AND '),countPromise=Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
-  const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
+  const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",COALESCE(n."nodeName",CASE WHEN '+notesSource+' THEN \'语法写作复习\' END) AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
   const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" WHERE '+where,args).then(row=>number(row.income)):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
   return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income,incomeScope:input.month||'all'},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};

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

@@ -2638,10 +2638,16 @@ async function recordDetailData(input, current) {
     const words = await legacyStudyRecordWords(id,targetId);
     return words.length ? { words:words.map((item) => ({ ...item, Title:item.Title || item.title || item.word || '' })), addon:[] } : null;
   }
-  const source = rows[0]; const recordAddon = source.__addon || {}; if(recordAddon.canonicalStudyId && String(recordAddon.canonicalStudyId)!==id) return recordDetailData({...input,id:recordAddon.canonicalStudyId},current); delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record); let storedWords = parseArray(recordAddon.xxqs); if (!storedWords.length) storedWords = await legacyStudyRecordWords(id,number(record.userId ?? record.UserID)); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
+  const source = rows[0]; const recordAddon = source.__addon || {}; if(recordAddon.canonicalStudyId && String(recordAddon.canonicalStudyId)!==id) return recordDetailData({...input,id:recordAddon.canonicalStudyId},current); delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record);
+  const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null;
+  let storedWords = parseArray(recordAddon.xxqs);
+  const notesReview = !storedWords.length && number(recordAddon.learned)===0 && order
+    && !order.cancelled && number(order.szyh)===number(record.userId ?? record.UserID) && [11,20,30,40].includes(number(order.dszt))
+    && ['primary_writing','middle_writing','high_writing'].includes(String(order.courseCategoryKey));
+  if (!storedWords.length && !notesReview) storedWords = await legacyStudyRecordWords(id,number(record.userId ?? record.UserID)); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
   if (ids.length) wordRows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]);
   const details = new Map(wordRows.map((row) => { const normalized = normalizeWordRow(row); return [String(normalized.GeneralID || normalized.generalId), normalized]; })); const words = storedWords.map((item) => { const raw = item && typeof item === 'object' ? safe(item) : {}; const wordId = String(raw.GeneralID ?? raw.generalId ?? raw.id ?? ''); const detail = details.get(wordId) || legacyAliases({ generalId: wordId, title: raw.Title || raw.title || '' }, 'CommonModel'); return { ...detail, ...raw, GeneralID: number(wordId, wordId), Title: raw.Title || raw.title || detail.Title || detail.title || '', detail: [detail] }; });
-  const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
+  return { words, addon: order ? [{...order, ...(notesReview ? {reviewContentMode:'notes'} : {})}] : [], record };
 }
 async function newWordPage(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid);

+ 21 - 1
scripts/sql/lesson-workflow.sql

@@ -38,6 +38,7 @@ DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "Lesson
  previous "LessonWorkflowCommand"; result jsonb; lesson_count int; cost jsonb; ledger_table text; evidence int;
  study record; study_words jsonb; study_course text; found_study boolean=false; words jsonb='[]'; review_date text; review_key text; review_id numeric; review_object text;
  total_reviews int; completed_reviews int; report_id text; was_charged boolean=false;
+ notes_lesson boolean; study_id numeric; study_general_id numeric; study_object text;
 BEGIN
  IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
  IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
@@ -65,6 +66,21 @@ BEGIN
  IF a.dszt='30' AND report."objectId" IS NOT NULL THEN RETURN jsonb_build_object('GeneralID',gid,'status',30,'unchanged',true,'feedbackId',report."objectId",'feedbackStatus',report."feedbackStatus",'teacherPay',(SELECT COALESCE(lr."teacherPaySnapshot",a."teacherPaySnapshot") FROM "LessonRecord" lr WHERE lr.company=co AND lr."objectId"=report."lessonId"),'payStatus',CASE WHEN (SELECT COALESCE(lr."teacherPaySnapshot",a."teacherPaySnapshot") FROM "LessonRecord" lr WHERE lr.company=co AND lr."objectId"=report."lessonId") IS NULL THEN 'pending' ELSE 'earned' END);END IF;
  IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
  IF a.dszt NOT IN ('10','11','20') THEN RAISE EXCEPTION '请先开始课程';END IF;
+ -- Notes lessons have a real source record even when there is no vocabulary batch.
+ notes_lesson=COALESCE(a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing'),false);
+ IF notes_lesson AND NOT EXISTS(
+  SELECT 1 FROM "DailyStudyRecord" d JOIN "CommonModel" sc ON sc.company=d.company AND sc."modelId"=56 AND sc."itemId"=d.id AND sc.status=99
+  WHERE d.company=co AND d.dsid::text=gid AND d."userId"::text=a.szyh::text AND COALESCE(to_jsonb(d)->>'recordScope','')<>'lesson-alias'
+ ) THEN
+  study_id=nextval('xiaoshu_business_id');study_general_id=nextval('xiaoshu_business_id');study_object=xs_object_id();
+  INSERT INTO "DailyStudyRecord" SELECT * FROM jsonb_populate_record(NULL::"DailyStudyRecord",jsonb_build_object(
+   'objectId',study_object,'company',co,'id',study_id,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:lesson-study:'||gid,
+   'userId',a.szyh::numeric,'pl',a.pl,'dsid',gid,'recordScope','lesson','studyVersion',0,'dqrq',to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYYMMDD'),
+   'xxqs','[]','learned',0,'ygg',0,'djq',0,'con','语法写作课笔记复习'));
+  PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'generalId',study_general_id,'itemId',study_id,
+   'modelId',56,'nodeId',291,'tableName','ZL_C_ss','title','语法写作课学习记录','status',99,'hits',0,'createdAt',now(),'updatedAt',now(),
+   'sourceKey','cloud:lesson-study:'||gid,'teachingManaged',true));
+ END IF;
  -- Versioned batches and completion serialize on the same xs-credit lock.
  IF to_regclass('"LessonStudyBatch"') IS NOT NULL THEN
   IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='draft') THEN RAISE EXCEPTION '还有未确认的学习批次,请先确认掌握状态或放弃该批次'; END IF;
@@ -74,6 +90,10 @@ BEGIN
    UPDATE "DailyStudyRecord" SET fxrl=CASE WHEN COALESCE(learned,0)>0 THEN (SELECT string_agg(to_char((now() AT TIME ZONE 'Asia/Shanghai')::date+offset_days,'YYYYMMDD'),',' ORDER BY offset_days) FROM unnest(ARRAY[1,2,3,5,7,9,11,14,17,21]) offset_days) ELSE '' END WHERE company=co AND dsid::text=gid AND "recordScope"='lesson';
   END IF;
  END IF;
+ IF notes_lesson THEN
+  UPDATE "DailyStudyRecord" d SET fxrl=(SELECT string_agg(to_char((now() AT TIME ZONE 'Asia/Shanghai')::date+offset_days,'YYYYMMDD'),',' ORDER BY offset_days) FROM unnest(ARRAY[1,2,3,5,7,9,11,14,17,21]) offset_days)
+  WHERE d.company=co AND d.dsid::text=gid AND d."userId"::text=a.szyh::text AND COALESCE(to_jsonb(d)->>'recordScope','')<>'lesson-alias';
+ END IF;
  SELECT count(*) INTO lesson_count FROM "LessonRecord" WHERE company=co AND yyds::text=gid;
  IF lesson_count>1 THEN RAISE EXCEPTION '历史凭据存在重复上课记录,请由后台核对';END IF;
  SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid LIMIT 1;
@@ -107,7 +127,7 @@ BEGIN
   study_words=COALESCE(NULLIF(study.xxqs,'')::jsonb,'[]');
   IF jsonb_typeof(study_words)<>'array' THEN RAISE EXCEPTION '课堂学习记录格式异常,请核对后重试';END IF;
   words=words||study_words;study_course=COALESCE(study.study_course_id,a.kcid);
-  IF jsonb_array_length(study_words)>0 THEN
+  IF jsonb_array_length(study_words)>0 OR notes_lesson THEN
    FOR review_date IN SELECT DISTINCT value FROM unnest(string_to_array(COALESCE(study.fxrl,''),',')) value WHERE value ~ '^\d{8}$' LIMIT 15 LOOP
     review_key='cloud:e_order_update:'||gid||CASE WHEN NULLIF(to_jsonb(study)->>'bindingId','') IS NOT NULL AND (to_jsonb(study)->>'bindingId') IS DISTINCT FROM a."bindingId" THEN ':study:'||study.study_general_id ELSE '' END||':review:'||review_date;
     SELECT id,"objectId" INTO review_id,review_object FROM "MemoryPracticeRecord" WHERE company=co AND "sourceKey"=review_key LIMIT 1;

+ 4 - 1
scripts/tests/fixtures/teacher-review-schema.sql

@@ -6,4 +6,7 @@ CREATE TABLE "MemoryPracticeRecord"("objectId" text,company text,id numeric,plid
   CREATE TABLE "ReviewRewardIncome"(company text,"taskId" text,amount numeric,status text,"ineligibleReason" text,"completedAt" timestamptz,"originalAmount" numeric,"correctionAmount" numeric,"correctionReason" text);
   INSERT INTO "MemoryPracticeRecord" SELECT 'r'||i,'co',i,30,20,100,1,CASE WHEN i=2 THEN '1' ELSE '0' END,CASE WHEN i<3 THEN '2026-10-14' WHEN i=3 THEN '2026-10-13' ELSE '2026-10-20' END,'2026-10-20 10:00:00' FROM generate_series(1,50) i;
   INSERT INTO "MemoryPracticeRecord" VALUES ('other','co',100,31,21,101,1,'0','2026-10-14','2026-10-14');
-  INSERT INTO "CommonModel" (company,"modelId","generalId","itemId") SELECT company,60,id+1000,id FROM "MemoryPracticeRecord";
+  INSERT INTO "CommonModel" (company,"modelId","generalId","itemId") SELECT company,60,id+1000,id FROM "MemoryPracticeRecord";
+ALTER TABLE "CommonModel" ADD COLUMN status numeric;
+ALTER TABLE "DailyStudyRecord" ADD COLUMN dsid text, ADD COLUMN "userId" numeric;
+CREATE TABLE "CourseAppointment" (company text,id numeric,szyh text,dszt text,"courseCategoryKey" text);

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

@@ -73,7 +73,14 @@ for(const category of ['primary_writing','middle_writing','high_writing'])for(co
    await finish();
    const report=await one('SELECT *,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
    assert.equal(Number(report.vocabularyCount),0);assert.equal(Number(report.durationMinutes),duration);assert.equal(report.courseCategoryKey,category);
-   const tasks=await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid]);assert.equal(tasks.n,0);
+   const tasks=await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid]);assert.equal(tasks.n,10);
+   assert.deepEqual(report.antiForgetSummary,{total:10,completed:0,pending:10});
+   const reviews=(await db.query('SELECT * FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1 ORDER BY kywrq',[a.gid])).rows;
+   const dates=(await one("SELECT array_agg(to_char((now() AT TIME ZONE 'Asia/Shanghai')::date+days,'YYYY-MM-DD') ORDER BY days) dates FROM unnest(ARRAY[1,2,3,5,7,9,11,14,17,21]) days")).dates;
+   assert.deepEqual(reviews.map(r=>r.kywrq),dates);
+   assert.ok(reviews.every(r=>Number(r.plid)===30 && Number(r.yhid)===20));
+   const source=await one('SELECT d.* FROM "DailyStudyRecord" d JOIN "CommonModel" c ON c.company=d.company AND c."modelId"=56 AND c."itemId"=d.id WHERE c."generalId"=$1',[reviews[0].xxjlid]);
+   assert.equal(source.dsid,a.gid);assert.deepEqual(JSON.parse(source.xxqs),[]);
    const edit=await one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,report.revision,'writing-feedback-'+a.gid,JSON.stringify({...feedback,contentSummary:'阅读短文并练习写作',coachComment:'表达清晰'})]);assert.equal(edit.result.saved,true);
    const after=(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=$1',['student'])).legacyUserData;
    assert.equal(Number(after[account]),Number(before[account])-1);assert.equal(Number(after.UserPoint),Number(before.UserPoint)-hours);
@@ -83,6 +90,38 @@ for(const category of ['primary_writing','middle_writing','high_writing'])for(co
   } finally { await db.exec('ROLLBACK'); }
  });
 }
+test('安装真实学习批次流程后,空写作课仍生成十轮笔记复习并分配给复习老师',async()=>{
+ await db.exec('BEGIN');
+ try{
+  await db.exec(`ALTER TABLE "CommonModel" ADD hits numeric;
+   ALTER TABLE "DailyStudyRecord" ADD "createdAt" timestamptz,ADD "sourceKey" text,ADD pl text,ADD dqrq text,ADD con text,ADD learned numeric,ADD ygg numeric,ADD djq numeric;`);
+  await db.exec(readFileSync(new URL('../sql/lesson-study-batches.sql',import.meta.url),'utf8'));
+  const a=await appointment(true,'',{kcid:'0',yykcid:'0',bindingId:'',courseCategoryKey:'middle_writing',fxpl:'31'});
+  await complete(a.gid,a.updatedAt,'notes-real-batches');
+  const rows=(await db.query('SELECT * FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid])).rows;
+  assert.equal(rows.length,10);assert.ok(rows.every(r=>Number(r.plid)===31));
+  const d=await one('SELECT * FROM "DailyStudyRecord" WHERE dsid=$1',[a.gid]);
+  assert.equal(d.recordScope,'lesson');assert.equal(Number(d.learned),0);assert.deepEqual(JSON.parse(d.xxqs),[]);assert.equal(d.fxrl.split(',').length,10);
+  await complete(a.gid,a.updatedAt,'notes-real-batches');
+  assert.equal((await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid])).n,10);
+ }finally{await db.exec('ROLLBACK');}
+});
+test('写作结课失败回滚空学习记录及复习计划,原请求重试只创建一份',async()=>{
+ await db.exec('BEGIN');
+ try{
+  const a=await appointment(true,'',{kcid:'0',yykcid:'0',bindingId:'',courseCategoryKey:'primary_writing'}),before=await balance();
+  await db.exec(`ALTER TABLE "LessonCompletionReport" ADD CONSTRAINT fail_notes_report CHECK("courseCategoryKey"<>'primary_writing')`);
+  await db.exec('SAVEPOINT failing_completion');
+  await assert.rejects(complete(a.gid,a.updatedAt,'notes-failed-report'),/fail_notes_report/);
+  await db.exec('ROLLBACK TO SAVEPOINT failing_completion');
+  assert.equal(await balance(),before);
+  assert.equal((await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid])).n,0);
+  assert.equal((await one('SELECT count(*)::int n FROM "DailyStudyRecord" WHERE dsid=$1',[a.gid])).n,0);
+  await db.exec('ALTER TABLE "LessonCompletionReport" DROP CONSTRAINT fail_notes_report');
+  await complete(a.gid,a.updatedAt,'notes-failed-report');
+  assert.equal((await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid])).n,10);
+ }finally{await db.exec('ROLLBACK');}
+});
 test('课堂附件随反馈保存与重开,旧客户端修改保留附件,显式移除不改变课时',async()=>{
  await db.exec('BEGIN');
  try{

+ 36 - 0
scripts/tests/review-pay-policy.test.mjs

@@ -50,6 +50,42 @@ test('30分钟0.5元、60分钟1元,按排课快照优先并记录实际老师
   assert.equal((await complete(name)).unchanged,true);assert.equal((await q('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name])).length,1);
  }
 });
+test('真实写作结课生成的空记录与复习任务能解析计薪时长,未到计划日拒绝提前完成',async()=>{
+ await db.exec('BEGIN');
+ try{
+  await db.exec(sql('lesson-workflow'));await db.exec(sql('lesson-study-batches'));
+  await q(`INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","legacyUserData") VALUES('store','c',2,2,'store','{}')`);
+  await q(`UPDATE "_User" SET "legacyUserData"='{"ParentUserID":2}' WHERE "objectId" IN ('student','coach')`);
+  for(const account of ['Purse','UserPoint']){
+   await q(`SELECT xs_credit_post('c','manager','fund','store',$1,10,'','测试充值','转账',$2)`,[account,'notes-fund-'+account]);
+   await q(`SELECT xs_credit_post('c','manager','transfer','student',$1,10,'','测试划拨','转账',$2)`,[account,'notes-credit-'+account]);
+  }
+  for(const minutes of [30,60]){
+   const entries=[{common:{title:'笔记学员'},addon:{szyh:'20',pl:'10',fxpl:'10',kcid:'0',yykcid:'0',bindingId:'',courseCategoryKey:'high_writing',durationMinutes:minutes,dslx:minutes===30?'1':'2',deliveryMode:'online',bxrq:today(),sdsd:minutes===30?'08:00':'10:00',dszt:'0',creditCostsSnapshot:[{account:'Purse',amount:1},{account:'UserPoint',amount:minutes/60}]}}];
+   const saved=(await one(`SELECT xs_schedule_save('c','manager','{}',$1::jsonb,$2) result`,[JSON.stringify(entries),'notes-schedule-'+minutes])).result;
+   await q(`SELECT xs_start_appointment_for_actor('c',$1,'coach',10)`,[saved.targetIds[0]]);
+   const a=await one(`SELECT ac."generalId"::text gid,a."updatedAt" FROM "CommonModel" ac JOIN "CourseAppointment" a ON a.company=ac.company AND a.id=ac."itemId" WHERE ac."objectId"=$1`,[saved.targetIds[0]]);
+   await q(`SELECT xs_lesson_complete('c',$1,'coach',10,$2,$3,NULL)`,[a.gid,a.updatedAt,'notes-completion-'+minutes]);
+   const tasks=await q(`SELECT *,xs_review_duration('c',"objectId") AS minutes FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1 ORDER BY kywrq`,[a.gid]);
+   assert.equal(tasks.length,10);assert.ok(tasks.every(t=>t.minutes===minutes&&t.rewardTaskKey&&!t.rewardDuplicate));
+   const source=await one(`SELECT d.* FROM "DailyStudyRecord" d JOIN "CommonModel" c ON c.company=d.company AND c."itemId"=d.id AND c."modelId"=56 WHERE c."generalId"=$1`,[tasks[0].xxjlid]);
+   assert.deepEqual(JSON.parse(source.xxqs),[]);assert.equal(Number(source.learned),0);
+   await db.exec('SAVEPOINT future_notes');await assert.rejects(complete(tasks[0].objectId),/未到期/);await db.exec('ROLLBACK TO SAVEPOINT future_notes');
+   assert.equal((await q(`SELECT * FROM "ReviewReward" WHERE "taskId"=$1`,[tasks[0].objectId])).length,0);
+  }
+ }finally{await db.exec('ROLLBACK');}
+});
+test('无单词的语法写作笔记复习沿用课时时长计薪,重复结束只有一张凭据',async()=>{
+ for(const minutes of [30,60]){
+  const name='notes-'+minutes;await task(name,{minutes});
+  await q('UPDATE "DailyStudyRecord" SET xxqs=$1 WHERE "objectId"=$2',['[]',name+'-study']);
+  await q('UPDATE "CourseAppointment" SET "courseCategoryKey"=$1 WHERE "objectId"=$2',['high_writing',name+'-appointment']);
+  const result=await complete(name);assert.equal(result.rewardStatus,'earned');assert.equal(Number(result.amount),minutes/60);
+  assert.equal((await complete(name)).unchanged,true);
+  assert.equal((await q('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name])).length,1);
+  assert.equal((await one('SELECT fxzt FROM "MemoryPracticeRecord" WHERE "objectId"=$1',[name])).fxzt,'1');
+ }
+});
 test('学生自行复习保存完成进度但零工资;老师随后重试不能补记工资,旧分组3会员也不计薪',async()=>{
  for(const group of [1,3]){
   await q('UPDATE "_User" SET "legacyGroupId"=$1 WHERE "objectId"=\'student\'',[group]);

+ 17 - 0
scripts/tests/review-student-names.test.mjs

@@ -39,3 +39,20 @@ test('老师复习列表待复习、已复习及翻页均返回姓名,保持
   const historical=await ctx.appReviewPage({status:'completed'},{get:()=>3});assert.equal(historical.items[0].honeyName,'历史学员');
  }finally{await db.close();}
 });
+test('无单词语法写作任务进入日历、今日统计和学员筛选,普通空记录仍隐藏',async()=>{
+ const db=new PGlite();try{
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  await db.exec(`INSERT INTO "DailyStudyRecord"(company,id,learned,xxqs,dsid,"userId") VALUES('co',101,0,'[]','200',20);
+   INSERT INTO "CommonModel"(company,"modelId","generalId","itemId",status) VALUES('co',56,100,101,99),('co',54,200,201,99);
+   INSERT INTO "CourseAppointment" VALUES('co',201,'20','30','high_writing');`);
+  const query=async(s,a)=>(await db.query(s,a)).rows;
+  const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});vm.runInContext(source,ctx);
+  const page=()=>ctx.appReviewPage({scope:'today',includeIncome:false},{get:()=>3});
+  const notes=await page();assert.equal(notes.items.length,2);assert.equal(notes.summary.todayTotal,2);assert.equal(notes.summary.todayPending,1);assert.equal(notes.summary.todayCompleted,1);
+  assert.equal(notes.students.length,1);assert.equal(notes.students[0].id,'20');assert.ok(notes.dates.length>0);
+  assert.ok(notes.items.every(r=>r.kc_title==='语法写作复习'));
+  for(const update of [`UPDATE "CourseAppointment" SET "courseCategoryKey"='word'`,`UPDATE "CourseAppointment" SET "courseCategoryKey"='high_writing',szyh='21'`,`UPDATE "CourseAppointment" SET szyh='20',dszt='10'`]){
+   await db.exec(update);const hidden=await page();assert.equal(hidden.items.length,0);assert.equal(hidden.summary.todayTotal,0);assert.equal(hidden.students.length,0);
+  }
+ }finally{await db.close();}
+});

+ 21 - 0
scripts/tests/writing-review-detail.test.mjs

@@ -0,0 +1,21 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+function context({category='primary_writing',learned=0,student=20,status=30,authorized=true,words='[]'}={}) {
+ let legacyCalls=0;
+ const ctx=vm.createContext({Parse:{},Psql:{query:async()=>[{company:'co',generalId:100,__addon:{userId:20,dsid:200,xxqs:words,learned}}]}});
+ vm.runInContext(source,ctx);
+ ctx.authorizeLearningRecordAccess=async()=>{if(!authorized)throw Error('无权查看');};
+ ctx.orderDetailRow=async()=>({szyh:student,dszt:status,courseCategoryKey:category});
+ ctx.legacyStudyRecordWords=async()=>{legacyCalls++;return[];};
+ return {run:()=>ctx.recordDetailData({id:100},{}),legacyCalls:()=>legacyCalls};
+}
+for(const category of ['primary_writing','middle_writing','high_writing'])test(category+' empty confirmed source is an intentional notes review',async()=>{
+ const c=context({category}),result=await c.run();assert.equal(result.words.length,0);assert.equal(result.addon[0].reviewContentMode,'notes');assert.equal(c.legacyCalls(),0);
+});
+for(const options of [{category:'word'},{learned:1},{student:21},{status:10}])test('missing words are not converted into notes: '+JSON.stringify(options),async()=>{
+ const c=context(options),result=await c.run();assert.equal(result.addon[0].reviewContentMode,undefined);assert.equal(c.legacyCalls(),1);
+});
+test('notes review still requires source record authorization',async()=>{const c=context({authorized:false});await assert.rejects(c.run(),/无权查看/);assert.equal(c.legacyCalls(),0);});