Selaa lähdekoodia

feat: unify lesson history and safely transfer pending reviews

彭峰 4 tuntia sitten
vanhempi
commit
a90eaafbf3
27 muutettua tiedostoa jossa 594 lisäystä ja 14 poistoa
  1. 48 0
      docs/qa/lesson-records-2026-10-13/README.md
  2. 12 0
      docs/qa/lesson-records-2026-10-13/browser-result.json
  3. 1 0
      projects/xiaoshu-admin/src/app/admin.constants.ts
  4. 1 0
      projects/xiaoshu-admin/src/app/admin.routes.ts
  5. 32 0
      projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html
  6. 21 0
      projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.ts
  7. 10 0
      projects/xiaoshu-admin/src/app/pages/lesson-record.models.ts
  8. 15 0
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.html
  9. 0 0
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.scss
  10. 35 0
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts
  11. 23 0
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts
  12. 2 0
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  13. 4 3
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  14. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html
  15. 3 1
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts
  16. 25 0
      scripts/check-lesson-records-browser.mjs
  17. 1 1
      scripts/cloud/legacy-direct-sync.js
  18. 70 0
      scripts/cloud/lesson-records.js
  19. 1 1
      scripts/cloud/mobile-teaching.js
  20. 11 3
      scripts/deploy-admin-functions.mjs
  21. 19 0
      scripts/migrate-lesson-records.mjs
  22. 55 0
      scripts/smoke-lesson-records.mjs
  23. 105 0
      scripts/sql/lesson-records.sql
  24. 6 0
      scripts/tests/fixtures/lesson-records-schema.sql
  25. 10 3
      scripts/tests/legacy-review-pull.test.mjs
  26. 76 0
      scripts/tests/lesson-records.test.mjs
  27. 6 0
      scripts/tests/review-assignment-access.test.mjs

+ 48 - 0
docs/qa/lesson-records-2026-10-13/README.md

@@ -0,0 +1,48 @@
+# 上课记录与抗遗忘转交验收
+
+版本:`2026-10-13-lesson-records-v10`。前端在本地验证,未发布前端;配套数据库迁移和云函数已发布并回读源码校验。
+
+## 入口与行为
+
+- 左侧「记录管理 → 上课记录」:姓名、会员 ID、授课老师、门店、课程和日期筛选,默认全部历史,北京时间倒序;按真实课次去重计数。
+- 每页 20 / 50 / 100;指定会员可查看全部;导出保持筛选条件,并检查跨页快照,变化时要求重试。
+- 会员详情复用同一列表;排课中心、会员详情和集中列表使用同一反馈详情。历史原始点评完整保留,未记录字段不猜测补写。
+- 管理员可转交本课或该学员全部已有未完成复习,包含逾期、今天、未来。预览展示数量、日期、原陪练和影响课次,必须填写原因。
+- 转交有版本校验、事务、幂等回执和逐任务审计。与复习完成使用同一任务行锁;新陪练可读取所接任务的来源词表,原陪练不能继续提交已转交任务。
+- 原授课老师、已完成任务、历史工资和后续新课默认陪练不变。新陪练完成任务按新归属计薪一次。
+- 夜间同步显式保护已转交任务;页面触发旧接口刷新也保护归属和完成状态,冲突留痕。
+
+## 验证结果
+
+| 检查 | 结果 |
+|---|---|
+| 管理后台测试 | 266 通过 |
+| 学生/老师端测试 | 218 通过 |
+| 服务端测试 | 192 通过 |
+| 双前端生产构建 | 通过;已有样式预算/CommonJS 警告 |
+| 云函数语法、动作覆盖 | 11 个定义有效,120 个调用动作无缺失 |
+| 线上隔离账号专项 | 16 项通过,包含并发、越权、停用陪练、旧同步保护和工资归属 |
+| 线上教学业务回归 | 74 项通过;隔离数据已清理 |
+| 本地浏览器连接真实后端 | 7 项通过:跨页、全部历史、日期、导出行数、反馈、窄屏、会员入口复用 |
+| 部署回读 | 两版运营网关、移动端网关、直接同步函数均与本次代码一致;原通用管理网关未变 |
+
+专项数据库测试覆盖同课次重复来源合并、55 节历史分页、姓名与日期组合计数、完整反馈、两种转交范围、逾期任务、重复请求、旧预览失效、完成/转交竞争、停用目标和跨帐套拒绝。线上并发测试使用独立请求连接;不会转交真实学员任务来做测试。
+
+## 历史核对与定向补齐
+
+- 截图会员 ID 1857:旧系统 4 节,新系统 4 节,逐条业务 ID 和反馈匹配。
+- 客户口述姓名在旧系统实际匹配会员 ID 1825:初查旧 51、新 50;缺少 2026-10-13 20:38 的课次 877550(预约 875450)。
+- 核实旧接口原始数据和真实师生身份,备份后进行事务回滚演练,再定向补齐 1 条学习、1 条上课、10 条已有未完成复习。
+- 对应预约为未修改的原始导入记录(创建/修改时间一致、无新教学凭据);事务锁内再次比对完整快照后同步旧状态。未重新结课或重新扣费。
+- 重跑命中审计回执,无重复新增。回读旧/新均 51 节,51 条业务 ID 和反馈全部匹配;余额、充值流水和复习工资前后相同。
+- 51 条真实历史已浏览器验证翻页、查看全部、倒序及 Excel 行数一致。
+
+## 部署与证据
+
+迁移入口:`node scripts/migrate-lesson-records.mjs --apply`,需要受保护环境里的主密钥和有效管理会话;迁移只建结构/函数,不自动转交现有任务。数据库迁移完成后发布两版运营网关、移动端网关和直接同步函数。
+
+备份、部署校验、隔离验收结果、定向补齐前后数据和包含真实资料的截图/Excel 保存在本机受保护的 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-13-lesson-records-v10/`,不提交真实反馈、会话令牌或密钥。
+
+## 独立存在的同步阻塞
+
+Mac 23:00 LaunchAgent 已恢复并确认在调度。今晚全量同步因 `legacy_account_identity_conflict` 安全中止;本次定向补齐不能代表全量同步成功,未伪造检查点或完成状态。账号身份冲突仍需单独核对处理。

+ 12 - 0
docs/qa/lesson-records-2026-10-13/browser-result.json

@@ -0,0 +1,12 @@
+{
+  "passed": true,
+  "checks": [
+    "集中查询入口、真实数据与累计课次",
+    "超过一页的真实记录可以翻页",
+    "全部历史行数与累计一致、北京时间倒序",
+    "真实筛选结果导出 Excel,行数与课次一致",
+    "完整反馈详情与有效陪练选择;未提交真实转交",
+    "窄屏无整页横向溢出",
+    "会员详情复用同一列表且不重复分页"
+  ]
+}

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -27,6 +27,7 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
     { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
     { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
     { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
   ] },
   ] },
+  { label: '记录管理', items: [{ label: '上课记录', icon: 'clipboard-list', route: '/admin/lessons' }] },
   { label: '内容运营', items: [
   { label: '内容运营', items: [
     { label: '词库管理', icon: 'languages', route: '/admin/vocabulary' },
     { label: '词库管理', icon: 'languages', route: '/admin/vocabulary' },
     { label: '专项训练', icon: 'book-open', route: '/admin/special-training' },
     { label: '专项训练', icon: 'book-open', route: '/admin/special-training' },

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -12,6 +12,7 @@ const operationsChildren: Routes = [
   { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
   { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
+  { path: 'lessons', data: { title: '上课记录', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/lesson-records.component').then(m => m.LessonRecordsComponent) },
   { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
   { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },

+ 32 - 0
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html

@@ -0,0 +1,32 @@
+<div class="backdrop" (click)="close()"></div><aside class="drawer lesson-drawer" role="dialog" aria-modal="true" aria-label="上课记录详情" (keydown.escape)="close()">
+<header><div><h2>上课记录详情</h2><p class="muted">课堂反馈与抗遗忘安排</p></div><button class="ui-button secondary" [disabled]="busy()" (click)="close()">关闭</button></header>
+@if(error()){<p class="notice error" role="alert">{{error()}}</p>}@if(message()){<p class="notice" role="status">{{message()}}</p>}
+@if(busy()){<p role="status">正在处理…</p>}
+@if(data();as d){
+<section class="lesson-identity"><h3>{{d.studentName}} <small>会员 ID {{d.studentId}}</small></h3><p>{{d.courseName}}</p><p>{{d.lessonAt | date:'yyyy-MM-dd HH:mm':'+0800'}}(北京时间) · {{d.classTypeLabel}}</p><p>授课老师:{{d.coachName}}</p></section>
+@if(d.duplicateCount>1){<p class="notice error">发现 {{d.duplicateCount}} 条同课次原始记录,课次数按一节统计,请核对。</p>}
+@if(!d.hasReport){<p class="muted">历史课次的原始反馈保留在老师点评中,未分项记录的内容显示“未记录”。</p>}
+<div class="feedback-grid">
+@if(!d.hasReport){<article class="wide"><h3>老师点评</h3><p>{{d.coachComment||'未记录'}}</p></article>}
+@for(field of [['学习内容','contentSummary'],['复习情况','reviewStatus'],['复习说明','reviewSummary'],['训练进度','trainingStatus'],['进度说明','trainingSummary']];track field[1]){<article><h3>{{field[0]}}</h3><p>{{report(field[1])}}</p></article>}
+<article><h3>作业</h3><p>{{d.report?.['noHomework'] ? '本次无作业' : report('homework')}}</p></article>
+@if(d.hasReport){<article class="wide"><h3>老师点评</h3><p>{{d.coachComment||'未记录'}}</p></article>}
+<article class="wide"><h3>学生评价</h3><p>{{d.studentReview.score ? d.studentReview.score+' 分' : '尚未评分'}} · {{d.studentReview.comment||'暂无文字评价'}}</p></article>
+<article class="wide"><h3>备注</h3><p>{{report('notes')}}</p></article>
+</div>
+<section class="review-section"><header><h3>抗遗忘复习安排</h3>@if(d.canTransfer){<button class="ui-button secondary" [disabled]="busy()" (click)="beginTransfer()">更换抗遗忘陪练</button>}</header><p class="muted">已完成任务和工资归属保留原记录。</p>
+<div class="table-scroll"><table class="data-table review-table"><thead><tr><th>计划日期</th><th>复习陪练</th><th>状态</th></tr></thead><tbody>@for(t of d.tasks;track t.objectId){<tr><td>{{t.plannedDate}}</td><td>{{t.coachName}}</td><td>{{t.status==='1'?'已完成':'未完成'}}</td></tr>}@empty{<tr><td colspan="3">本课暂无关联复习任务</td></tr>}</tbody></table></div>
+</section>
+@if(transferring()){
+<section class="transfer-panel"><h3>转交未完成复习</h3><p>包含逾期、今天和未来的已有任务;后续新课仍由该课授课老师负责。</p>
+<div class="form-grid"><label>范围<select [(ngModel)]="scope" (ngModelChange)="resetPreview()" [disabled]="busy()"><option value="lesson" [disabled]="!d.appointmentId">本节课未完成复习</option><option value="student">该学员全部未完成复习</option></select></label>
+<label>目标陪练<select [(ngModel)]="coachId" (ngModelChange)="resetPreview()" [disabled]="busy()"><option [ngValue]="0">请选择陪练</option>@for(c of coaches();track c.userId){<option [ngValue]="c.userId">{{c.displayName}} · {{c.userId}}</option>}</select></label>
+<label class="wide">转交原因<textarea maxlength="1000" [(ngModel)]="reason" (ngModelChange)="resetPreview()" [disabled]="busy()" placeholder="例如:原老师离岗,由新老师接手复习"></textarea></label></div>
+<button class="ui-button secondary" (click)="checkTransfer()" [disabled]="busy()">预览受影响任务</button>
+@if(preview();as p){<div class="notice"><strong>将转交 {{p.count}} 次复习,涉及 {{p.lessonCount}} 节课</strong><p>{{p.dateFrom}} 至 {{p.dateTo}}</p>@if(p.unlinkedCount){<p>{{p.unlinkedCount}} 条任务缺少课次关联,但学员归属已核实。</p>}
+<details><summary>查看全部任务与原陪练</summary>@for(t of p.tasks;track t.taskId){<p>{{t.plannedDate}} · {{t.coachName}} · 课次 {{t.appointmentId||'未关联'}}</p>}</details></div>
+<button class="ui-button" (click)="saveTransfer()" [disabled]="busy()||!p.count">确认保存转交</button>}
+<button class="ui-button secondary" [disabled]="busy()" (click)="transferring.set(false)">取消</button></section>}
+@if(d.audit.length){<details class="audit-section"><summary>转交审计({{d.audit.length}} 条任务记录)</summary>@for(a of d.audit;track a['objectId']){<p>{{$any(a['createdAt'])|date:'yyyy-MM-dd HH:mm':'+0800'}} · {{a['oldCoachId']}} → {{a['newCoachId']}} · {{a['reason']}}</p>}</details>}
+}@else if(!busy()){<button class="ui-button secondary" (click)="load()">重新加载</button>}
+</aside>

+ 21 - 0
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.ts

@@ -0,0 +1,21 @@
+import {CommonModule} from '@angular/common';
+import {Component,ChangeDetectionStrategy,OnChanges,Input,Output,EventEmitter,inject,signal} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {OperationsService} from '../operations.service';
+import {LessonHistoryDetail,TransferPreview} from './lesson-record.models';
+@Component({selector:'app-lesson-record-detail',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./lesson-record-detail.component.html',styleUrls:['./operations-professional.scss','./lesson-records.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+export class LessonRecordDetailComponent implements OnChanges {
+ @Input() lessonId='';@Input() appointmentId='';@Output() closed=new EventEmitter<void>();@Output() changed=new EventEmitter<void>();
+ readonly data=signal<LessonHistoryDetail|null>(null);readonly busy=signal(false);readonly error=signal('');readonly message=signal('');
+ readonly transferring=signal(false);readonly preview=signal<TransferPreview|null>(null);readonly coaches=signal<{userId:number;displayName:string}[]>([]);
+ scope='lesson';coachId=0;reason='';private key='';private serial=0;private readonly ops=inject(OperationsService);
+ ngOnChanges():void{void this.load();}
+ async load():Promise<void>{const serial=++this.serial;this.busy.set(true);this.error.set('');try{const d=await this.ops.request<LessonHistoryDetail>('ops/lessons/detail',{lessonId:this.lessonId,appointmentId:this.appointmentId});if(serial===this.serial)this.data.set(d);}catch(e){if(serial===this.serial)this.error.set(e instanceof Error?e.message:'详情加载失败');}finally{if(serial===this.serial)this.busy.set(false);}}
+ async beginTransfer():Promise<void>{this.transferring.set(true);if(!this.data()?.appointmentId)this.scope='student';this.resetPreview();this.busy.set(true);this.error.set('');try{const rows=await this.ops.request<{items:{userId:number;displayName:string}[]}>('ops/lessons/transfer-coaches');this.coaches.set(rows.items);}catch(e){this.error.set(e instanceof Error?e.message:'陪练列表加载失败');}finally{this.busy.set(false);}}
+ resetPreview():void{this.preview.set(null);this.key='';}
+ params():Record<string,unknown>{return{studentId:this.data()?.studentId,appointmentId:this.data()?.appointmentId,scope:this.scope,coachId:this.coachId};}
+ async checkTransfer():Promise<void>{if(!this.coachId||this.reason.trim().length<2){this.error.set('请选择目标陪练,并填写至少两个字的转交原因');return;}this.busy.set(true);this.error.set('');this.preview.set(null);try{this.preview.set(await this.ops.request<TransferPreview>('ops/lessons/transfer-preview',this.params()));this.key=crypto.randomUUID();}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.busy.set(false);}}
+ async saveTransfer():Promise<void>{const p=this.preview();if(!p?.count||this.busy())return;this.busy.set(true);this.error.set('');try{const r=await this.ops.request<{count:number}>('ops/lessons/transfer',{...this.params(),version:p.version,reason:this.reason.trim(),idempotencyKey:this.key});this.message.set(`已转交 ${r.count} 次未完成复习`);this.transferring.set(false);this.resetPreview();await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'保存失败,可用同一请求重试');}finally{this.busy.set(false);}}
+ report(key:string):string{const v=this.data()?.report?.[key];return v===undefined||v===null||v===''?'未记录':String(v);}
+ close():void{if(!this.busy())this.closed.emit();}
+}

+ 10 - 0
projects/xiaoshu-admin/src/app/pages/lesson-record.models.ts

@@ -0,0 +1,10 @@
+export interface LessonHistoryRow {
+ targetId:string;lessonId:string;generalId:number;appointmentId:string;studentId:number;studentName:string;
+ coachId:number;coachName:string;courseName:string;storeName:string;classTypeLabel:string;lessonAt:string;
+ score:number;coachComment:string;studentComment:string;feedbackSummary:string;pendingReviews:number;
+ reviewCoachLabel:string;reviewCoaches:{id:string;name:string;count:number}[];duplicateCount:number;
+}
+export interface LessonHistoryPage {items:LessonHistoryRow[];total:number;cumulativeTotal:number;page:number;pageSize:number;refreshedAt:string;version:string}
+export interface ReviewTask {objectId:string;coachId:number;coachName:string;plannedDate:string;status:string;completedAt:string}
+export interface LessonHistoryDetail extends LessonHistoryRow {report:Record<string,unknown>|null;studentReview:{score:number;comment:string};tasks:ReviewTask[];audit:Record<string,unknown>[];canTransfer:boolean;hasReport:boolean}
+export interface TransferPreview {version:string;count:number;lessonCount:number;unlinkedCount:number;dateFrom:string;dateTo:string;tasks:{taskId:string;coachId:string;coachName:string;plannedDate:string;appointmentId:string}[]}

+ 15 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.html

@@ -0,0 +1,15 @@
+<section class="lesson-history">
+<header class="page-header"><div><h1>{{embedded?'上课记录':'记录管理 · 上课记录'}}</h1><p>查询完整上课历史、查看课堂反馈与转交抗遗忘复习</p></div><button class="ui-button secondary" (click)="export()" [disabled]="exporting()||busy()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header>
+<form class="lesson-filters" (ngSubmit)="query()">
+@if(!embedded){<label>学员姓名<input name="search" [(ngModel)]="search" placeholder="输入学员姓名"></label><label>会员 ID<input name="member" [(ngModel)]="memberId" inputmode="numeric" placeholder="精确查询"></label>}
+<label>授课老师<input name="coach" [(ngModel)]="coachName" placeholder="老师姓名"></label><label>门店<input name="store" [(ngModel)]="storeName" placeholder="门店名称"></label><label>课程<input name="course" [(ngModel)]="courseName" placeholder="课程名称"></label><label>开始日期<input type="date" name="from" [(ngModel)]="dateFrom"></label><label>结束日期<input type="date" name="to" [(ngModel)]="dateTo"></label>
+<div class="filter-actions"><button type="submit" class="ui-button" [disabled]="busy()">查询</button><button type="button" class="ui-button secondary" [disabled]="busy()" (click)="reset()">重置</button></div></form>
+@if(error()){<p role="alert" class="notice error">{{error()}}</p>}@if(busy()){<p class="notice" role="status">正在查询上课记录…</p>}
+@if(data();as d){<div class="record-count"><strong>累计已上课 {{d.cumulativeTotal}} 节</strong><span>当前筛选 {{d.total}} 节 · 时间均为北京时间</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()" [disabled]="busy()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label>@if(studentId||memberId){<button class="ui-button secondary" [disabled]="busy()" (click)="showAll()">查看该学员全部结果</button>}</div>
+<section class="content-card"><div class="table-scroll"><table class="data-table lesson-table"><thead><tr><th>学员 / 课程</th><th>上课时间 ↓</th><th>授课老师 / 课型</th><th>反馈摘要</th><th>当前复习陪练</th><th>操作</th></tr></thead><tbody>
+@for(row of d.items;track row.lessonId){<tr><td><strong>{{row.studentName}}</strong><small>ID {{row.studentId}} · {{row.storeName}}</small><p>{{row.courseName}}</p></td><td class="time-cell">{{row.lessonAt | date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.coachName}}<small>{{row.classTypeLabel}}</small></td><td><p class="feedback-excerpt">{{row.feedbackSummary||'未记录'}}</p>@if(row.duplicateCount>1){<small class="exception">同课次有重复来源,已合并计数</small>}</td><td>{{row.reviewCoachLabel}}<small>待复习 {{row.pendingReviews}} 次</small></td><td><button class="ui-button secondary compact" (click)="detailId.set(row.lessonId)">查看反馈</button></td></tr>}
+@empty{<tr><td colspan="6"><div class="empty">没有符合条件的上课记录,可调整姓名或日期后查询。</div></td></tr>}
+</tbody></table></div></section>
+<div class="lesson-pagination"><span>已显示 {{d.items.length}} 条 / 共 {{d.total}} 条</span>@if(!all){<div><button class="ui-button secondary" [disabled]="busy()||page<=1" (click)="turn(-1)">上一页</button><span>第 {{page}} 页</span><button class="ui-button secondary" [disabled]="busy()||page*pageSize>=d.total" (click)="turn(1)">下一页</button></div>}</div>}
+@if(detailId()){<app-lesson-record-detail [lessonId]="detailId()" (closed)="detailId.set('')" (changed)="load()" />}
+</section>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.scss


+ 35 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -0,0 +1,35 @@
+import {TestBed} from '@angular/core/testing';
+import {ActivatedRoute,convertToParamMap} from '@angular/router';
+import {OperationsService} from '../operations.service';
+import {LessonRecordsComponent} from './lesson-records.component';
+import {LessonRecordDetailComponent} from './lesson-record-detail.component';
+describe('上课记录与复习转交',()=>{
+ let ops:jasmine.SpyObj<OperationsService>;
+ beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['request']);TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}]});});
+ const page=(items:unknown[],total=2)=>({items,total,cumulativeTotal:51,page:1,pageSize:100,version:'snapshot1'});
+ it('单个会员查看全部连续读取分页,保持筛选与实际总课次数',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.studentId=20;c.all=true;c.coachName='钱老师';
+  ops.request.and.returnValues(Promise.resolve(page([{lessonId:'a'}])),Promise.resolve(page([{lessonId:'b'}])));
+  await c.load();expect(c.data()?.items.length).toBe(2);expect(c.data()?.cumulativeTotal).toBe(51);expect(ops.request.calls.mostRecent().args[1]).toEqual(jasmine.objectContaining({studentId:20,coachName:'钱老师',page:2}));
+ });
+ it('后续页失败时清除不完整结果并允许重试',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.studentId=20;c.all=true;
+  ops.request.and.returnValues(Promise.resolve(page([{lessonId:'a'}])),Promise.reject(new Error('网络中断')));
+  await c.load();expect(c.data()).toBeNull();expect(c.error()).toBe('网络中断');expect(c.busy()).toBeFalse();
+ });
+ it('查询默认没有月份限制;切换条件清除查看全部',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());expect(c.params()['dateFrom']).toBe('');expect(c.params()['dateTo']).toBe('');
+  c.all=true;c.page=4;ops.request.and.resolveTo(page([]));c.query();expect(c.page).toBe(1);expect(c.all).toBeFalse();
+ });
+ it('转交预览失败不保留旧预览;未选择目标或原因不能提交',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordDetailComponent());await c.checkTransfer();expect(ops.request).not.toHaveBeenCalled();
+  c.coachId=11;c.reason='老师交接';c.preview.set({version:'old',count:2} as never);ops.request.and.rejectWith(new Error('任务变化'));
+  await c.checkTransfer();expect(c.preview()).toBeNull();expect(c.error()).toBe('任务变化');
+ });
+ it('保存失败保留同一幂等请求,重复点击仅提交一次;成功后回读',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordDetailComponent());c.data.set({studentId:20,appointmentId:'1000'} as never);c.coachId=11;c.reason='老师交接';
+  ops.request.and.resolveTo({version:'v1',count:2});await c.checkTransfer();ops.request.and.rejectWith(new Error('断网'));
+  await c.saveTransfer();const first=ops.request.calls.mostRecent().args[1];expect(c.preview()?.version).toBe('v1');await c.saveTransfer();expect(ops.request.calls.mostRecent().args[1]?.['idempotencyKey']).toBe(first?.['idempotencyKey']);
+  ops.request.and.returnValues(Promise.resolve({count:2}),Promise.resolve({studentId:20,appointmentId:'1000'}));await c.saveTransfer();expect(c.preview()).toBeNull();expect(c.message()).toContain('2');
+ });
+});

+ 23 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts

@@ -0,0 +1,23 @@
+import {CommonModule} from '@angular/common';
+import {Component,ChangeDetectionStrategy,Input,OnChanges,OnInit,inject,signal} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {ActivatedRoute} from '@angular/router';
+import {OperationsService} from '../operations.service';
+import {LessonRecordDetailComponent} from './lesson-record-detail.component';
+import {LessonHistoryPage,LessonHistoryRow} from './lesson-record.models';
+@Component({selector:'app-lesson-records',standalone:true,imports:[CommonModule,FormsModule,LessonRecordDetailComponent],templateUrl:'./lesson-records.component.html',styleUrls:['./operations-professional.scss','./lesson-records.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+export class LessonRecordsComponent implements OnInit,OnChanges {
+ @Input() studentId=0;@Input() embedded=false;@Input() refreshVersion=0;
+ readonly data=signal<LessonHistoryPage|null>(null);readonly busy=signal(false);readonly error=signal('');readonly detailId=signal('');readonly exporting=signal(false);
+ page=1;pageSize=20;search='';memberId='';coachName='';storeName='';courseName='';dateFrom='';dateTo='';all=false;
+ private readonly ops=inject(OperationsService);private readonly route=inject(ActivatedRoute);private serial=0;private ready=false;
+ ngOnInit():void{this.ready=true;this.memberId=this.route.snapshot.queryParamMap.get('studentId')||'';void this.load();}
+ ngOnChanges():void{if(this.ready){this.page=1;this.all=false;void this.load();}}
+ params():Record<string,unknown>{return{studentId:this.studentId||this.memberId,search:this.search,coachName:this.coachName,storeName:this.storeName,courseName:this.courseName,dateFrom:this.dateFrom,dateTo:this.dateTo};}
+ async load():Promise<void>{const serial=++this.serial;this.busy.set(true);this.error.set('');const params=this.params();try{let d=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:this.page,pageSize:this.all?100:this.pageSize});if(this.all){const items=[...d.items];for(let p=2;items.length<d.total;p++){if(serial!==this.serial)return;const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:p,pageSize:100});if(!next.items.length||next.total!==d.total||next.version!==d.version)throw Error('记录发生变化,请重新查询');items.push(...next.items);}d={...d,items};}if(serial===this.serial)this.data.set(d);}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'查询失败');}}finally{if(serial===this.serial)this.busy.set(false);}}
+ query():void{this.page=1;this.all=false;void this.load();}
+ reset():void{this.search='';this.memberId='';this.coachName='';this.storeName='';this.courseName='';this.dateFrom='';this.dateTo='';this.query();}
+ showAll():void{if(!(this.studentId||this.memberId))return;this.all=true;this.page=1;void this.load();}
+ turn(delta:number):void{this.page+=delta;void this.load();}
+ async export():Promise<void>{if(this.exporting())return;this.exporting.set(true);this.error.set('');const params=this.params();try{const first=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:1,pageSize:100});const rows:LessonHistoryRow[]=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page,pageSize:100});if(!next.items.length||next.total!==first.total||next.version!==first.version)throw Error('导出期间记录发生变化,请重试');rows.push(...next.items);}if(new Set(rows.map(r=>r.lessonId)).size!==rows.length)throw Error('导出期间排序变化,请重试');const xlsx=await import('xlsx');const sheet=xlsx.utils.json_to_sheet(rows.map(r=>({'会员 ID':r.studentId,'学员':r.studentName,'课程':r.courseName,'门店':r.storeName,'授课老师':r.coachName,'上课时间(北京时间)':r.lessonAt?new Date(r.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'未记录','课型':r.classTypeLabel,'老师点评':r.coachComment,'学生评分':r.score||'未评分','学生评价':r.studentComment,'反馈摘要':r.feedbackSummary,'复习陪练':r.reviewCoaches.map(c=>c.name).join('、'),'待复习次数':r.pendingReviews})));const book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,sheet,'上课记录');xlsx.writeFile(book,'上课记录.xlsx');}catch(e){this.error.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
+}

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -204,3 +204,5 @@
     <section class="feedback-sections"><article><span>预约编号</span><p>{{report['appointmentGeneralId']}}</p></article><article><span>课时处理</span><p>{{report['creditState']}} · 有效记录 {{report['lessonCount']}} 条</p></article><article><span>学生评价</span><p>{{$any(report['studentReview'])?.score ? $any(report['studentReview']).score+' 分' : '尚未评价'}} {{$any(report['studentReview'])?.comment}}</p></article>@if(report['issue']){<article role="alert"><span>待核对异常</span><p>{{report['issue']}}</p></article>}@if(!report['hasReport']){<article><p>尚未生成完课报告</p></article>}<article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>老师点评</span><p>{{report['coachComment']||'无'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
     <section class="feedback-sections"><article><span>预约编号</span><p>{{report['appointmentGeneralId']}}</p></article><article><span>课时处理</span><p>{{report['creditState']}} · 有效记录 {{report['lessonCount']}} 条</p></article><article><span>学生评价</span><p>{{$any(report['studentReview'])?.score ? $any(report['studentReview']).score+' 分' : '尚未评价'}} {{$any(report['studentReview'])?.comment}}</p></article>@if(report['issue']){<article role="alert"><span>待核对异常</span><p>{{report['issue']}}</p></article>}@if(!report['hasReport']){<article><p>尚未生成完课报告</p></article>}<article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>老师点评</span><p>{{report['coachComment']||'无'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
   </aside>
   </aside>
 }
 }
+
+@if(lessonAppointmentId()){<app-lesson-record-detail [appointmentId]="lessonAppointmentId()" (closed)="lessonAppointmentId.set('')" />}

+ 4 - 3
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -1,3 +1,4 @@
+import { LessonRecordDetailComponent } from './lesson-record-detail.component';
 import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
 import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { ActivatedRoute } from '@angular/router';
 import { ActivatedRoute } from '@angular/router';
@@ -125,9 +126,9 @@ export function scheduleCoachBusy(
   });
   });
 }
 }
 
 
-@Component({ selector:'app-operations-schedule', standalone:true, imports: [AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
+@Component({ selector:'app-operations-schedule', standalone:true, imports: [LessonRecordDetailComponent,AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit, OnDestroy {
 export class OperationsScheduleComponent implements OnInit, OnDestroy {
-  readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly detail=signal<ScheduleAppointment|null>(null); readonly cancelReason=signal(''); readonly detailError=signal(''); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
+  readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly lessonAppointmentId=signal(''); readonly feedback=signal<Record<string,unknown>|null>(null); readonly detail=signal<ScheduleAppointment|null>(null); readonly cancelReason=signal(''); readonly detailError=signal(''); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null); readonly checking=signal(false);
   readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null); readonly checking=signal(false);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService); private readonly liveRefresh=inject(OperationsLiveRefreshService);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService); private readonly liveRefresh=inject(OperationsLiveRefreshService);
@@ -177,7 +178,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
   openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
   openEdit(item:ScheduleAppointment):void{++this.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   openEdit(item:ScheduleAppointment):void{++this.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   closeDrawer():void{++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
   closeDrawer():void{++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
-  async viewFeedback(item:ScheduleAppointment):Promise<void>{if(item.readOnly){this.message.set('这条排课来自旧系统实时接口;完课反馈尚未同步到新库,请在原业务端查看。');return;}this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
+  async viewFeedback(item:ScheduleAppointment):Promise<void>{this.lessonAppointmentId.set(String(item.generalId));}
   async viewWorkflowIssue(targetId:string):Promise<void>{this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'异常详情加载失败');}}
   async viewWorkflowIssue(targetId:string):Promise<void>{this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'异常详情加载失败');}}
   closeFeedback():void{this.feedback.set(null);}
   closeFeedback():void{this.feedback.set(null);}
   setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
   setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html

@@ -38,11 +38,11 @@
       <section class="panel"><header><div><h2>已开课程</h2><span>查看学习进度,取消绑定进入回收站并可恢复</span></div></header>@if (detail.permissions.canEdit) { <app-member-enrollment [studentId]="detail.userId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /> }@if(detail.permissions.canManage){<label class="course-reason"><span>课程回收 / 恢复原因</span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="操作下方已开课程前填写,至少 2 个字" maxlength="500" /></label>}<div class="record-table"><table><thead><tr><th>分类与课程</th><th>已学/内容数量</th><th>进度</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().courses; track row['targetId']) { <tr><td>{{ row['categoryName'] || '待确认分类' }} · {{ row['courseName'] || '未命名课程' }}</td><td>{{ row['learnedCount'] || 0 }} / {{ row['openedCount'] || 0 }}</td><td>{{ row['progressStage'] || '未记录' }}</td><td>{{ row['enrollmentStatus'] === 'pending' ? '分类待确认' : row['statusLabel'] }}</td><td>@if (detail.permissions.canManage) { <button type="button" (click)="toggleCourse(row)" [disabled]="saving() || sectionLoading()">{{ $any(row['status']) === -2 ? '恢复' : '移入回收站' }}</button> }</td></tr> } @empty { <tr><td colspan="5">暂无课程绑定</td></tr> }</tbody></table></div></section>
       <section class="panel"><header><div><h2>已开课程</h2><span>查看学习进度,取消绑定进入回收站并可恢复</span></div></header>@if (detail.permissions.canEdit) { <app-member-enrollment [studentId]="detail.userId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /> }@if(detail.permissions.canManage){<label class="course-reason"><span>课程回收 / 恢复原因</span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="操作下方已开课程前填写,至少 2 个字" maxlength="500" /></label>}<div class="record-table"><table><thead><tr><th>分类与课程</th><th>已学/内容数量</th><th>进度</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().courses; track row['targetId']) { <tr><td>{{ row['categoryName'] || '待确认分类' }} · {{ row['courseName'] || '未命名课程' }}</td><td>{{ row['learnedCount'] || 0 }} / {{ row['openedCount'] || 0 }}</td><td>{{ row['progressStage'] || '未记录' }}</td><td>{{ row['enrollmentStatus'] === 'pending' ? '分类待确认' : row['statusLabel'] }}</td><td>@if (detail.permissions.canManage) { <button type="button" (click)="toggleCourse(row)" [disabled]="saving() || sectionLoading()">{{ $any(row['status']) === -2 ? '恢复' : '移入回收站' }}</button> }</td></tr> } @empty { <tr><td colspan="5">暂无课程绑定</td></tr> }</tbody></table></div></section>
     }
     }
     @case ('appointments') { <section class="panel"><header><div><h2>预约记录</h2><span>查看课程、陪练、预约时间和业务状态</span></div></header><div class="record-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>预约时间</th><th>课型</th><th>状态</th></tr></thead><tbody>@for (row of related().appointments; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] || '待分配' }}</td><td>{{ $any(row['appointmentAt'] || row['startsAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['statusLabel'] }}</td></tr> } @empty { <tr><td colspan="5">暂无预约记录</td></tr> }</tbody></table></div></section> }
     @case ('appointments') { <section class="panel"><header><div><h2>预约记录</h2><span>查看课程、陪练、预约时间和业务状态</span></div></header><div class="record-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>预约时间</th><th>课型</th><th>状态</th></tr></thead><tbody>@for (row of related().appointments; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] || '待分配' }}</td><td>{{ $any(row['appointmentAt'] || row['startsAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['statusLabel'] }}</td></tr> } @empty { <tr><td colspan="5">暂无预约记录</td></tr> }</tbody></table></div></section> }
-    @case ('lessons') { <section class="panel"><header><div><h2>上课记录</h2><span>仅展示真实上课事实、老师和评价</span></div></header><div class="record-table lessons-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>上课时间</th><th>课型</th><th>评分</th><th>评价</th></tr></thead><tbody>@for (row of related().lessons; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ $any(row['lessonAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['score'] || '—' }}</td><td>{{ row['comment'] || '—' }}</td></tr> } @empty { <tr><td colspan="6">暂无上课记录</td></tr> }</tbody></table></div></section> }
+    @case ('lessons') { <app-lesson-records [studentId]="detail.userId" [embedded]="true" [refreshVersion]="refreshVersion()" /> }
     @case ('reports') { <section class="panel"><header><div><h2>单词学习报表</h2><span>查看该会员每次学习的掌握情况和复习计划</span></div><a class="primary action-link" routerLink="/admin/learning-reports" [queryParams]="{ studentObjectId: detail.objectId }">进入报表中心</a></header><div class="date-filters"><app-operations-date-filter label="开始日期" [value]="reportFrom()" (valueChange)="reportFrom.set($event)" /><app-operations-date-filter label="结束日期" [value]="reportTo()" (valueChange)="reportTo.set($event)" /><button type="button" class="primary" (click)="filterReports()" [disabled]="sectionLoading()">查询</button><button type="button" (click)="filterReports(true)" [disabled]="sectionLoading()">重置</button></div><div class="record-table"><table><thead><tr><th>学习日期</th><th>课程</th><th>陪练老师</th><th>学习结果</th><th>掌握率</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().reports; track row['targetId']) { <tr><td>{{ row['studyDate'] || '日期缺失' }}</td><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ row['learnedCount'] }} 个 · 掌握 {{ row['masteredCount'] }} · 待复习 {{ row['reviewDueCount'] }}</td><td>{{ row['masteryRate'] }}%</td><td>{{ row['statusLabel'] }}</td><td><a class="action-link compact" [routerLink]="['/admin/learning-reports']" [queryParams]="{ studentObjectId: detail.objectId, report: row['targetId'] }">查看报表</a></td></tr> } @empty { <tr><td colspan="7">暂无学习报表</td></tr> }</tbody></table></div></section> }
     @case ('reports') { <section class="panel"><header><div><h2>单词学习报表</h2><span>查看该会员每次学习的掌握情况和复习计划</span></div><a class="primary action-link" routerLink="/admin/learning-reports" [queryParams]="{ studentObjectId: detail.objectId }">进入报表中心</a></header><div class="date-filters"><app-operations-date-filter label="开始日期" [value]="reportFrom()" (valueChange)="reportFrom.set($event)" /><app-operations-date-filter label="结束日期" [value]="reportTo()" (valueChange)="reportTo.set($event)" /><button type="button" class="primary" (click)="filterReports()" [disabled]="sectionLoading()">查询</button><button type="button" (click)="filterReports(true)" [disabled]="sectionLoading()">重置</button></div><div class="record-table"><table><thead><tr><th>学习日期</th><th>课程</th><th>陪练老师</th><th>学习结果</th><th>掌握率</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().reports; track row['targetId']) { <tr><td>{{ row['studyDate'] || '日期缺失' }}</td><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ row['learnedCount'] }} 个 · 掌握 {{ row['masteredCount'] }} · 待复习 {{ row['reviewDueCount'] }}</td><td>{{ row['masteryRate'] }}%</td><td>{{ row['statusLabel'] }}</td><td><a class="action-link compact" [routerLink]="['/admin/learning-reports']" [queryParams]="{ studentObjectId: detail.objectId, report: row['targetId'] }">查看报表</a></td></tr> } @empty { <tr><td colspan="7">暂无学习报表</td></tr> }</tbody></table></div></section> }
     @case ('balances') { <section class="panel"><header><div><h2>课时与流水</h2><span>门店划拨与退回原门店均记录操作原因</span></div></header><app-member-credit-account [objectId]="detail.objectId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /><h3 class="record-heading">历史课时明细</h3><div class="record-table"><table><thead><tr><th>项目</th><th>变化</th><th>调整前</th><th>余额</th><th>时间</th><th>说明</th></tr></thead><tbody>@for (row of related().balances; track row['targetId']) { <tr><td>{{ row['moneyTypeLabel'] }}</td><td>{{ row['score'] }}</td><td>{{ row['scoreBefore'] }}</td><td>{{ row['balanceAfter'] }}</td><td>{{ $any(row['happenedAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['detail'] }}</td></tr> } @empty { <tr><td colspan="6">暂无课时流水</td></tr> }</tbody></table></div></section> }
     @case ('balances') { <section class="panel"><header><div><h2>课时与流水</h2><span>门店划拨与退回原门店均记录操作原因</span></div></header><app-member-credit-account [objectId]="detail.objectId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /><h3 class="record-heading">历史课时明细</h3><div class="record-table"><table><thead><tr><th>项目</th><th>变化</th><th>调整前</th><th>余额</th><th>时间</th><th>说明</th></tr></thead><tbody>@for (row of related().balances; track row['targetId']) { <tr><td>{{ row['moneyTypeLabel'] }}</td><td>{{ row['score'] }}</td><td>{{ row['scoreBefore'] }}</td><td>{{ row['balanceAfter'] }}</td><td>{{ $any(row['happenedAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['detail'] }}</td></tr> } @empty { <tr><td colspan="6">暂无课时流水</td></tr> }</tbody></table></div></section> }
     @case ('audit') { <div class="audit-grid"><section class="panel"><header><div><h2>运营跟进备注</h2><span>记录后续联系和服务事项</span></div></header>@if (detail.permissions.canEdit) { <label><span>新增备注</span><textarea [ngModel]="noteContent()" (ngModelChange)="noteContent.set($event)" placeholder="填写需要跟进的事项或沟通结果"></textarea></label><button class="primary" type="button" (click)="saveNote()" [disabled]="saving()"><lucide-icon [img]="icons.MessageSquarePlus" [size]="16" />保存备注</button> }<div class="timeline">@for (note of notes(); track note['objectId']) { <article><strong>{{ note['operatorName'] || '运营人员' }}</strong><time>{{ $any(note['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ note['content'] }}</p></article> } @empty { <p class="muted">暂无跟进备注</p> }</div></section><section class="panel"><header><div><h2>操作审计</h2><span>账号、资料、关系、课程和课时修改记录</span></div></header><div class="timeline audit">@for (item of related().audit; track item['objectId']) { <article><strong>{{ item['operatorName'] || '系统' }}</strong><time>{{ $any(item['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ item['summary'] || '会员业务操作' }}</p></article> } @empty { <p class="muted">暂无操作审计记录</p> }</div></section></div> }
     @case ('audit') { <div class="audit-grid"><section class="panel"><header><div><h2>运营跟进备注</h2><span>记录后续联系和服务事项</span></div></header>@if (detail.permissions.canEdit) { <label><span>新增备注</span><textarea [ngModel]="noteContent()" (ngModelChange)="noteContent.set($event)" placeholder="填写需要跟进的事项或沟通结果"></textarea></label><button class="primary" type="button" (click)="saveNote()" [disabled]="saving()"><lucide-icon [img]="icons.MessageSquarePlus" [size]="16" />保存备注</button> }<div class="timeline">@for (note of notes(); track note['objectId']) { <article><strong>{{ note['operatorName'] || '运营人员' }}</strong><time>{{ $any(note['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ note['content'] }}</p></article> } @empty { <p class="muted">暂无跟进备注</p> }</div></section><section class="panel"><header><div><h2>操作审计</h2><span>账号、资料、关系、课程和课时修改记录</span></div></header><div class="timeline audit">@for (item of related().audit; track item['objectId']) { <article><strong>{{ item['operatorName'] || '系统' }}</strong><time>{{ $any(item['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ item['summary'] || '会员业务操作' }}</p></article> } @empty { <p class="muted">暂无操作审计记录</p> }</div></section></div> }
   }
   }
 
 
-  @if(tab()!=='overview'&&tab()!=='audit') {<nav class="pagination" aria-label="会员业务记录分页"><span>共 {{ recordTotal() }} 条 · 每页 {{ pageSize }} 条</span><button type="button" (click)="changePage(recordPage()-1)" [disabled]="sectionLoading()||recordPage()<=1">上一页</button><strong>{{ recordPage() }} / {{ pageCount() }}</strong><button type="button" (click)="changePage(recordPage()+1)" [disabled]="sectionLoading()||recordPage()>=pageCount()">下一页</button></nav>}
+  @if(tab()!=='overview'&&tab()!=='audit'&&tab()!=='lessons') {<nav class="pagination" aria-label="会员业务记录分页"><span>共 {{ recordTotal() }} 条 · 每页 {{ pageSize }} 条</span><button type="button" (click)="changePage(recordPage()-1)" [disabled]="sectionLoading()||recordPage()<=1">上一页</button><strong>{{ recordPage() }} / {{ pageCount() }}</strong><button type="button" (click)="changePage(recordPage()+1)" [disabled]="sectionLoading()||recordPage()>=pageCount()">下一页</button></nav>}
 } }
 } }

+ 3 - 1
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -1,3 +1,4 @@
+import { LessonRecordsComponent } from './lesson-records.component';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
 import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
@@ -14,7 +15,7 @@ type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'reports'
 
 
 @Component({
 @Component({
   selector: 'app-operations-user-detail', standalone: true,
   selector: 'app-operations-user-detail', standalone: true,
-  imports: [DropdownDirective, OperationsDateFilterComponent, MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [LessonRecordsComponent, DropdownDirective, OperationsDateFilterComponent, MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-user-detail.component.html', styleUrl: './operations-user-detail.component.scss',
   templateUrl: './operations-user-detail.component.html', styleUrl: './operations-user-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 })
@@ -42,6 +43,7 @@ export class OperationsUserDetailComponent implements OnInit {
   async filterReports(clear=false): Promise<void> { if(clear){this.reportFrom.set('');this.reportTo.set('');} if(this.reportFrom()&&this.reportTo()&&this.reportFrom()>this.reportTo()){this.error.set('开始日期不能晚于结束日期');return;}this.recordPage.set(1);await this.loadSection('reports'); }
   async filterReports(clear=false): Promise<void> { if(clear){this.reportFrom.set('');this.reportTo.set('');} if(this.reportFrom()&&this.reportTo()&&this.reportFrom()>this.reportTo()){this.error.set('开始日期不能晚于结束日期');return;}this.recordPage.set(1);await this.loadSection('reports'); }
   private kindFor(tab: MemberTab): OperationsListKind | null { return ({ courses: 'course-bindings', appointments: 'appointments', lessons: 'lessons', balances: 'money-logs' } as Partial<Record<MemberTab, OperationsListKind>>)[tab] || null; }
   private kindFor(tab: MemberTab): OperationsListKind | null { return ({ courses: 'course-bindings', appointments: 'appointments', lessons: 'lessons', balances: 'money-logs' } as Partial<Record<MemberTab, OperationsListKind>>)[tab] || null; }
   async loadSection(tab = this.tab()): Promise<void> {
   async loadSection(tab = this.tab()): Promise<void> {
+    if(tab==='lessons')return;
     const user = this.user(); if (!user || tab === 'overview') return;
     const user = this.user(); if (!user || tab === 'overview') return;
     const serial=++this.sectionSerial; this.sectionLoading.set(true); this.error.set('');
     const serial=++this.sectionSerial; this.sectionLoading.set(true); this.error.set('');
     try {
     try {

+ 25 - 0
scripts/check-lesson-records-browser.mjs

@@ -0,0 +1,25 @@
+import {chromium} from 'playwright';
+import {mkdirSync,writeFileSync,readFileSync} from 'node:fs';
+import * as XLSX from 'xlsx';
+import assert from 'node:assert/strict';
+export async function checkLessonRecordsBrowser({auth,output,studentId=1857,memberObjectId}){
+ mkdirSync(output,{recursive:true});const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const checks=[],errors=[];
+ try{const context=await browser.newContext({viewport:{width:1440,height:1050}});await context.addInitScript(auth=>{localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));sessionStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));},auth);
+ const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
+ await page.goto('http://127.0.0.1:4300/admin/lessons?studentId='+studentId);await page.getByRole('button',{name:'查看反馈',exact:true}).first().waitFor({timeout:60000});
+ assert.match(await page.locator('.record-count').innerText(),/累计已上课/);checks.push('集中查询入口、真实数据与累计课次');
+ const total=Number((await page.locator('.record-count').innerText()).match(/当前筛选 (\d+) 节/)[1]);
+ if(total>20){assert.equal(await page.locator('.lesson-table tbody tr').count(),20);const first=await page.locator('.lesson-table tbody tr').first().innerText();await page.getByRole('button',{name:'下一页',exact:true}).click();await page.waitForFunction(()=>document.querySelector('.lesson-pagination')?.textContent.includes('第 2 页')&&!document.querySelector('[role=status]'));assert.notEqual(await page.locator('.lesson-table tbody tr').first().innerText(),first);checks.push('超过一页的真实记录可以翻页');}
+ await page.getByRole('button',{name:'查看该学员全部结果'}).click();await page.waitForFunction(n=>document.querySelectorAll('.lesson-table tbody tr').length===n&&!document.querySelector('[role=status]'),total,{timeout:60000});
+ const dates=await page.locator('.lesson-table tbody tr td:nth-child(2)').allTextContents();assert.deepEqual(dates,[...dates].sort().reverse());checks.push('全部历史行数与累计一致、北京时间倒序');
+ await page.screenshot({path:output+'/history-desktop.png',fullPage:true});
+ const downloadPromise=page.waitForEvent('download');await page.getByRole('button',{name:'导出 Excel',exact:true}).click();const download=await downloadPromise;assert.match(download.suggestedFilename(),/上课记录.*xlsx/);await download.saveAs(output+'/lesson-records.xlsx');const book=XLSX.read(readFileSync(output+'/lesson-records.xlsx'));assert.equal(XLSX.utils.sheet_to_json(book.Sheets[book.SheetNames[0]]).length,total);checks.push('真实筛选结果导出 Excel,行数与课次一致');
+ await page.getByRole('button',{name:'查看反馈',exact:true}).first().click();await page.getByRole('dialog',{name:'上课记录详情'}).waitFor();await page.getByRole('heading',{name:'老师点评',exact:true}).waitFor({timeout:60000}).catch(async e=>{await page.screenshot({path:output+'/detail-error.png',fullPage:true});throw Error((await page.getByRole('dialog').innerText())+' '+e.message);});
+ await page.getByRole('button',{name:'更换抗遗忘陪练'}).click();await page.locator('.transfer-panel select').last().locator('option').nth(1).waitFor({timeout:60000,state:'attached'});
+ assert.ok(await page.locator('.transfer-panel select').last().locator('option').count()>1);checks.push('完整反馈详情与有效陪练选择;未提交真实转交');
+ await page.screenshot({path:output+'/feedback-desktop.png',fullPage:true});await page.getByRole('button',{name:'关闭',exact:true}).click();
+ await page.setViewportSize({width:390,height:844});await page.screenshot({path:output+'/history-mobile-width.png',fullPage:true});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth+1));checks.push('窄屏无整页横向溢出');
+ if(memberObjectId){await page.setViewportSize({width:1440,height:1050});await page.goto('http://127.0.0.1:4300/admin/members/'+memberObjectId);await page.getByRole('button',{name:'上课记录',exact:true}).click();await page.locator('app-lesson-records .data-table tbody tr').first().waitFor();assert.equal(await page.locator('nav.pagination').count(),0);checks.push('会员详情复用同一列表且不重复分页');}
+ assert.deepEqual(errors,[]);writeFileSync(output+'/browser-result.json',JSON.stringify({passed:true,checks},null,2));return{passed:true,checks};
+ }finally{await browser.close();}
+}

+ 1 - 1
scripts/cloud/legacy-direct-sync.js

@@ -54,7 +54,7 @@ try{
     }
     }
     if(m===2||m===52||cc.teachingManaged||aa.teachingManaged||m===54&&(aa.bindingId||aa.creditHoldStatus||aa.courseCategoryKey)||m===58&&aa.enrollmentStatus==='active'){out.conflicts.push({generalId:gid,reason:'保留新系统课程内容或教学凭据'});continue;}
     if(m===2||m===52||cc.teachingManaged||aa.teachingManaged||m===54&&(aa.bindingId||aa.creditHoldStatus||aa.courseCategoryKey)||m===58&&aa.enrollmentStatus==='active'){out.conflicts.push({generalId:gid,reason:'保留新系统课程内容或教学凭据'});continue;}
     const fields=Object.keys(incoming.values||{});if(!fields.length||fields.some(f=>!s.fields.includes(f)))throw Error('Invalid patch fields');
     const fields=Object.keys(incoming.values||{});if(!fields.length||fields.some(f=>!s.fields.includes(f)))throw Error('Invalid patch fields');
-    if(m===60&&(await t.oneOrNone('SELECT "objectId" FROM "ReviewReward" WHERE company=$1 AND "taskId"=$2',[co,aa.objectId])||fields.some(f=>['yhid','xxjlid','kywrq','plid'].includes(f)))){out.conflicts.push({generalId:gid,reason:'保留奖励凭据或复习身份'});continue;}
+    if(m===60&&(Number(aa.assignmentVersion)>0||await t.oneOrNone('SELECT "objectId" FROM "ReviewReward" WHERE company=$1 AND "taskId"=$2',[co,aa.objectId])||fields.some(f=>['yhid','xxjlid','kywrq','plid'].includes(f)))){out.conflicts.push({generalId:gid,reason:'保留奖励凭据或复习身份'});continue;}
     if(m===60)await t.none("SET LOCAL session_replication_role='replica'");
     if(m===60)await t.none("SET LOCAL session_replication_role='replica'");
     await t.none('UPDATE '+q(s.className)+' a SET '+fields.map(f=>q(f)+'=v.'+q(f)).join(',')+',"updatedAt"=now() FROM jsonb_populate_record(NULL::'+q(s.className)+',$3::jsonb) v WHERE a.company=$1 AND a."objectId"=$2',[co,aa.objectId,JSON.stringify(incoming.values)]);
     await t.none('UPDATE '+q(s.className)+' a SET '+fields.map(f=>q(f)+'=v.'+q(f)).join(',')+',"updatedAt"=now() FROM jsonb_populate_record(NULL::'+q(s.className)+',$3::jsonb) v WHERE a.company=$1 AND a."objectId"=$2',[co,aa.objectId,JSON.stringify(incoming.values)]);
     if(m===60){await t.none("SET LOCAL session_replication_role='origin'");await t.any('SELECT xiaoshu_record_review_reward(m,true) FROM "MemoryPracticeRecord" m WHERE m.company=$1 AND m."objectId"=$2 AND m.fxzt::text=\'1\'',[co,aa.objectId]);}
     if(m===60){await t.none("SET LOCAL session_replication_role='origin'");await t.any('SELECT xiaoshu_record_review_reward(m,true) FROM "MemoryPracticeRecord" m WHERE m.company=$1 AND m."objectId"=$2 AND m.fxzt::text=\'1\'',[co,aa.objectId]);}

+ 70 - 0
scripts/cloud/lesson-records.js

@@ -0,0 +1,70 @@
+// Shared classroom history for member detail, scheduling and the records centre.
+const LESSON_RECORDS_VERSION='2026-10-13-lesson-records-v10';
+function lessonHistoryBase(){return `WITH facts AS (
+ SELECT l.*,c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",
+ COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
+ COALESCE(r."actualStartAt",l."lessonAt",xiaoshu_safe_shanghai_timestamp(l.plpjsj),c."createdAt",l."sourceCreatedAt",l."createdAt") AS "actualAt",
+ COALESCE(NULLIF(s."realName",''),NULLIF(s.nickname,''),s."legacyUserData"->>'HoneyName',s.username,l."studentName",'学员 '||l.xymz) AS "studentNameResolved",
+ COALESCE(NULLIF(p."realName",''),NULLIF(p.nickname,''),p."legacyUserData"->>'HoneyName',p.username,l."coachName",'历史陪练 '||l.jsmz) AS "coachNameResolved",
+ COALESCE(NULLIF(l.kcmc,''),NULLIF(l."courseName",''),n."nodeName",'未记录课程') AS "courseNameResolved",
+ COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') AS "storeIdResolved",
+ COALESCE(NULLIF(st."realName",''),NULLIF(st.nickname,''),st."legacyUserData"->>'HoneyName',st.username,'未记录门店') AS "storeName",
+ r."contentSummary" AS "feedbackSummary"
+ FROM "LessonRecord" l
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
+ LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.xymz ORDER BY u."objectId" LIMIT 1) s ON true
+ LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.jsmz ORDER BY u."objectId" LIMIT 1) p ON true
+ LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') ORDER BY u."objectId" LIMIT 1) st ON true
+ LEFT JOIN LATERAL (SELECT "nodeName" FROM "Node" n WHERE n.company=l.company AND n."nodeId"::text=l.kcid LIMIT 1) n ON true
+ LEFT JOIN LATERAL (SELECT "contentSummary","actualStartAt" FROM "LessonCompletionReport" r WHERE r.company=l.company AND (r."lessonId"=l."objectId" OR r."appointmentGeneralId"::text=l.yyds) ORDER BY r."submittedAt" DESC LIMIT 1) r ON true
+ WHERE l.company=$1
+ ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn,count(*) OVER(PARTITION BY company,xymz,"classKey") duplicates FROM facts), lessons AS (SELECT * FROM ranked WHERE rn=1)`;}
+function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...input},conditions=[];
+ const add=(sql,value)=>{if(value!==undefined&&value!==null&&String(value).trim()){args.push(String(value).trim());conditions.push(sql.replaceAll('?',String(args.length)));}};
+ add('xymz=$?::text',f.studentId);add('jsmz=$?::text',f.coachId);add('"storeIdResolved"=$?::text',f.storeId);add('kcid=$?::text',f.courseId);
+ for(const [field,key] of [['"studentNameResolved"','studentName'],['"coachNameResolved"','coachName'],['"storeName"','storeName'],['"courseNameResolved"','courseName']])add('position(lower($?) in lower('+field+'))>0',f[key]);
+ add('position(lower($?) in lower(concat_ws(\' \',xymz,"studentNameResolved")))>0',f.search);
+ 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 '):'';}
+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:row.courseCategoryName||classType(row.kclx),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){
+ requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'缺少帐套');
+ const args=[co],where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search},args),paging=pageInput(input);args.push(paging.pageSize,(paging.page-1)*paging.pageSize);
+ const q=lessonHistoryBase()+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})
+ SELECT md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),'')) AS "historyVersion",(SELECT count(*)::int FROM filtered) total,(SELECT count(*)::int FROM lessons${cumulativeWhere}) "cumulativeTotal",
+ COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM page p
+ LEFT JOIN LATERAL (SELECT COALESCE(sum(n),0) n,jsonb_agg(jsonb_build_object('id',coach,'name',name,'count',n) ORDER BY name) coaches FROM (
+ SELECT m.plid::text coach,COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),u."legacyUserData"->>'HoneyName',u.username,'陪练 '||m.plid::text) name,count(*)::int n
+ 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};
+}
+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);
+ if(!co||(!id&&!appointment))fail(400,'缺少课次编号');
+ const row=await Psql.oneOrNone(lessonHistoryBase()+` SELECT * FROM ranked WHERE ($2<>'' AND ("targetId"=$2 OR "objectId"=$2 OR "generalId"::text=$2)) OR ($3<>'' AND yyds=$3) LIMIT 1`,[co,id,appointment]);
+ if(!row)fail(404,'未找到实际上课记录');
+ const [report,review,tasks,auditRows]=await Promise.all([
+ Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE company=$1 AND ("lessonId"=$2 OR "appointmentGeneralId"::text=$3) ORDER BY "submittedAt" DESC LIMIT 1',[co,row.objectId,row.yyds]),
+ 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: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);
+ if(!co||!/^\d+$/.test(student)||!/^\d+$/.test(coach)||!['lesson','student'].includes(input.scope)||input.scope==='lesson'&&!appointment)fail(400,'请选择有效的学员、课次和目标陪练');
+ await Psql.one('SELECT xs_review_manager($1,$2),xs_review_target($1,$3)',[co,context.current.id,coach]);
+ const snap=await Psql.one('SELECT s.tasks,md5(s.tasks::text) version FROM (SELECT xs_review_transfer_snapshot($1,$2,$3,$4) tasks) s',[co,student,appointment,coach]);
+ const ids=[...new Set(snap.tasks.map(t=>t.coachId))],coaches=ids.length?await Psql.query('SELECT "legacyUserId"::text id,COALESCE(NULLIF("realName",\'\'),NULLIF(nickname,\'\'),"legacyUserData"->>\'HoneyName\',username) name FROM "_User" WHERE company=$1 AND "legacyUserId"::text=ANY($2::text[])',[co,ids]):[];
+ const tasks=snap.tasks.map(t=>({...t,coachName:coaches.find(c=>c.id===t.coachId)?.name||'陪练 '+t.coachId}));
+ return {version:snap.version,count:tasks.length,lessonCount:new Set(tasks.map(t=>t.appointmentId).filter(Boolean)).size,unlinkedCount:tasks.filter(t=>!t.appointmentId).length,dateFrom:tasks.map(t=>t.plannedDate).sort()[0]||'',dateTo:tasks.map(t=>t.plannedDate).sort().at(-1)||'',tasks};
+}
+async function lessonTransferCommit(context,input){
+ requireRole(context,['ops-manager']);if(!['student','lesson'].includes(input.scope)||input.scope==='lesson'&&!text(input.appointmentId))fail(400,'缺少转交范围');
+ try{return (await Psql.one('SELECT xs_review_transfer($1,$2,$3,$4,$5,$6,$7,$8) result',[companyIdOf(context,input),context.current.id,text(input.studentId),input.scope==='student'?'':text(input.appointmentId),text(input.coachId),text(input.version),text(input.reason),text(input.idempotencyKey)])).result;}catch(error){fail(409,text(error.message)||'转交失败,请刷新预览');}
+}
+
+async function lessonTransferCoaches(context,input){requireRole(context,['ops-manager']);const items=await Psql.query(`SELECT "legacyUserId" AS "userId",COALESCE(NULLIF("realName",''),NULLIF(nickname,''),"legacyUserData"->>'HoneyName',username) AS "displayName" FROM "_User" u WHERE company=$1 AND COALESCE(to_jsonb(u)->>'identityType','coach')='coach' AND COALESCE(to_jsonb(u)->>'legacyGroupId',to_jsonb(u)->>'groupId',u."legacyUserData"->>'GroupID')='3' AND NOT COALESCE("isDisabled",false) AND NOT COALESCE("isDeleted",false) ORDER BY "displayName","userId"`,[companyIdOf(context,input)]);return{items};}

+ 1 - 1
scripts/cloud/mobile-teaching.js

@@ -22,7 +22,7 @@ async function appLessonReport(input,current) {
 }
 }
 async function appReviewPage(input,current) {
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
-  const reviewTarget=coach&&number(input.studentId)?await appLearningTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined;
+  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'];
   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.
   // 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"),\'\'),\'[]\')=\'[]\')');
   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"),\'\'),\'[]\')=\'[]\')');

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 11 - 3
scripts/deploy-admin-functions.mjs


+ 19 - 0
scripts/migrate-lesson-records.mjs

@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+import {readFileSync} from 'node:fs';
+import {randomUUID} from 'node:crypto';
+const sql=readFileSync(new URL('./sql/lesson-records.sql',import.meta.url),'utf8');
+if(!process.argv.includes('--apply')){console.log('上课记录迁移就绪:新增复习转交、审计和并发保护;不转交任何现有任务。使用 --apply 执行。');process.exit(0);}
+const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN||process.env.XIAOSHU_SESSION_TOKEN;
+if(!master||!token)throw Error('需要受保护环境中的主密钥和有效管理会话');
+const app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+async function parse(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':master},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'数据库请求失败');return d;}
+const sessions=(await parse('/classes/_Session?'+new URLSearchParams({where:JSON.stringify({sessionToken:token}),include:'user',limit:'2'}))).results;
+const s=sessions?.[0];if(sessions.length!==1||!['super-admin','ops-manager'].includes(s.user?.adminRoleKey)||s.user.isDisabled||s.user.isDeleted||new Date(s.expiresAt?.iso)<new Date())throw Error('管理会话不可用');
+const co=s.user.company?.objectId;if(!co)throw Error('缺少帐套');
+const schema=await parse('/schemas/MemoryPracticeRecord');if(!schema.fields?.assignmentVersion)await parse('/schemas/MemoryPracticeRecord','PUT',{fields:{assignmentVersion:{type:'Number'}}});
+const path='xiaoshu/system/lesson-records-migrate-'+randomUUID(),expires=Date.now()+600000;let id;
+try{
+ const code=`async function handler(request,response){try{if(request.user?.id!==${JSON.stringify(s.user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});await request.user.fetch({useMasterKey:true});if(request.user.get('isDisabled')||request.user.get('isDeleted'))return response.status(403).json({success:false});const active=await Psql.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[${JSON.stringify(co)},'running']);if(active.n)throw Error('夜间同步正在运行,暂缓迁移');await Psql.none(${JSON.stringify(sql)});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ id=(await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ const response=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}})}),result=await response.json();if(!response.ok||!result.success)throw Error(result.message||'迁移失败');console.log('上课记录与复习转交迁移成功;现有任务未转交。');
+}finally{if(id)await parse('/classes/Function/'+id,'DELETE');}

+ 55 - 0
scripts/smoke-lesson-records.mjs

@@ -0,0 +1,55 @@
+// Executed by the scoped release runner. Only disposable fixture IDs are mutated.
+import assert from 'node:assert/strict';
+import {randomUUID,randomBytes} from 'node:crypto';
+export async function smokeLessonRecords({parse,call,scoped,co,save}){
+ const suffix=Date.now(),prefix='lrtest_'+suffix,uid=990000000+suffix%1000000,users=[],checks=[];
+ const ok=(value,label)=>{assert.ok(value,label);checks.push(label);console.log('PASS '+label);};
+ const sql=async(query,args=[])=>scoped(`return response.json({success:true,data:await Psql.query(${JSON.stringify(query)},${JSON.stringify(args)})});`);
+ const ops=async(operation,params={})=>(await call('xiaoshu/ops/gateway-v3',{operation,...params})).data;
+ const mobile=async(u,action,params={})=>(await call('xiaoshu/app/gateway',{action,...params},u.sessionToken)).result;
+ const taskId=i=>prefix+'_task'+i,gid=i=>uid*100+i;
+ try{
+  for(const [i,kind] of ['member','coach','coach','member'].entries()){
+   const value=await parse('/users','POST',{username:prefix+'_'+i,password:randomBytes(40).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co},realName:'隔离上课记录验收'+i,identityType:kind,legacyGroupId:kind==='coach'?3:1,legacyUserId:uid+i,legacyUserData:{UserID:uid+i,GroupID:kind==='coach'?3:1,Purse:3,SilverCoin:3,UserPoint:3,UserExp:3}});users.push({...value,uid:uid+i});
+  }
+  save('isolated-fixtures.json',{users,prefix});
+  await sql(`INSERT INTO "DailyStudyRecord"("objectId","sourceKey",company,id,"userId",dsid,pl,learned,xxqs,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$4,$5,$6,1,$7,now(),now())`,[prefix+'_study',co,gid(1),uid,String(gid(2)),String(uid+1),JSON.stringify([{GeneralID:1,Title:'hello',check:1}])]);
+  await sql(`INSERT INTO "CommonModel"("objectId","sourceKey",company,"generalId","itemId","modelId","nodeId",title,status,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$3,56,291,'隔离学习记录',99,now(),now())`,[prefix+'_studyCommon',co,gid(1)]);
+  // 25 classes prove cross-page history; only the first owns the review tasks.
+  for(let i=0;i<25;i++)await sql(`INSERT INTO "LessonRecord"("objectId","sourceKey",company,id,xymz,jsmz,yyds,kclx,kcmc,pldp,pjnr,plpjsj,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$4,$5,$6,'2','隔离课程','完整老师反馈','完整学生评价',$7,now(),now())`,[prefix+'_lesson'+i,co,gid(20+i),String(uid),String(uid+1),String(gid(2+i)),`2026-10-${String(i+1).padStart(2,'0')} 10:00:00`]);
+  for(let i=0;i<4;i++){
+   await sql(`INSERT INTO "MemoryPracticeRecord"("objectId","sourceKey",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,fxzt,wcsj,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$4,$5,$6,$7,$8,$8,$9,'',now(),now())`,[taskId(i),co,gid(100+i),uid+1,uid,gid(1),gid(2),i===0?'2026-01-01':`2099-01-0${i+1}`,i===3?'1':'0']);
+   await sql(`INSERT INTO "CommonModel"("objectId","sourceKey",company,"generalId","itemId","modelId","nodeId",title,status,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$3,60,388,'隔离复习',99,now(),now())`,[prefix+'_taskCommon'+i,co,gid(100+i)]);
+  }
+  const p=await ops('ops/lessons/list',{studentId:uid});ok(p.total===25&&p.items.length===20&&p.items[0].lessonId===prefix+'_lesson24','真实接口分页、总课次与时间倒序');
+  const second=await ops('ops/lessons/list',{studentId:uid,page:2});ok(second.items.length===5&&second.version===p.version,'分页快照一致且无漏行');
+  const detail=await ops('ops/lessons/detail',{lessonId:prefix+'_lesson0'});ok(detail.coachComment==='完整老师反馈'&&detail.studentReview.comment==='完整学生评价'&&detail.tasks.length===4,'历史反馈与复习关联可完整查看');
+  const params={studentId:uid,appointmentId:String(gid(2)),scope:'lesson',coachId:uid+2};
+  const preview=await ops('ops/lessons/transfer-preview',params);ok(preview.count===3&&preview.dateFrom==='2026-01-01','预览包括逾期与未来,排除已完成');
+  const before=await sql('SELECT to_jsonb(r) data FROM "ReviewReward" r WHERE company=$1 AND "taskId"=$2',[co,taskId(3)]);
+  const request={...params,version:preview.version,reason:'隔离发布验收',idempotencyKey:randomUUID()};
+  const responses=await Promise.all([ops('ops/lessons/transfer',request),ops('ops/lessons/transfer',request)]);ok(responses.every(r=>r.count===3&&r.commandId===responses[0].commandId),'真实并发重复转交只执行一次');
+  const after=await sql('SELECT to_jsonb(r) data FROM "ReviewReward" r WHERE company=$1 AND "taskId"=$2',[co,taskId(3)]);assert.deepEqual(after,before);ok(true,'已完成奖励原值保持不变');
+  const page=await mobile(users[2],'app_review_page',{uid:uid+2,studentId:uid,pageSize:100,status:'pending'});ok(page.items.length===3,'无授课关系的新陪练可查看接手任务');
+  const words=await mobile(users[2],'e_record_detail',{id:gid(1),studentId:uid});ok(words.length===1&&words[0].Title==='hello','新陪练可读取对应来源词表');
+  await assert.rejects(mobile(users[2],'app_learning_overview',{studentId:uid}));ok(true,'新陪练没有获得无关学习历史权限');
+  const complete=i=>({content:JSON.stringify({GeneralID:gid(100+i)}),addon:JSON.stringify({fxzt:1})});
+  await assert.rejects(mobile(users[1],'content_update',complete(0)));ok(true,'原陪练旧页面完成请求被拒绝');
+  await Promise.all([mobile(users[2],'content_update',complete(0)),mobile(users[2],'content_update',complete(0))]);
+  const reward=await sql('SELECT "coachId",amount FROM "ReviewReward" WHERE company=$1 AND "taskId"=$2',[co,taskId(0)]);ok(reward.length===1&&Number(reward[0].coachId)===uid+2&&Number(reward[0].amount)===1,'新陪练完成只计一次一元工资');
+  await sql(`UPDATE "MemoryPracticeRecord" SET plid=$3,fxzt='1',wcsj='2026-01-01' WHERE company=$1 AND "objectId"=$2`,[co,taskId(1),uid+1]);
+  const preserved=await sql('SELECT plid,fxzt FROM "MemoryPracticeRecord" WHERE company=$1 AND "objectId"=$2',[co,taskId(1)]);ok(Number(preserved[0].plid)===uid+2&&preserved[0].fxzt==='0','旧来源更新被保护并保留待复习状态');
+  const reverse={...params,coachId:uid+1},racePreview=await ops('ops/lessons/transfer-preview',reverse);
+  const race=await Promise.allSettled([ops('ops/lessons/transfer',{...reverse,version:racePreview.version,reason:'并发交接验证',idempotencyKey:randomUUID()}),mobile(users[2],'content_update',complete(1))]);
+  const actual=await sql('SELECT m.plid,m.fxzt,r."coachId" FROM "MemoryPracticeRecord" m LEFT JOIN "ReviewReward" r ON r.company=m.company AND r."taskId"=m."objectId" WHERE m.company=$1 AND m."objectId"=$2',[co,taskId(1)]);
+  ok(race.filter(r=>r.status==='fulfilled').length===1&&(actual[0].fxzt==='1'?Number(actual[0].coachId)===uid+2:Number(actual[0].plid)===uid+1),'真实多连接转交与完成互斥,工资归属一致');
+  await assert.rejects(call('xiaoshu/ops/gateway-v3',{operation:'ops/lessons/transfer-preview',...params},users[1].sessionToken));ok(true,'普通老师不能调用管理转交接口');
+  await parse('/users/'+users[2].objectId,'PUT',{isDisabled:true});await assert.rejects(ops('ops/lessons/transfer-preview',params));ok(true,'停用目标陪练被拒绝');
+  const balances=await sql('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=$1',[users[0].objectId]);ok(['Purse','SilverCoin','UserPoint','UserExp'].every(k=>balances[0].legacyUserData[k]===3),'转交不扣课时或余额');
+  save('isolated-result.json',{passed:true,checks});return {passed:true,checks};
+ }finally{
+  // Fixed fixture prefix and IDs only; reward immutability bypass is transaction-local for teardown.
+  await scoped(String.raw`await Psql.tx(async t=>{await t.none("SET LOCAL session_replication_role='replica'");const co=${JSON.stringify(co)},prefix=${JSON.stringify(prefix)},uid=${uid};for(const table of ['ReviewAssignmentConflict','ReviewTransferAudit'])await t.none('DELETE FROM "'+table+'" WHERE company=$1 AND "taskId" LIKE $2',[co,prefix+'_task%']);await t.none('DELETE FROM "ReviewTransferCommand" WHERE company=$1 AND result->>\'studentId\'=$2',[co,String(uid)]);await t.none('DELETE FROM "ReviewReward" WHERE company=$1 AND "taskId" LIKE $2',[co,prefix+'_task%']);for(const table of ['MemoryPracticeRecord','LessonRecord','DailyStudyRecord','CommonModel'])await t.none('DELETE FROM "'+table+'" WHERE company=$1 AND "objectId" LIKE $2',[co,prefix+'_%']);});return response.json({success:true,data:{cleaned:true}});`);
+  for(const u of [...users].reverse())await parse('/users/'+u.objectId,'DELETE');save('isolated-fixtures-cleaned.json',{prefix,cleaned:true});console.log('ISOLATED_FIXTURES_CLEANED');
+ }
+}

+ 105 - 0
scripts/sql/lesson-records.sql

@@ -0,0 +1,105 @@
+BEGIN;
+ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "assignmentVersion" double precision DEFAULT 0;
+CREATE TABLE IF NOT EXISTS "ReviewTransferCommand" (
+ "objectId" text PRIMARY KEY,"company" text NOT NULL,"actorId" text NOT NULL,"key" text NOT NULL,
+ "fingerprint" text NOT NULL,"reason" text NOT NULL,"result" jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now(),
+ UNIQUE ("company","key")
+);
+CREATE TABLE IF NOT EXISTS "ReviewTransferAudit" (
+ "objectId" text PRIMARY KEY,"company" text NOT NULL,"commandId" text NOT NULL,"taskId" text NOT NULL,
+ "studentId" text NOT NULL,"appointmentId" text,"oldCoachId" text,"newCoachId" text NOT NULL,
+ "oldVersion" double precision,"newVersion" double precision,"createdAt" timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS "ReviewAssignmentConflict" (
+ "objectId" text PRIMARY KEY,"company" text NOT NULL,"taskId" text NOT NULL,"incoming" jsonb NOT NULL,
+ "preserved" jsonb NOT NULL,"createdAt" timestamptz NOT NULL DEFAULT now()
+);
+-- A source learning record is authoritative; conflicting appointment links are never guessed.
+CREATE OR REPLACE VIEW xs_review_task_link AS
+SELECT m."company",m."objectId" AS "taskId",m."yhid"::text AS "studentId",
+ CASE WHEN source.n=1 AND (COALESCE(NULLIF(m."orderId"::text,'0'),'')='' OR m."orderId"::text=source.appointment) THEN source.appointment END AS "appointmentId"
+FROM "MemoryPracticeRecord" m
+LEFT JOIN LATERAL (
+ SELECT count(DISTINCT NULLIF(NULLIF(d.dsid,'0'),'')) n,min(NULLIF(NULLIF(d.dsid,'0'),'')) appointment
+ FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d.company=c.company AND d.id=c."itemId" AND d."userId"::text=m.yhid::text
+ WHERE c.company=m.company AND c."modelId"=56 AND c."generalId"::text=m.xxjlid::text
+) source ON true;
+
+CREATE OR REPLACE FUNCTION xs_review_manager(co text, actor text) RETURNS void LANGUAGE plpgsql AS $$
+DECLARE u jsonb; role_key text;
+BEGIN
+ SELECT to_jsonb(t) INTO u FROM "_User" t WHERE t."company"=co AND t."objectId"=actor;
+ role_key:=COALESCE(NULLIF(u->>'adminRoleKey',''),CASE WHEN u->'roles' ? 'super-admin' THEN 'super-admin' WHEN u->'roles' ? 'ops-manager' THEN 'ops-manager' WHEN u->'roles' ? 'ops-staff' THEN 'ops-staff' WHEN u->'roles' ? 'ops-auditor' THEN 'ops-auditor' WHEN u->>'isAdmin'='true' OR u->>'role'='admin' OR u->'roles' ? 'admin' THEN 'ops-manager' END);
+ IF u IS NULL OR u->>'isDisabled'='true' OR u->>'isDeleted'='true' OR COALESCE(role_key,'') NOT IN ('super-admin','ops-manager') THEN RAISE EXCEPTION '仅管理角色可以转交复习'; END IF;
+END $$;
+CREATE OR REPLACE FUNCTION xs_review_target(co text, coach text) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM 1 FROM "_User" u WHERE u."company"=co AND COALESCE(u."legacyUserId"::text,u."legacyUserData"->>'UserID')=coach
+ AND COALESCE(to_jsonb(u)->>'identityType','coach')='coach'
+ AND COALESCE(to_jsonb(u)->>'legacyGroupId',to_jsonb(u)->>'groupId',u."legacyUserData"->>'GroupID')='3'
+ AND COALESCE(to_jsonb(u)->>'isDisabled','false')<>'true' AND COALESCE(to_jsonb(u)->>'isDeleted','false')<>'true' FOR SHARE;
+ IF NOT FOUND THEN RAISE EXCEPTION '目标陪练不存在、已停用或身份异常'; END IF;
+END $$;
+CREATE OR REPLACE FUNCTION xs_review_transfer_snapshot(co text, student text, appointment text, coach text) RETURNS jsonb LANGUAGE sql STABLE AS $$
+ SELECT COALESCE(jsonb_agg(jsonb_build_object('taskId',m."objectId",'coachId',m."plid"::text,'version',COALESCE(m."assignmentVersion",0),'updatedAt',m."updatedAt",'plannedDate',m."kywrq",'appointmentId',l."appointmentId") ORDER BY m."objectId"),'[]'::jsonb)
+ FROM "MemoryPracticeRecord" m JOIN xs_review_task_link l ON l."company"=m."company" AND l."taskId"=m."objectId"
+ WHERE m."company"=co AND m."yhid"::text=student AND COALESCE(m."fxzt"::text,'0')<>'1' AND COALESCE(m."rewardDuplicate",false)=false
+ AND (appointment='' OR l."appointmentId"=appointment) AND COALESCE(m."plid"::text,'')<>coach
+ AND NOT EXISTS(SELECT 1 FROM "ReviewReward" r WHERE r."company"=co AND r."taskId"=m."objectId");
+$$;
+CREATE OR REPLACE FUNCTION xs_review_transfer(co text, actor text, student text, appointment text, coach text, expected text, reason text, idem text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE snapshot jsonb; receipt record; fingerprint text; command_id text; task record; result jsonb;
+BEGIN
+ PERFORM xs_review_manager(co,actor);
+ IF student !~ '^[1-9][0-9]*$' OR coach !~ '^[1-9][0-9]*$' OR length(trim(reason))<2 OR length(reason)>1000 OR length(idem)<8 OR length(idem)>120 THEN RAISE EXCEPTION '缺少有效学员、陪练、原因或请求编号'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('review-transfer:'||co||':'||student));
+ fingerprint:=md5(jsonb_build_array(actor,student,appointment,coach,expected,reason)::text);
+ SELECT * INTO receipt FROM "ReviewTransferCommand" WHERE "company"=co AND "key"=idem;
+ IF FOUND THEN IF receipt."fingerprint"<>fingerprint THEN RAISE EXCEPTION '请求编号已用于其他转交'; END IF; RETURN receipt."result"; END IF;
+ PERFORM xs_review_target(co,coach);
+ -- Match completion's row lock; re-read versions after a concurrent completion settles.
+ PERFORM 1 FROM "MemoryPracticeRecord" m WHERE m."company"=co AND m."yhid"::text=student ORDER BY m."objectId" FOR UPDATE;
+ snapshot:=xs_review_transfer_snapshot(co,student,appointment,coach);
+ IF md5(snapshot::text)<>expected THEN RAISE EXCEPTION '复习任务已变化,请刷新预览后重试'; END IF;
+ IF jsonb_array_length(snapshot)=0 THEN RAISE EXCEPTION '没有可转交的未完成复习'; END IF;
+ command_id:=md5(co||':'||idem);
+ PERFORM set_config('xiaoshu.review_transfer',command_id,true);
+ FOR task IN SELECT value AS data FROM jsonb_array_elements(snapshot) LOOP
+  UPDATE "MemoryPracticeRecord" SET "plid"=coach::numeric,"assignmentVersion"=COALESCE("assignmentVersion",0)+1,"updatedAt"=clock_timestamp() WHERE "company"=co AND "objectId"=task.data->>'taskId';
+  INSERT INTO "ReviewTransferAudit" ("objectId","company","commandId","taskId","studentId","appointmentId","oldCoachId","newCoachId","oldVersion","newVersion") VALUES
+  (md5(command_id||':'||(task.data->>'taskId')),co,command_id,task.data->>'taskId',student,task.data->>'appointmentId',task.data->>'coachId',coach,(task.data->>'version')::numeric,(task.data->>'version')::numeric+1);
+ END LOOP;
+ PERFORM set_config('xiaoshu.review_transfer','',true);
+ result:=jsonb_build_object('commandId',command_id,'count',jsonb_array_length(snapshot),'studentId',student,'coachId',coach);
+ INSERT INTO "ReviewTransferCommand" ("objectId","company","actorId","key","fingerprint","reason","result") VALUES(command_id,co,actor,idem,fingerprint,reason,result);
+ RETURN result;
+END $$;
+-- Protect assignments even when a page-triggered legacy refresh raced the transfer.
+CREATE OR REPLACE FUNCTION xs_review_assignment_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF COALESCE(current_setting('xiaoshu.review_transfer',true),'')<>'' THEN RETURN NEW; END IF;
+ NEW."assignmentVersion":=OLD."assignmentVersion";
+ IF COALESCE(OLD."assignmentVersion",0)>0 AND
+ (NEW."plid" IS DISTINCT FROM OLD."plid" OR (NEW."fxzt" IS DISTINCT FROM OLD."fxzt" AND COALESCE(current_setting('xiaoshu.review_completion',true),'')<>OLD."company"||':'||OLD."objectId")) THEN
+  INSERT INTO "ReviewAssignmentConflict" ("objectId","company","taskId","incoming","preserved") VALUES(md5(OLD."company"||OLD."objectId"||to_jsonb(NEW)::text),OLD."company",OLD."objectId",jsonb_build_object('coachId',NEW."plid",'status',NEW."fxzt"),jsonb_build_object('coachId',OLD."plid",'status',OLD."fxzt")) ON CONFLICT DO NOTHING;
+  RETURN OLD;
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS a_review_assignment_guard ON "MemoryPracticeRecord";
+CREATE TRIGGER a_review_assignment_guard BEFORE UPDATE ON "MemoryPracticeRecord" FOR EACH ROW EXECUTE FUNCTION xs_review_assignment_guard();
+-- Check the authenticated actor after taking the same row lock used by transfer.
+CREATE OR REPLACE FUNCTION xs_complete_assigned_review(co text, task_id text, actor text) RETURNS void LANGUAGE plpgsql AS $$
+DECLARE task record; u jsonb; uid text;
+BEGIN
+ SELECT to_jsonb(t) INTO u FROM "_User" t WHERE t."company"=co AND t."objectId"=actor;
+ uid:=COALESCE(u->>'legacyUserId',u->'legacyUserData'->>'UserID');
+ IF u IS NULL OR u->>'isDisabled'='true' OR u->>'isDeleted'='true' THEN RAISE EXCEPTION '账号不可用'; END IF;
+ SELECT * INTO task FROM "MemoryPracticeRecord" WHERE "company"=co AND "objectId"=task_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION '复习任务不存在'; END IF;
+ IF uid IS NULL OR (uid<>task."plid"::text AND uid<>task."yhid"::text) THEN RAISE EXCEPTION '复习已转交或不属于当前账号,请刷新列表'; END IF;
+ PERFORM xiaoshu_complete_review(co,task_id);
+END $$;
+CREATE INDEX IF NOT EXISTS xs_review_student_pending ON "MemoryPracticeRecord" ("company","yhid","fxzt");
+CREATE INDEX IF NOT EXISTS xs_review_transfer_task ON "ReviewTransferAudit" ("company","taskId");
+COMMIT;

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 6 - 0
scripts/tests/fixtures/lesson-records-schema.sql


+ 10 - 3
scripts/tests/legacy-review-pull.test.mjs

@@ -8,9 +8,10 @@ const source = gatewaySource.match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]
   .replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/, '');
   .replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/, '');
 assert.ok(source, '未找到旧系统抗遗忘记录拉取实现');
 assert.ok(source, '未找到旧系统抗遗忘记录拉取实现');
 
 
-function contextFor(rows) {
+function contextFor(rows, initial = []) {
   let sequence = 0;
   let sequence = 0;
-  let stored = [];
+  let stored = initial;
+  const conflicts = [];
   const writes = [];
   const writes = [];
   const context = {
   const context = {
     console,
     console,
@@ -33,6 +34,7 @@ function contextFor(rows) {
       return { ok: true, async text() { return JSON.stringify({ retcode: 1, result: JSON.stringify(rows), page: { itemCount: rows.length, pageCount: 1 } }); } };
       return { ok: true, async text() { return JSON.stringify({ retcode: 1, result: JSON.stringify(rows), page: { itemCount: rows.length, pageCount: 1 } }); } };
     },
     },
     Psql: {
     Psql: {
+      async none(sql, args) { if(sql.includes('ReviewAssignmentConflict')) {conflicts.push(args);return;}throw Error(sql);},
       async one(sql) {
       async one(sql) {
         if (sql.includes('COUNT(*)') && sql.includes('MemoryPracticeRecord')) return { total: stored.length };
         if (sql.includes('COUNT(*)') && sql.includes('MemoryPracticeRecord')) return { total: stored.length };
         throw new Error(`unexpected one sql: ${sql}`);
         throw new Error(`unexpected one sql: ${sql}`);
@@ -55,7 +57,7 @@ function contextFor(rows) {
     },
     },
   };
   };
   vm.runInNewContext(`${source};this.normalize=legacyReviewRecord;this.sync=performLegacyReviewSync`, context);
   vm.runInNewContext(`${source};this.normalize=legacyReviewRecord;this.sync=performLegacyReviewSync`, context);
-  return { context, writes };
+  return { context, writes, conflicts };
 }
 }
 
 
 test('旧系统抗遗忘记录只接收当前学员并保留计划与完成状态', () => {
 test('旧系统抗遗忘记录只接收当前学员并保留计划与完成状态', () => {
@@ -106,3 +108,8 @@ test('抗遗忘记录首次补齐后重复核对不再写入', async () => {
   assert.equal(writes[0][0].table, 'MemoryPracticeRecord');
   assert.equal(writes[0][0].table, 'MemoryPracticeRecord');
   assert.equal(writes[0][1].match.generalId, 866548);
   assert.equal(writes[0][1].match.generalId, 866548);
 });
 });
+
+ test('页面旧接口刷新保留已转交老师与完成状态并记录冲突',async()=>{
+ const {context,writes,conflicts}=contextFor([{GeneralID:99,yhid:42,plid:155,fxzt:'1',CreateTime:'2026-10-13 10:00:00'}],[{generalId:99,itemId:99,taskObjectId:'task99',assignmentVersion:1,plid:166,fxzt:'0'}]);
+ const result=await context.sync(42,{id:'actor'},true);assert.equal(result.written,0);assert.equal(result.conflicts,1);assert.equal(result.state,'conflict');assert.equal(writes.length,0);assert.equal(conflicts.length,1);
+ });

+ 76 - 0
scripts/tests/lesson-records.test.mjs

@@ -0,0 +1,76 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+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))})};
+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};
+const preview=i=>ctx.lessonTransferPreview(manager,{...input,...i});
+const commit=async(p,extra={})=>ctx.lessonTransferCommit(manager,{...input,version:p.version,reason:'离岗交接',idempotencyKey:'transfer-test-0001',...extra});
+async function task(id,day,extra={}){await query(`INSERT INTO "MemoryPracticeRecord" ("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,fxzt,wcsj,"updatedAt") VALUES($1,'c',$2,$3,$4,$5,1000,$6,$7,'',now())`,[id,Number(id.replace(/\D/g,''))||10,extra.coach??10,extra.student??20,extra.study??100,day,extra.status??'0']);}
+before(async()=>{
+ await db.exec(readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('../sql/lesson-records.sql',import.meta.url),'utf8'));
+ await db.exec(`INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","adminRoleKey","realName","legacyUserData") VALUES
+ ('manager','c',1,1,'member','ops-manager','管理员','{}'),('staff','c',2,1,'member','ops-staff','员工','{}'),('old','c',10,3,'coach',null,'原老师','{}'),('new','c',11,3,'coach',null,'新老师','{}'),('student','c',20,1,'member',null,'林可欣','{"ParentUserID":40}'),('store','c',40,2,'store',null,'南昌总店','{}');
+ INSERT INTO "DailyStudyRecord"("objectId",company,id,"userId",dsid) VALUES('study','c',100,20,'1000');
+ INSERT INTO "CommonModel"("objectId",company,"generalId","itemId","modelId") VALUES('studyCommon','c',100,100,56);
+ INSERT INTO "Node" VALUES('c',30,'单词课');`);
+ for(let i=1;i<=55;i++){await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,kcid,kcmc,yyds,kclx,pldp,pjnr,plpjsj,"updatedAt") VALUES($1,'c',$2,'20','10','30','七年级单词',$3,'2','老师点评','学生评价',$4,now())`,['lesson'+i,i,String(1000+i-1),'2026-10-'+String(Math.ceil(i/3)).padStart(2,'0')+' 10:00:00']);}
+ await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,yyds,plpjsj) VALUES('duplicate','c',56,'20','10','1000','2026-10-01 10:00:00')`);
+ await query(`INSERT INTO "LessonCompletionReport"("objectId",company,"lessonId","appointmentGeneralId","contentSummary","coachComment") VALUES('report','c','lesson2',1001,'学习17词','新式完整点评')`);
+ await task('pending1','2026-01-01');await task('pending2','2099-01-01');await task('done3','2026-01-03',{status:'1'});
+});
+after(()=>db.close());
+test('历史查询按实际日期倒序、分页计数去重;姓名ID门店课程日期组合筛选',async()=>{
+ const p=await ctx.lessonHistoryPage(manager,{});assert.equal(p.total,55);assert.equal(p.cumulativeTotal,55);assert.equal(p.items.length,20);assert.equal(p.items[0].lessonId,'lesson55');
+ const next=await ctx.lessonHistoryPage(manager,{page:3});assert.equal(next.items.length,15);assert.equal(next.items.at(-1).duplicateCount,2);
+ const filtered=await ctx.lessonHistoryPage(manager,{studentId:20,studentName:'林',coachName:'原',storeName:'南昌',courseName:'七年级',dateFrom:'2026-10-02',dateTo:'2026-10-02'});assert.equal(filtered.total,3);assert.equal(filtered.cumulativeTotal,55);
+ assert.equal((await ctx.lessonHistoryPage({...manager,company:'other'},{})).total,0);
+ assert.equal((await ctx.lessonHistoryPage(manager,{search:"%' OR 1=1--"})).total,0);
+});
+test('按姓名搜索时累计课次只统计匹配学员,日期只影响当前筛选',async()=>{
+ await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,yyds,plpjsj) VALUES('otherStudent','c',999,'21','10','other','2026-10-01 10:00:00')`);
+ const p=await ctx.lessonHistoryPage(manager,{search:'林可欣',dateFrom:'2026-10-02',dateTo:'2026-10-02'});assert.equal(p.total,3);assert.equal(p.cumulativeTotal,55);
+ await query(`DELETE FROM "LessonRecord" WHERE "objectId"='otherStudent'`);
+});
+test('反馈详情保留双方原始文本,读取新报告,缺失课次返回失败',async()=>{
+ const old=await ctx.lessonHistoryDetail(manager,{lessonId:'lesson3'});assert.equal(old.coachComment,'老师点评');assert.equal(old.studentReview.comment,'学生评价');assert.equal(old.hasReport,false);
+ const modern=await ctx.lessonHistoryDetail(manager,{appointmentId:'1001'});assert.equal(modern.report.contentSummary,'学习17词');assert.equal(modern.coachComment,'新式完整点评');assert.equal(modern.canTransfer,true);
+ await assert.rejects(ctx.lessonHistoryDetail(manager,{lessonId:'missing'}),/未找到/);
+});
+test('预览包含逾期与未来,已完成及工资排除;转交幂等、逐任务审计',async()=>{
+ const p=await preview();assert.equal(p.count,2);assert.equal(p.lessonCount,1);assert.equal(p.dateFrom,'2026-01-01');
+ const rewardBefore=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'done3\'');
+ const result=await commit(p);assert.equal(result.count,2);assert.deepEqual(await commit(p),result);
+ assert.equal(Number((await one('SELECT plid FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending1\'')).plid),11);
+ assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'done3\''),rewardBefore);
+ assert.equal((await one('SELECT count(*)::int n FROM "ReviewTransferAudit"')).n,2);
+ await assert.rejects(commit(p,{reason:'更改原因'}),/请求编号/);
+});
+test('原老师旧页面不能提交,新老师完成按新归属计薪,重复完成无重复工资',async()=>{
+ await assert.rejects(query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','old']),/复习已转交/);
+ await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','new']);await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','new']);
+ const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'pending1\'');assert.equal(r.coachId,11);assert.equal(Number(r.amount),1);
+});
+test('旧同步不能回退手工归属或代新老师完成;冲突留痕',async()=>{
+ await query(`UPDATE "MemoryPracticeRecord" SET plid=10,fxzt='1',wcsj='2026-10-01' WHERE "objectId"='pending2'`);
+ const t=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending2\'');assert.equal(Number(t.plid),11);assert.equal(t.fxzt,'0');assert.equal((await one('SELECT count(*)::int n FROM "ReviewAssignmentConflict"')).n,1);
+});
+test('完成抢先或版本改变后旧预览失败;权限和停用陪练拒绝',async()=>{
+ const p=await preview({coachId:10});await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending2','new']);
+ await assert.rejects(commit(p,{coachId:10,idempotencyKey:'stale-preview-002'}),/已变化/);
+ await assert.rejects(ctx.lessonTransferPreview({...manager,role:'ops-staff'},input),/forbidden/);
+ await assert.rejects(query('SELECT xs_review_transfer($1,$2,$3,$4,$5,$6,$7,$8)',['c','staff','20','1000','11','x','测试','unauth-000']),/仅管理/);
+ await query(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='new'`);await assert.rejects(preview(),/停用/);await query(`UPDATE "_User" SET "isDisabled"=false WHERE "objectId"='new'`);
+});
+test('全部学员范围只覆盖已有任务,不更改未来课程默认,跨帐套不可转交',async()=>{
+ await task('pending4','2099-02-01');await task('pending5','2099-02-02',{student:21});
+ const p=await preview({scope:'student'});assert.equal(p.count,1);await commit(p,{scope:'student',idempotencyKey:'student-scope-003'});
+ assert.equal(Number((await one('SELECT plid FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending5\'')).plid),10);
+ await assert.rejects(ctx.lessonTransferPreview({...manager,company:'other'},input),/仅管理/);
+});

+ 6 - 0
scripts/tests/review-assignment-access.test.mjs

@@ -0,0 +1,6 @@
+import test from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';
+const gateway=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+function harness(rows){const source=gateway.slice(gateway.indexOf('async function appReviewTarget'),gateway.indexOf('function normalizeWordRow'));let queries=[];const ctx={isAdminUser:()=>false,ownLegacyId:u=>u.id,number:v=>Number(v)||0,DEFAULT_COMPANY_ID:'c',fail:(status,message)=>{throw Error(status+': '+message)},Psql:{oneOrNone:async(s,p)=>{queries.push({s,p});return rows;}},appLearningTarget:async()=>{throw Error('No teaching relationship');}};vm.createContext(ctx);vm.runInContext(source,ctx);return{ctx,queries};}
+const teacher={id:11,get:k=>k==='legacyGroupId'?3:null};
+test('接手老师仅凭当前任务获得学员复习筛选和来源词表权限',async()=>{const {ctx,queries}=harness({exists:1});assert.equal(await ctx.appReviewTarget(teacher,20),20);await ctx.authorizeLearningRecordAccess(teacher,{UserID:20,pl:10,GeneralID:100});assert.ok(queries.every(q=>q.p[0]==='c'&&q.p[1]==='11'&&q.p[2]==='20'));assert.equal(queries.at(-1).p[3],'100');});
+test('其他学员和未分配来源被拒绝;不自动放宽整段学习历史',async()=>{const {ctx}=harness(null);await assert.rejects(ctx.appReviewTarget(teacher,99),/403/);await assert.rejects(ctx.authorizeLearningRecordAccess(teacher,{UserID:20,pl:10,GeneralID:999}),/No teaching relationship/);});

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä