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

feat(teaching): select student enrolled books from appointments

彭峰 5 часов назад
Родитель
Сommit
22fe03a199

+ 28 - 0
docs/migration/lesson-course-selection-2026-10-12.md

@@ -0,0 +1,28 @@
+# 教师预约详情按学员选择课本验收
+
+2026-10-12,北京时间。生产应用 `7pIbDBJmKx_main`。
+
+## 行为
+
+教师预约详情显示该学员全部有效的已开通课程,以开课编号区分同名课本或不同分类。默认选中排课课程,老师可以切换课本或词库,查看所选课程的单元和进度,再开始或继续本节课。学生详情不显示教师选择入口。
+
+切换课程清空原先选中的单元,取消旧单元请求,防止迟到响应覆盖新课本;标记学完写入所选开课。加载失败、空列表、无单元时给出明确反馈,缺少有效选择不能开始词汇学习。
+
+实际学习内容与预约计薪规则分别保存:切换课本不修改原排课的课程、开课编号、工资和课时快照。学习路由、学习草稿和学习记录携带实际选择的开课编号。不同开课的学习记录分别保存,结课汇总学习词汇,抗遗忘任务关联实际词库;仍只扣一次原预约课时。
+
+## 权限与发布
+
+- `app_lesson_courses` 根据预约查找所属学员,只允许实际预约老师读取。忽略客户端传入的其他学员编号;待确认、已回收、已停用的开课不作为有效选项。
+- 开始时在课时业务锁内重新核对预约版本、教师归属、所选开课归属及内容有效性。保存学习记录还验证所选单词属于该课程。
+- 本次仅发布 App 网关和 `xs_lesson_complete` 例程。发布前备份线上代码及例程,并确认没有正在执行的结课查询。迁移不回填历史数据、不修改预约或余额,临时部署函数已删除并回读确认。
+- 线上 `migration_status` 版本为 `2026-10-12-lesson-course-selection-v4`,包含 `app_lesson_courses`;线上 App 完整代码与本地打包代码一致。
+
+## 验证
+
+- 62项移动端测试通过,包括预约交互与API读取不触发刷新循环;37项相关Node/PGlite测试通过,包括选课权限、记录隔离与幂等、多词库结课和原有预约修复。11个云函数定义校验通过,移动端生产构建成功。
+- 真实教师现有会话:熊小俊预约858326返回4门有效开课,小赵预约858566返回4门,花花预约858626返回6门;每门课程的单元接口均通过。其他老师读取熊小俊的预约课程列表返回403。
+- 熊小俊实际可选:新课标高中考纲3000词、人教版2024年9月录、人教版(PEP)三年级起2024年9月录、人教版(PEP)三年级起2022年录。
+- 生产事务回滚验证:选择另一已开通课本171后,开始逻辑在事务内到达学习中;原排课课程443、绑定、工资和课时快照保留。强制回滚后预约及学员余额与事务前完全一致,未替教师实际开始课程。
+- 本地移动端4200已启动,预约详情路径与main.js均返回200。当前浏览器会话已过期,真实登录后的浏览器手动点击尚未验收;页面切换交互通过ChromeHeadless测试,线上师生数据权限通过真实接口验证。
+
+私有发布备份、测试日志、接口回读和事务回滚证据位于 `/Users/mac/.local/state/xiaoshu/course-selection/2026-10-12/`,不提交Git。夜间同步与工资结算来源完整性的验收状态未由本功能改变。

+ 11 - 0
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -40,6 +40,17 @@ describe('ApiService', () => {
     expect(actual).toEqual([{ ID: 1 }]);
     expect(actual).toEqual([{ ID: 1 }]);
   });
   });
 
 
+  it('treats lesson courses as a read and does not trigger appointment refresh loops', () => {
+    sessionToken = 'r:session-token';
+    let changes = 0;
+    api.dataChanges$.subscribe(() => changes++);
+    api.get('app_lesson_courses', { orderId: 901 }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body.params.action).toBe('app_lesson_courses');
+    request.flush({ retcode: 0, result: { items: [] } });
+    expect(changes).toBe(0);
+  });
+
   it('coalesces concurrent overview reads but fetches fresh data after completion', () => {
   it('coalesces concurrent overview reads but fetches fresh data after completion', () => {
     sessionToken = 'r:session-token';
     sessionToken = 'r:session-token';
     const first: unknown[] = [];
     const first: unknown[] = [];

+ 2 - 2
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -46,7 +46,7 @@ export class ApiService {
       ...(sessionToken ? { token: sessionToken } : {}),
       ...(sessionToken ? { token: sessionToken } : {}),
       params: { action, ...params },
       params: { action, ...params },
     };
     };
-    const bounded = ['user_get','e_order_detail','e_order_update_v2','app_companion_overview','app_live_versions','app_lesson_complete','app_lesson_review'].includes(action);
+    const bounded = ['app_lesson_courses','user_get','e_order_detail','e_order_update_v2','app_companion_overview','app_live_versions','app_lesson_complete','app_lesson_review'].includes(action);
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
       headers: {
       headers: {
         'Content-Type': 'application/json',
         'Content-Type': 'application/json',
@@ -81,7 +81,7 @@ export class ApiService {
 
 
   private isReadAction(action: string): boolean {
   private isReadAction(action: string): boolean {
     return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
     return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
-      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_live_versions', 'app_review_page', 'app_lesson_report'].includes(action);
+      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_live_versions', 'app_review_page', 'app_lesson_report', 'app_lesson_courses'].includes(action);
   }
   }
 
 
   private normalize<T>(input: unknown): ApiEnvelope<T> {
   private normalize<T>(input: unknown): ApiEnvelope<T> {

+ 38 - 4
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
 import { provideRouter, Router } from '@angular/router';
 import { provideRouter, Router } from '@angular/router';
 import { RouterTestingHarness } from '@angular/router/testing';
 import { RouterTestingHarness } from '@angular/router/testing';
 import { By } from '@angular/platform-browser';
 import { By } from '@angular/platform-browser';
-import { of, throwError } from 'rxjs';
+import { of, throwError, Subject } from 'rxjs';
 import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
 import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
 import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
 import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
 import { ApiService, ApiError } from './api.service';
 import { ApiService, ApiError } from './api.service';
@@ -14,11 +14,11 @@ import { appointmentDate, beijingDate } from './appointment-time';
 @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
 @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
 
 
 describe('appointment workflow screens',()=>{
 describe('appointment workflow screens',()=>{
- let order:Record<string,unknown>,failDetail:boolean,calls:Array<{action:string;params:Record<string,unknown>}>;
+ let order:Record<string,unknown>,failDetail:boolean,failCourses:boolean,courses:Record<string,unknown>[]|null,delayedUnits:Subject<unknown>|null,calls:Array<{action:string;params:Record<string,unknown>}>;
  const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
  const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
  const response=(result:unknown)=>of({retcode:0,result});
  const response=(result:unknown)=>of({retcode:0,result});
  beforeEach(()=>{
  beforeEach(()=>{
-  localStorage.clear();sessionStorage.clear();calls=[];failDetail=false;user.set({userId:30,groupId:3});groupId.set(3);
+  localStorage.clear();sessionStorage.clear();calls=[];failDetail=false;failCourses=false;courses=null;delayedUnits=null;user.set({userId:30,groupId:3});groupId.set(3);
   order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
   order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
   const invoke=(action:string,params:Record<string,unknown>={})=>{
   const invoke=(action:string,params:Record<string,unknown>={})=>{
    calls.push({action,params});
    calls.push({action,params});
@@ -26,7 +26,8 @@ describe('appointment workflow screens',()=>{
    if(action==='app_live_versions')return response({version:'appointments=1',scopeVersions:{appointments:'1'},syncStatus:{state:'current'}});
    if(action==='app_live_versions')return response({version:'appointments=1',scopeVersions:{appointments:'1'},syncStatus:{state:'current'}});
    if(action==='app_companion_overview')return response({courses:[],schedules:[{...order}],dates:[{date:'2099-10-11',finished:0,planned:1}],studentOptions:[],page:{itemCount:1,pageCount:1},syncStatus:{state:'current'}});
    if(action==='app_companion_overview')return response({courses:[],schedules:[{...order}],dates:[{date:'2099-10-11',finished:0,planned:1}],studentOptions:[],page:{itemCount:1,pageCount:1},syncStatus:{state:'current'}});
    if(action==='user_get')return response({userId:20,honeyName:'测试学员',groupId:1});
    if(action==='user_get')return response({userId:20,honeyName:'测试学员',groupId:1});
-   if(action==='node_list')return response([{NodeID:811,NodeName:'Unit 1'}]);
+   if(action==='app_lesson_courses')return failCourses?throwError(()=>new ApiError('课程读取失败')):response({items:courses||[{bindingId:'binding-1',GeneralID:5801,kcid:801,Title:'词表',categoryKey:order['courseCategoryKey'],scheduled:true,syjd:'{}'},{bindingId:'binding-2',GeneralID:5802,kcid:802,Title:'另一词库',categoryKey:'word',syjd:'{"822":"822@Unit 2@1"}'}]});
+   if(action==='node_list')return delayedUnits||response([{NodeID:params['pid']==='802'?822:811,NodeName:'Unit 1'}]);
    if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
    if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
    if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:[],reviewStatus:'pending'};return response({status:30});}
    if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:[],reviewStatus:'pending'};return response({status:30});}
    if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
    if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
@@ -120,6 +121,39 @@ describe('appointment workflow screens',()=>{
   component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
   component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
   expect(TestBed.inject(Router).url).toContain('orderId=901');expect(TestBed.inject(Router).url).toContain('bindingId=binding-1');
   expect(TestBed.inject(Router).url).toContain('orderId=901');expect(TestBed.inject(Router).url).toContain('bindingId=binding-1');
  });
  });
+ it('lists every opened enrollment and switches units, progress and the classroom binding together',async()=>{
+  const {harness,component}=await screen();
+  const select=harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]') as HTMLSelectElement;
+  expect(select.options.length).toBe(3);expect(select.value).toBe('binding-1');
+  component.toggleUnit({NodeID:811});
+  select.value='binding-2';select.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
+  expect(component.selectedCourse).toBe('802');expect(component.selectedUnits().size).toBe(0);expect(component.completedUnits().has('822')).toBeTrue();
+  expect(calls.filter(c=>c.action==='node_list').at(-1)?.params).toEqual(jasmine.objectContaining({pid:'802',bindingId:'binding-2',userId:20}));
+  component.toggleUnit({NodeID:822});component.startOrContinue();await harness.fixture.whenStable();
+  expect(calls.find(c=>c.action==='e_order_update_v2')?.params).toEqual(jasmine.objectContaining({learningBindingId:'binding-2',expectedUpdatedAt:order['updatedAt']}));
+  const url=TestBed.inject(Router).url;expect(url).toContain('pnid=802');expect(url).toContain('bindingId=binding-2');
+  expect(order['bindingId']).toBe('binding-1');expect(order['kcid']).toBe(801);
+ });
+ it('ignores an old unit response after selecting another book and marks the selected enrollment only',async()=>{
+  delayedUnits=new Subject();const old=delayedUnits;
+  const {component}=await screen();delayedUnits=null;component.selectedBindingId='binding-2';component.chooseCourse();
+  old.next({result:[{NodeID:811,NodeName:'Old'}]});
+  expect(component.units()[0]['NodeID']).toBe(822);
+  await component.markUnitComplete({NodeID:822,NodeName:'Unit 2'});
+  const payload=calls.find(c=>c.action==='content_update')?.params;
+  expect(JSON.parse(String(payload?.['content'])).GeneralID).toBe(5802);
+ });
+ for(const mode of ['empty','failure'])it(`blocks start when the course list is ${mode}`,async()=>{
+  if(mode==='empty')courses=[];else failCourses=true;
+  const {component}=await screen();component.toggleUnit({NodeID:811});component.startOrContinue();
+  expect(calls.some(c=>c.action==='e_order_update_v2')).toBeFalse();
+ });
+ it('does not request teacher course selection in the student view',async()=>{
+  user.set({userId:20,groupId:1});groupId.set(1);order['allowedActions']=[];
+  const {harness}=await screen('/pages/shop/stu_get_order?id=901');
+  expect(calls.some(c=>c.action==='app_lesson_courses')).toBeFalse();
+  expect(harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]')).toBeNull();
+ });
  it('does not allow a cached appointment to write when the detail endpoint fails',async()=>{
  it('does not allow a cached appointment to write when the detail endpoint fails',async()=>{
   failDetail=true;
   failDetail=true;
   const harness=await RouterTestingHarness.create();
   const harness=await RouterTestingHarness.create();

+ 16 - 5
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -24,9 +24,19 @@
     </dl>
     </dl>
     </section>
     </section>
 
 
-    @if (!order()['cancelled'] && !isStudentView && status() <= 10 && !isWriting) {
+    @if (!order()['cancelled'] && !isStudentView && status() <= 10) {
       <section class="course-section">
       <section class="course-section">
-        <label><span>本次课程</span><strong>{{order()['courseCategoryName'] || ''}} · {{field('course')}}</strong><small>如需改课,请由管理员调整排课</small></label>
+        <label><span>本次学习课本 / 词库</span>
+          <select aria-label="本次学习课本或词库" [(ngModel)]="selectedBindingId" (ngModelChange)="chooseCourse()" [disabled]="coursesLoading() || submitting()">
+            <option value="">{{coursesLoading() ? '正在加载学员课程…' : '请选择已开通课程'}}</option>
+            @for (course of courses(); track course['bindingId']) { <option [value]="course['bindingId']">{{course['Title']}} · {{course['categoryName'] || '已开通'}}{{course['scheduled'] ? '(排课课程)' : ''}}</option> }
+          </select>
+          <small>可选择该学员所有已开通的课程,默认使用排课课程。</small>
+        </label>
+        @if(coursesError()){<p class="detail-warning" role="alert">{{coursesError()}}</p><button type="button" class="secondary-button" (click)="load()">重新加载课程</button>}
+        @else if(!coursesLoading() && !courses().length){<p>该学员暂无已开通课程,请联系管理员开通。</p>}
+        @if(isWriting){<p class="schedule-hint">当前选择语法写作课程,可开始上课并在结束时填写学习内容。</p>}
+        @else {
         <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,右侧按钮可标记已学完</span></div>
         <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,右侧按钮可标记已学完</span></div>
         <div class="unit-grid">
         <div class="unit-grid">
           @for (unit of units(); track $index) {
           @for (unit of units(); track $index) {
@@ -46,8 +56,9 @@
               </button>
               </button>
               <button type="button" class="mark-complete" [disabled]="!can('start') && !can('continue')" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
               <button type="button" class="mark-complete" [disabled]="!can('start') && !can('continue')" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
             </article>
             </article>
-          } @empty { <div class="empty-state"><p>请选择课程后加载单元</p></div> }
+          } @empty { <div class="empty-state"><p>{{unitsLoading() ? '正在加载学习单元…' : selectedBindingId ? '暂无可用学习单元' : '请选择课程后加载单元'}}</p></div> }
         </div>
         </div>
+        }
       </section>
       </section>
     }
     }
 
 
@@ -103,8 +114,8 @@
     }
     }
 
 
     <div class="appointment-actions">
     <div class="appointment-actions">
-      @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
-      @if (can('continue')) { <button type="button" [class.primary-button]="!can('complete')" [class.secondary-button]="can('complete')" [disabled]="submitting()" (click)="startOrContinue()">继续课程</button> }
+      @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
+      @if (can('continue')) { <button type="button" [class.primary-button]="!can('complete')" [class.secondary-button]="can('complete')" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">继续课程</button> }
       @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">{{status()===10?'结束课程':'补齐反馈并结课'}}</button> }
       @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">{{status()===10?'结束课程':'补齐反馈并结课'}}</button> }
       @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
       @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
       @if (status()>=30 && !order()['cancelled']) { <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
       @if (status()>=30 && !order()['cancelled']) { <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }

+ 45 - 61
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -28,6 +28,11 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly practiceRecord = signal<SourceItem>({});
   readonly practiceRecord = signal<SourceItem>({});
   readonly learningRecord = signal<SourceItem>({});
   readonly learningRecord = signal<SourceItem>({});
   readonly courses = signal<SourceItem[]>([]);
   readonly courses = signal<SourceItem[]>([]);
+  readonly coursesLoading = signal(false);
+  readonly coursesError = signal('');
+  readonly unitsLoading = signal(false);
+  private courseRequest?: Subscription;
+  private unitRequest?: Subscription;
   readonly units = signal<SourceItem[]>([]);
   readonly units = signal<SourceItem[]>([]);
   readonly selectedUnits = signal<Set<string>>(new Set());
   readonly selectedUnits = signal<Set<string>>(new Set());
   readonly completedUnits = signal<Set<string>>(new Set());
   readonly completedUnits = signal<Set<string>>(new Set());
@@ -48,6 +53,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
 
 
   readonly feedback=computed(()=>this.order()['feedback'] as Record<string,unknown>|null);
   readonly feedback=computed(()=>this.order()['feedback'] as Record<string,unknown>|null);
   selectedCourse = '';
   selectedCourse = '';
+  selectedBindingId = '';
   coachComment = '';
   coachComment = '';
   contentSummary = '';
   contentSummary = '';
   reviewStatus = '';
   reviewStatus = '';
@@ -78,7 +84,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     this.stopLive = this.liveData.watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:()=>{if(!this.submitting())this.load(true);}});
     this.stopLive = this.liveData.watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:()=>{if(!this.submitting())this.load(true);}});
   }
   }
 
 
-  ngOnDestroy(): void { this.stopLive?.(); this.loadRequest?.unsubscribe(); }
+  ngOnDestroy(): void { this.stopLive?.(); this.loadRequest?.unsubscribe(); this.courseRequest?.unsubscribe(); this.unitRequest?.unsubscribe(); }
 
 
   can(action: string): boolean { return this.detailVerified() && !this.order()['cancelled'] && Array.isArray(this.order()['allowedActions']) && (this.order()['allowedActions'] as string[]).includes(action); }
   can(action: string): boolean { return this.detailVerified() && !this.order()['cancelled'] && Array.isArray(this.order()['allowedActions']) && (this.order()['allowedActions'] as string[]).includes(action); }
 
 
@@ -86,7 +92,8 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
 
 
   get isStudentView(): boolean { return this.meta.path === 'pages/shop/stu_get_order'; }
   get isStudentView(): boolean { return this.meta.path === 'pages/shop/stu_get_order'; }
   get orderId(): string { return this.route.snapshot.queryParamMap.get('id') ?? this.route.snapshot.queryParamMap.get('cart') ?? ''; }
   get orderId(): string { return this.route.snapshot.queryParamMap.get('id') ?? this.route.snapshot.queryParamMap.get('cart') ?? ''; }
-  get isWriting(): boolean { return ['primary_writing','middle_writing','high_writing'].includes(String(this.order()['courseCategoryKey'] || '')) || String(this.order()['if_writing'] ?? '') === '1'; }
+  get selectedCourseItem(): SourceItem | undefined { return this.courses().find(row => String(row['bindingId']) === this.selectedBindingId); }
+  get isWriting(): boolean { return ['primary_writing','middle_writing','high_writing'].includes(String(this.selectedCourseItem?.['categoryKey'] || this.order()['courseCategoryKey'] || '')); }
 
 
   load(background = false): void {
   load(background = false): void {
     this.loadRequest?.unsubscribe();
     this.loadRequest?.unsubscribe();
@@ -107,7 +114,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
         // Older servers already support starting lessons. Missing new workflow metadata
         // Older servers already support starting lessons. Missing new workflow metadata
         // must not disable that endpoint; never derive write access from the cached row.
         // must not disable that endpoint; never derive write access from the cached row.
         if (!Array.isArray(detail['allowedActions'])) Object.assign(detail, this.legacyStartActions(detail));
         if (!Array.isArray(detail['allowedActions'])) Object.assign(detail, this.legacyStartActions(detail));
-        if (background) { this.order.set({...this.order(),...detail}); return; }
+        if (background) { const changed = this.order()['bindingId'] !== detail['bindingId']; this.order.set({...this.order(),...detail}); if (changed && !this.isStudentView) this.loadCourses(); return; }
         this.applyOrder({ ...fallback, ...detail });
         this.applyOrder({ ...fallback, ...detail });
       },
       },
       error: (error: ApiError) => {
       error: (error: ApiError) => {
@@ -163,17 +170,26 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   private applyOrder(order: SourceItem): void {
   private applyOrder(order: SourceItem): void {
     this.order.set(order);
     this.order.set(order);
     this.syncAppointmentStudent(order);
     this.syncAppointmentStudent(order);
-    this.selectedCourse = String(order['kcid'] ?? '');
+    if (!this.selectedBindingId) { this.selectedCourse = String(order['kcid'] ?? ''); this.selectedBindingId = String(order['bindingId'] || ''); }
     if (!this.coachComment) this.coachComment = String(order['pldp'] ?? '');
     if (!this.coachComment) this.coachComment = String(order['pldp'] ?? '');
     this.loadPracticeRecord();
     this.loadPracticeRecord();
     if (!this.isStudentView) {
     if (!this.isStudentView) {
-      this.loadCourses();
+      if (this.status() <= 10) this.loadCourses();
       this.loadLearningRecord();
       this.loadLearningRecord();
-      if (this.status() <= 10 && this.selectedCourse) this.loadUnits();
     }
     }
   }
   }
 
 
-  chooseCourse(): void { this.platform.toast('如需改课,请由管理员调整排课', 'error'); }
+  chooseCourse(): void {
+    this.unitRequest?.unsubscribe();
+    this.units.set([]); this.selectedUnits.set(new Set()); this.completedUnits.set(new Set()); this.bindingRecord.set({});
+    this.actionError.set('');
+    const course = this.selectedCourseItem;
+    this.selectedCourse = String(course?.['kcid'] || '');
+    if (!course) return;
+    this.bindingRecord.set(course);
+    this.completedUnits.set(new Set(Object.keys(this.parseRecord(course['syjd']))));
+    if (!this.isWriting) this.loadUnits();
+  }
 
 
   toggleUnit(unit: SourceItem): void {
   toggleUnit(unit: SourceItem): void {
     const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
     const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
@@ -186,7 +202,9 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
 
 
   async markUnitComplete(unit: SourceItem): Promise<void> {
   async markUnitComplete(unit: SourceItem): Promise<void> {
     if (!this.detailVerified() || (!this.can('start') && !this.can('continue'))) return;
     if (!this.detailVerified() || (!this.can('start') && !this.can('continue'))) return;
+    const selectedBindingId = this.selectedBindingId;
     if (!(await this.platform.confirm(`确认将“${this.unitTitle(unit)}”标记为已学完?`))) return;
     if (!(await this.platform.confirm(`确认将“${this.unitTitle(unit)}”标记为已学完?`))) return;
+    if (selectedBindingId !== this.selectedBindingId) return;
     const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
     const id = String(unit['NodeID'] ?? unit['ID'] ?? '');
     const binding = this.bindingRecord();
     const binding = this.bindingRecord();
     if (!binding['GeneralID']) {
     if (!binding['GeneralID']) {
@@ -199,7 +217,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
       content: JSON.stringify({ GeneralID: binding['GeneralID'], ModelID: 58 }),
       content: JSON.stringify({ GeneralID: binding['GeneralID'], ModelID: 58 }),
       addon: JSON.stringify({ syjd: JSON.stringify(progress) }),
       addon: JSON.stringify({ syjd: JSON.stringify(progress) }),
     }).subscribe({
     }).subscribe({
-      next: () => this.completedUnits.update((current) => new Set(current).add(id)),
+      next: () => { this.courses.update(rows => rows.map(row => String(row['bindingId']) === selectedBindingId ? {...row,syjd:JSON.stringify(progress)} : row)); if (selectedBindingId === this.selectedBindingId) { this.bindingRecord.set({...binding,syjd:JSON.stringify(progress)}); this.completedUnits.update(current => new Set(current).add(id)); } },
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
     });
     });
   }
   }
@@ -230,6 +248,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     this.actionError.set('');
     this.actionError.set('');
     if (!this.can('start') && !this.can('continue')) { this.actionError.set(String(this.order()['blockedReason'] || '详情未核实,请刷新后重试')); return; }
     if (!this.can('start') && !this.can('continue')) { this.actionError.set(String(this.order()['blockedReason'] || '详情未核实,请刷新后重试')); return; }
     if(this.order()['cancelled']===true){this.platform.toast('此预约已取消,不能开始课程','error');return;}
     if(this.order()['cancelled']===true){this.platform.toast('此预约已取消,不能开始课程','error');return;}
+    if (this.coursesLoading() || this.unitsLoading() || !this.selectedCourseItem || this.coursesError()) { this.actionError.set('请先加载并选择该学员已开通的课程'); return; }
     if (!this.isWriting && !this.selectedUnits().size) {
     if (!this.isWriting && !this.selectedUnits().size) {
       this.actionError.set(this.units().length ? '请选择本次学习单元' : '暂无可用学习单元,请刷新重试或联系管理员核对课程内容');
       this.actionError.set(this.units().length ? '请选择本次学习单元' : '暂无可用学习单元,请刷新重试或联系管理员核对课程内容');
       return;
       return;
@@ -241,7 +260,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
       }
       }
       void this.router.navigate(['/pages/stu/stu_abc'], {
       void this.router.navigate(['/pages/stu/stu_abc'], {
         replaceUrl: true,
         replaceUrl: true,
-        queryParams: { nids: [...this.selectedUnits()].join(','), pnid: this.selectedCourse, orderId: this.orderId, bindingId:this.order()['bindingId']||undefined },
+        queryParams: { nids: [...this.selectedUnits()].join(','), pnid: this.selectedCourse, orderId: this.orderId, bindingId:this.selectedBindingId||undefined },
       });
       });
     });
     });
   }
   }
@@ -297,7 +316,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   }
   }
   private updateOrderStatus(status:number,next:()=>void):void{
   private updateOrderStatus(status:number,next:()=>void):void{
     if(this.submitting())return;this.submitting.set(true);
     if(this.submitting())return;this.submitting.set(true);
-    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.actionError.set(error.message)});
+    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,learningBindingId:this.selectedBindingId,expectedUpdatedAt:this.order()['updatedAt'],content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.actionError.set(error.message)});
   }
   }
 
 
   private loadPracticeRecord(): void {
   private loadPracticeRecord(): void {
@@ -316,54 +335,28 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   }
   }
 
 
   private loadCourses(): void {
   private loadCourses(): void {
-    const studentId = this.appointmentStudentId();
-    const currentCourse = this.currentCourseOption();
-    if (!studentId) {
-      this.courses.set(currentCourse ? [currentCourse] : []);
-      return;
-    }
-    this.api.get<unknown>('content_list', { inputer: '', nid: 28, myfield: `yhid=${studentId}`, orders: 'id=DESC', psize: 200 }).subscribe({
-      next: ({ result }) => {
-        const courses: SourceItem[] = this.findList(result).map((item): SourceItem => {
-          const parts = String(item['Title'] ?? '').split('|');
-          return { ...item, Title: parts[parts.length - 1] || '-', kcId: item['kcid'] };
-        });
-        if (currentCourse && !courses.some((course) => String(course['kcid'] ?? course['kcId'] ?? '') === this.selectedCourse)) {
-          courses.unshift(currentCourse);
-        }
+    this.courseRequest?.unsubscribe(); this.unitRequest?.unsubscribe();
+    this.coursesLoading.set(true); this.coursesError.set(''); this.units.set([]); this.selectedUnits.set(new Set());
+    this.courseRequest = this.api.get<unknown>('app_lesson_courses', { orderId: this.orderId }).pipe(finalize(() => this.coursesLoading.set(false))).subscribe({
+      next: ({result}) => {
+        const courses = this.findList(result);
         this.courses.set(courses);
         this.courses.set(courses);
+        const selected = courses.find(row => String(row['bindingId']) === this.selectedBindingId)
+          || courses.find(row => String(row['bindingId']) === String(this.order()['bindingId'] || ''));
+        this.selectedBindingId = String(selected?.['bindingId'] || '');
+        this.chooseCourse();
       },
       },
-      error: () => this.courses.set(currentCourse ? [currentCourse] : []),
+      error: (error: ApiError) => { this.courses.set([]); this.selectedBindingId = ''; this.coursesError.set('学员课程加载失败:' + error.message); },
     });
     });
   }
   }
 
 
   private loadUnits(): void {
   private loadUnits(): void {
-    const studentId = this.appointmentStudentId();
-    if (!studentId) {
-      this.units.set([]);
-      this.platform.toast('预约记录缺少学员 ID,暂时无法加载学习单元', 'error');
-      return;
-    }
-    this.api.get<unknown>('node_list', { pid: this.selectedCourse, bindingId: this.order()['bindingId'] || '', ifunit: 1, userId: studentId }).subscribe({
-      next: ({ result }) => {
-        this.units.set(this.findList(result));
-        this.loadUnitProgress();
-      },
-      error: (error: ApiError) => this.actionError.set('学习单元加载失败:'+error.message),
-    });
-  }
-
-  private loadUnitProgress(): void {
-    const studentId = this.appointmentStudentId();
-    if (!studentId) return;
-    this.api.post<unknown>('content_list', { ModelID: 58, myfield2: `yhid=${studentId}|kcid=${this.selectedCourse}` }).subscribe({
-      next: ({ result }) => {
-        const records=this.findList(result),bindingId=String(this.order()['bindingId']||'');
-        const record = (bindingId?records.find(row=>String(row['bindingId']||row['id'])===bindingId):records.length===1?records[0]:undefined) ?? {};
-        this.bindingRecord.set(record);
-        this.completedUnits.set(new Set(Object.keys(this.parseRecord(record['syjd']))));
-      },
-      error: () => undefined,
+    const studentId = this.appointmentStudentId(), bindingId = this.selectedBindingId;
+    if (!studentId || !bindingId) return;
+    this.unitsLoading.set(true);
+    this.unitRequest = this.api.get<unknown>('node_list', {pid:this.selectedCourse,bindingId,ifunit:1,userId:studentId,psize:1000}).pipe(finalize(() => this.unitsLoading.set(false))).subscribe({
+      next: ({result}) => { if (bindingId === this.selectedBindingId) this.units.set(this.findList(result)); },
+      error: (error: ApiError) => { if (bindingId === this.selectedBindingId) this.actionError.set('学习单元加载失败:' + error.message); },
     });
     });
   }
   }
 
 
@@ -406,15 +399,6 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     });
     });
   }
   }
 
 
-  private currentCourseOption(): SourceItem | null {
-    if (!this.selectedCourse) return null;
-    return {
-      kcid: this.selectedCourse,
-      kcId: this.selectedCourse,
-      Title: this.field('course') || `课程 ${this.selectedCourse}`,
-    };
-  }
-
   private appointmentSnapshot(): SourceItem {
   private appointmentSnapshot(): SourceItem {
     const state = history.state as { appointmentSnapshot?: unknown } | null;
     const state = history.state as { appointmentSnapshot?: unknown } | null;
     if (state?.appointmentSnapshot && typeof state.appointmentSnapshot === 'object' && !Array.isArray(state.appointmentSnapshot)) {
     if (state?.appointmentSnapshot && typeof state.appointmentSnapshot === 'object' && !Array.isArray(state.appointmentSnapshot)) {

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

@@ -1584,7 +1584,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
         // Restrict the read to this appointment, its student and the signed-in coach.
         // Restrict the read to this appointment, its student and the signed-in coach.
         return this.api.get<unknown>('content_list', {
         return this.api.get<unknown>('content_list', {
           modelId: 56, psize: 1,
           modelId: 56, psize: 1,
-          myfield2: `UserId=${studentId}|dsid=${orderId}|pl=${coachId}`,
+          myfield2: `UserId=${studentId}|dsid=${orderId}|pl=${coachId}${this.query('bindingId') ? '|bindingId=' + this.query('bindingId') : ''}`,
         });
         });
       }),
       }),
       switchMap(({ result }) => {
       switchMap(({ result }) => {
@@ -1610,6 +1610,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const owner = Number(user?.groupId) === 3 ? student : user;
     const owner = Number(user?.groupId) === 3 ? student : user;
     const xxqs = this.words().map((word) => ({ GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) }));
     const xxqs = this.words().map((word) => ({ GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) }));
     const addon = {
     const addon = {
+      bindingId: this.query('bindingId') || undefined,
       learned: xxqs.length,
       learned: xxqs.length,
       ygg: xxqs.filter((item) => item.check === 1).length,
       ygg: xxqs.filter((item) => item.check === 1).length,
       djq: xxqs.filter((item) => item.check === 2).length,
       djq: xxqs.filter((item) => item.check === 2).length,
@@ -1640,6 +1641,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const owner = Number(user?.groupId) === 3 ? student : user;
     const owner = Number(user?.groupId) === 3 ? student : user;
     const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 }));
     const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 }));
     const addon = {
     const addon = {
+      bindingId: this.query('bindingId') || undefined,
       con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`,
       con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`,
       szmdid: this.orderInfo()['szmd'],
       szmdid: this.orderInfo()['szmd'],
       dqrq: this.compactDate(),
       dqrq: this.compactDate(),
@@ -2218,7 +2220,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
 
   private draft():LearningDraft {
   private draft():LearningDraft {
     const learner=this.canStartPointReading?this.session.student():this.session.user();
     const learner=this.canStartPointReading?this.session.student():this.session.user();
-    return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${learner?.userId}.${this.query('orderId')||this.query('bindingId')||'practice'}`);
+    return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${learner?.userId}.${this.query('orderId') ? this.query('orderId') + '.' + (this.query('bindingId') || 'unbound') : this.query('bindingId') || 'practice'}`);
   }
   }
   private learningStorageKey(key:string):string{return this.draft().key(key);}
   private learningStorageKey(key:string):string{return this.draft().key(key);}
   private storageGet(key:string):unknown{return this.draft().get(key);}
   private storageGet(key:string):unknown{return this.draft().get(key);}

+ 40 - 0
scripts/cloud/lesson-courses.js

@@ -0,0 +1,40 @@
+// Learning content is chosen from the student's enrollments. Billing stays on the appointment.
+async function appLessonCourses(input,current) {
+ assertLessonTenant(current);
+ const order=await orderDetailRow(input.orderId||input.id,current,false);
+ if(!order)fail(404,'预约不存在');
+ if(number(current.get('legacyGroupId'))!==3||ownLegacyId(current)!==number(order.pl))fail(403,'仅预约老师可以选择学员课程');
+ if(order.cancelled)fail(409,'预约已取消');
+ const rows=await Psql.query('SELECT b.*,c."generalId",n."nodeName" AS "courseName" FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id JOIN "Node" n ON n.company=b.company AND n."nodeId"::text=b.kcid WHERE b.company=$1 AND b.yhid=$2 AND b."enrollmentStatus"=\'active\' AND c.status=99 AND COALESCE(n.zstatus,99)=99 ORDER BY b.id DESC,b."objectId"',[DEFAULT_COMPANY_ID,String(order.szyh)]);
+ return {items:rows.map(b=>({bindingId:b.objectId,GeneralID:b.generalId,kcid:b.kcid,Title:b.courseName,categoryKey:b.categoryKey,categoryName:b.categoryName,syjd:b.syjd,scheduled:b.objectId===order.bindingId})),studentId:number(order.szyh),scheduledBindingId:order.bindingId,source:'database'};
+}
+async function lessonStudyBinding(addon,current,uid,order,words=[]) {
+ assertLessonTenant(current);
+ if(order){
+  if(number(order.szyh)!==uid||order.cancelled)fail(403,'学习记录与预约学员不匹配');
+  if(ownLegacyId(current)!==uid&&(number(current.get('legacyGroupId'))!==3||ownLegacyId(current)!==number(order.pl)))fail(403,'仅预约师生可以保存本节课学习记录');
+  if(number(order.dszt)!==10)fail(409,'请在课程进行中保存学习记录');
+ }
+ const id=String(addon.bindingId||order?.bindingId||'');
+ if(!id)return null;
+ const binding=await Psql.oneOrNone('SELECT b."objectId",b.kcid FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id JOIN "Node" n ON n.company=b.company AND n."nodeId"::text=b.kcid WHERE b.company=$1 AND b.yhid=$2 AND b."objectId"=$3 AND b."enrollmentStatus"=\'active\' AND c.status=99 AND COALESCE(n.zstatus,99)=99',[DEFAULT_COMPANY_ID,String(uid),id]);
+ if(!binding)fail(409,'所选课程尚未开通或已停用,请刷新课程列表');
+ if(words.length){
+  const ids=[...new Set(words.map(w=>String(w.GeneralID)))];
+  const found=await Psql.one('WITH RECURSIVE nodes AS (SELECT "nodeId" FROM "Node" WHERE company=$1 AND "nodeId"::text=$2 AND COALESCE(zstatus,99)=99 UNION SELECT n."nodeId" FROM "Node" n JOIN nodes p ON n."parentId"=p."nodeId" WHERE n.company=$1 AND COALESCE(n.zstatus,99)=99) SELECT count(DISTINCT c."generalId")::int total FROM "CommonModel" c JOIN nodes n ON n."nodeId"=c."nodeId" WHERE c.company=$1 AND c."modelId"=52 AND c.status=99 AND c."generalId"::text=ANY($3::text[])',[DEFAULT_COMPANY_ID,String(binding.kcid),ids]);
+  if(number(found.total)!==ids.length)fail(409,'学习单词与所选课程不匹配,请重新进入课程');
+ }
+ return binding;
+}
+async function startLessonWithContent(current,appointmentId,bindingId,expected) {
+ // Selection and assignment are checked under the same lock as enrollment recycling and scheduling.
+ try{return await Psql.tx(async t=>{
+  await t.one('SELECT pg_advisory_xact_lock(hashtext($1))',['xs-credit:'+DEFAULT_COMPANY_ID]);
+  const a=await t.one('SELECT a.szyh,a."updatedAt"::text AS version FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."modelId"=54 AND c."itemId"=a.id WHERE c.company=$1 AND c."objectId"=$2',[DEFAULT_COMPANY_ID,appointmentId]);
+  if(expected){const v=await t.one('SELECT $1::timestamptz=$2::timestamptz same',[a.version,String(expected)]);if(!v.same)throw Error('预约已更新,请刷新详情后重试');}
+  const b=await t.one('SELECT b."objectId",b.kcid FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id JOIN "Node" n ON n.company=b.company AND n."nodeId"::text=b.kcid WHERE b.company=$1 AND b.yhid=$2 AND b."objectId"=$3 AND b."enrollmentStatus"=\'active\' AND c.status=99 AND COALESCE(n.zstatus,99)=99',[DEFAULT_COMPANY_ID,String(a.szyh),String(bindingId)]);
+  await t.one('SELECT xs_course_size($1,$2)',[DEFAULT_COMPANY_ID,String(b.kcid)]);
+  await t.one('SELECT xs_start_appointment_for_actor($1,$2,$3,$4)',[DEFAULT_COMPANY_ID,appointmentId,current.id,ownLegacyId(current)]);
+  return {bindingId:b.objectId,courseId:b.kcid};
+ });}catch(e){fail(409,String(e.message||e));}
+}

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

@@ -2,6 +2,8 @@ async function enrollmentOrderTransition(input,current,order,orderId,status){
  const row=await Psql.oneOrNone('SELECT "objectId","sourceKey" FROM "CommonModel" WHERE "company"=$1 AND "modelId"=54 AND "generalId"::text=$2',[DEFAULT_COMPANY_ID,String(orderId)]);if(!row)fail(404,'预约不存在');
  const row=await Psql.oneOrNone('SELECT "objectId","sourceKey" FROM "CommonModel" WHERE "company"=$1 AND "modelId"=54 AND "generalId"::text=$2',[DEFAULT_COMPANY_ID,String(orderId)]);if(!row)fail(404,'预约不存在');
  if(status!==10)fail(409,'请使用老师一次结课入口');
  if(status!==10)fail(409,'请使用老师一次结课入口');
  if(number(order.dszt)===0&&/^legacy[:\-]/.test(row.sourceKey||'')){const prepared=await prepareLegacyAppointment(row,current);if(prepared.blockedReason)fail(409,prepared.blockedReason);}
  if(number(order.dszt)===0&&/^legacy[:\-]/.test(row.sourceKey||'')){const prepared=await prepareLegacyAppointment(row,current);if(prepared.blockedReason)fail(409,prepared.blockedReason);}
- try{await Psql.one('SELECT xs_start_appointment_for_actor($1,$2,$3,$4) AS result',[DEFAULT_COMPANY_ID,row.objectId,current.id,ownLegacyId(current)]);}catch(error){fail(409,String(error.message));}
- return{GeneralID:orderId,status,previousStatus:number(order.dszt)};
+ let learning;
+ if(input.learningBindingId)learning=await startLessonWithContent(current,row.objectId,input.learningBindingId,input.expectedUpdatedAt);
+ else try{await Psql.one('SELECT xs_start_appointment_for_actor($1,$2,$3,$4) AS result',[DEFAULT_COMPANY_ID,row.objectId,current.id,ownLegacyId(current)]);}catch(error){fail(409,String(error.message));}
+ return{GeneralID:orderId,status,previousStatus:number(order.dszt),learning};
 }
 }

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


+ 17 - 0
scripts/migrate-lesson-course-selection.mjs

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

+ 19 - 11
scripts/sql/lesson-workflow.sql

@@ -22,7 +22,7 @@ CREATE OR REPLACE FUNCTION xs_lesson_complete(co text,gid text,actor text,actor_
 RETURNS jsonb LANGUAGE plpgsql AS $$
 RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "LessonCompletionReport";
 DECLARE a "CourseAppointment"; c "CommonModel"; l "LessonRecord"; report "LessonCompletionReport";
  previous "LessonWorkflowCommand"; result jsonb; lesson_count int; cost jsonb; ledger_table text; evidence int;
  previous "LessonWorkflowCommand"; result jsonb; lesson_count int; cost jsonb; ledger_table text; evidence int;
- study record; words jsonb='[]'; review_date text; review_key text; review_id numeric; review_object text;
+ study record; study_words jsonb; study_course text; found_study boolean=false; words jsonb='[]'; review_date text; review_key text; review_id numeric; review_object text;
  total_reviews int; completed_reviews int; report_id text; was_charged boolean=false;
  total_reviews int; completed_reviews int; report_id text; was_charged boolean=false;
 BEGIN
 BEGIN
  IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
  IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
@@ -76,21 +76,23 @@ BEGIN
   was_charged=COALESCE((result->>'periodDeducted')::boolean,false);
   was_charged=COALESCE((result->>'periodDeducted')::boolean,false);
   SELECT * INTO l FROM "LessonRecord" WHERE company=co AND "objectId"=result->>'lessonId';
   SELECT * INTO l FROM "LessonRecord" WHERE company=co AND "objectId"=result->>'lessonId';
  END IF;
  END IF;
- SELECT d.*,sc."generalId" AS study_general_id INTO study FROM "DailyStudyRecord" d
- JOIN "CommonModel" sc ON sc.company=d.company AND sc."modelId"=56 AND sc."itemId"=d.id
- WHERE d.company=co AND d."userId"::text=a.szyh::text AND d.dsid::text=gid ORDER BY d."updatedAt" DESC LIMIT 1;
- IF FOUND THEN
-  words=COALESCE(NULLIF(study.xxqs,'')::jsonb,'[]');
-  IF jsonb_typeof(words)<>'array' THEN RAISE EXCEPTION '课堂学习记录格式异常,请核对后重试';END IF;
-  IF jsonb_array_length(words)>0 THEN
+ FOR study IN SELECT d.*,sc."generalId" AS study_general_id,b.kcid AS study_course_id FROM "DailyStudyRecord" d
+ JOIN "CommonModel" sc ON sc.company=d.company AND sc."modelId"=56 AND sc."itemId"=d.id AND sc.status=99
+ LEFT JOIN "CourseBinding" b ON b.company=d.company AND b."objectId"=to_jsonb(d)->>'bindingId' AND b.yhid::text=a.szyh::text
+ WHERE d.company=co AND d."userId"::text=a.szyh::text AND d.dsid::text=gid ORDER BY d."updatedAt" DESC,d.id DESC LOOP
+  IF NOT found_study THEN words='[]';found_study=true;END IF;
+  study_words=COALESCE(NULLIF(study.xxqs,'')::jsonb,'[]');
+  IF jsonb_typeof(study_words)<>'array' THEN RAISE EXCEPTION '课堂学习记录格式异常,请核对后重试';END IF;
+  words=words||study_words;study_course=COALESCE(study.study_course_id,a.kcid);
+  IF jsonb_array_length(study_words)>0 THEN
    FOR review_date IN SELECT DISTINCT value FROM unnest(string_to_array(COALESCE(study.fxrl,''),',')) value WHERE value ~ '^\d{8}$' LIMIT 15 LOOP
    FOR review_date IN SELECT DISTINCT value FROM unnest(string_to_array(COALESCE(study.fxrl,''),',')) value WHERE value ~ '^\d{8}$' LIMIT 15 LOOP
-    review_key='cloud:e_order_update:'||gid||':review:'||review_date;
+    review_key='cloud:e_order_update:'||gid||CASE WHEN NULLIF(to_jsonb(study)->>'bindingId','') IS NOT NULL AND (to_jsonb(study)->>'bindingId') IS DISTINCT FROM a."bindingId" THEN ':study:'||study.study_general_id ELSE '' END||':review:'||review_date;
     SELECT id,"objectId" INTO review_id,review_object FROM "MemoryPracticeRecord" WHERE company=co AND "sourceKey"=review_key LIMIT 1;
     SELECT id,"objectId" INTO review_id,review_object FROM "MemoryPracticeRecord" WHERE company=co AND "sourceKey"=review_key LIMIT 1;
     IF review_object IS NULL THEN
     IF review_object IS NULL THEN
      review_id=nextval('xiaoshu_business_id');review_object=xs_object_id();
      review_id=nextval('xiaoshu_business_id');review_object=xs_object_id();
      INSERT INTO "MemoryPracticeRecord" SELECT * FROM jsonb_populate_record(NULL::"MemoryPracticeRecord",jsonb_build_object(
      INSERT INTO "MemoryPracticeRecord" SELECT * FROM jsonb_populate_record(NULL::"MemoryPracticeRecord",jsonb_build_object(
       'objectId',review_object,'company',co,'id',review_id,'createdAt',now(),'updatedAt',now(),'sourceKey',review_key,
       'objectId',review_object,'company',co,'id',review_id,'createdAt',now(),'updatedAt',now(),'sourceKey',review_key,
-      'yhid',a.szyh,'plid',COALESCE(NULLIF(a.fxpl,''),a.pl),'orderId',gid,'xxjlid',study.study_general_id,'kcid',a.kcid,
+      'yhid',a.szyh,'plid',COALESCE(NULLIF(a.fxpl,''),a.pl),'orderId',gid,'xxjlid',study.study_general_id,'kcid',study_course,
       'fxzt','0','kywrq',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD'),'kywsj',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD')||' '||COALESCE(a.sdsd,'00:00')));
       'fxzt','0','kywrq',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD'),'kywsj',to_char(to_date(review_date,'YYYYMMDD'),'YYYY-MM-DD')||' '||COALESCE(a.sdsd,'00:00')));
     END IF;
     END IF;
     IF NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE company=co AND "sourceKey"=review_key) THEN
     IF NOT EXISTS(SELECT 1 FROM "CommonModel" WHERE company=co AND "sourceKey"=review_key) THEN
@@ -98,7 +100,13 @@ BEGIN
     END IF;
     END IF;
    END LOOP;
    END LOOP;
   END IF;
   END IF;
- END IF;
+ END LOOP;
+ -- Combine all selected books for the lesson report without counting a word twice.
+ SELECT COALESCE(jsonb_agg(value ORDER BY ord),'[]'::jsonb) INTO words FROM (
+  SELECT DISTINCT ON (COALESCE(value->>'GeneralID',value::text)) value,ord
+  FROM jsonb_array_elements(words) WITH ORDINALITY w(value,ord)
+  ORDER BY COALESCE(value->>'GeneralID',value::text),ord
+ ) unique_words;
  UPDATE "LessonRecord" SET kzsj=words::text,pldp=COALESCE(NULLIF(feedback->>'coachComment',''),NULLIF(feedback->>'notes',''),pldp),plpjsj=now()::text,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
  UPDATE "LessonRecord" SET kzsj=words::text,pldp=COALESCE(NULLIF(feedback->>'coachComment',''),NULLIF(feedback->>'notes',''),pldp),plpjsj=now()::text,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
  UPDATE "CourseAppointment" SET dszt='30',jssj=COALESCE(NULLIF(jssj,''),now()::text),"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
  UPDATE "CourseAppointment" SET dszt='30',jssj=COALESCE(NULLIF(jssj,''),now()::text),"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
  UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND ("objectId"=c."objectId" OR ("modelId"=59 AND "itemId"=l.id));
  UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND ("objectId"=c."objectId" OR ("modelId"=59 AND "itemId"=l.id));

+ 40 - 0
scripts/tests/lesson-course-selection.test.mjs

@@ -0,0 +1,40 @@
+import{test,before,after}from'node:test';import assert from'node:assert/strict';import{readFileSync}from'node:fs';import vm from'node:vm';import{PGlite}from'@electric-sql/pglite';
+const db=new PGlite(),read=p=>readFileSync(new URL(p,import.meta.url),'utf8'),q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0],source=read('../deploy-admin-functions.mjs'),c=vm.createContext({Psql:{one,oneOrNone:one,query:q,tx:fn=>db.transaction(t=>fn({one:async(s,p=[])=>(await t.query(s,p)).rows[0]}))}}),teacher={id:'teacher',get:k=>({company:{id:'co'},legacyUserId:30,legacyGroupId:3}[k])};
+let version;before(async()=>{
+ await db.exec(`CREATE TABLE "_User"("objectId" text primary key,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"legacyUserData" jsonb,"isDisabled" boolean);
+ CREATE TABLE "CommonModel"("objectId" text primary key,company text,"modelId" numeric,"itemId" numeric,"generalId" numeric,status numeric,"nodeId" numeric,title text,subtitle text,"teachingManaged" boolean,"updatedAt" timestamptz);
+ CREATE TABLE "CourseAppointment"("objectId" text primary key,company text,id numeric,szyh text,pl text,kcid text,yykcid text,dszt text,bxrq text,yysj text,kssj text,"bindingId" text,"teachingManaged" boolean,"updatedAt" timestamptz,"creditHoldStatus" text,"creditCostsSnapshot" jsonb,"teacherPaySnapshot" numeric);
+ CREATE TABLE "CourseBinding"("objectId" text primary key,company text,id numeric,yhid text,kcid text,"categoryKey" text,"categoryName" text,"enrollmentStatus" text,syjd text);
+ CREATE TABLE "Node"(company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,zstatus numeric);
+ CREATE TABLE "CourseCreditReservation"(company text,"studentId" numeric,"appointmentId" text,state text,"creditCosts" jsonb);
+ INSERT INTO "_User" VALUES('teacher','co',30,3,'{}',false),('student','co',20,1,'{"Purse":10}',false),('other-teacher','co',31,3,'{}',false);
+ INSERT INTO "Node" VALUES('co',100,8,'原词表',99),('co',101,100,'Unit 1',99),('co',102,8,'新词表',99),('co',103,102,'Unit 2',99),('co',104,8,'停用',-2);
+ INSERT INTO "CommonModel"("objectId",company,"modelId","generalId","nodeId",status) VALUES('w1','co',52,1001,101,99),('w2','co',52,1002,103,99);
+ INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status) SELECT 'cb'||i,'co',58,i,5000+i,99 FROM generate_series(1,6)i;
+ INSERT INTO "CourseBinding" VALUES('b1','co',1,'20','100','word','单词课','active','{}'),('b2','co',2,'20','102','trial','体验课','active','{}'),('pending','co',3,'20','100','word','单词课','pending','{}'),('recycled','co',4,'20','100','word','单词课','recycled','{}'),('foreign','co',5,'21','100','word','单词课','active','{}'),('disabled','co',6,'20','104','word','单词课','active','{}');
+ INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status) VALUES('appointment','co',54,1,901,99);
+ INSERT INTO "CourseAppointment" VALUES('addon','co',1,'20','30','100','100','0',to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD'),'','', 'b1',false,now(),'ready','[{"account":"Purse","amount":1}]',37);
+ INSERT INTO "CourseCreditReservation" VALUES('co',20,'appointment','reserved','[{"account":"Purse","amount":1}]');`);
+ const enrollment=read('../sql/member-enrollment.sql');for(const name of ['xs_appointment_day','xs_appointment_valid_at','xs_reserved','xs_course_size','xs_validate_appointment','xs_start_appointment'])await db.exec(enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0]);
+ await db.exec(read('../sql/lesson-workflow.sql').match(/CREATE OR REPLACE FUNCTION xs_start_appointment_for_actor\([\s\S]*?\$\$;/)[0]);
+ const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");vm.runInContext(app+read('../cloud/lesson-workflow.js')+read('../cloud/lesson-courses.js'),c);version=(await one('SELECT "updatedAt"::text v FROM "CourseAppointment"')).v;
+});after(()=>db.close());
+test('教师按预约所属学员获取所有有效开课,包括不同课程分类,排除待确认、回收、停用和他人课程',async()=>{const r=await c.appLessonCourses({orderId:901,studentId:21},teacher);assert.deepEqual(Array.from(r.items,x=>x.bindingId).sort(),['b1','b2']);assert.equal(r.scheduledBindingId,'b1');for(const u of [{id:'other-teacher',get:k=>({company:{id:'co'},legacyUserId:31,legacyGroupId:3}[k])},{id:'student',get:k=>({company:{id:'co'},legacyUserId:20,legacyGroupId:1}[k])}])await assert.rejects(c.appLessonCourses({orderId:901},u),/仅预约老师/);});
+test('开始时验证实际选择的课程但保留排课课程、工资和扣减快照;旧版本和停用课程拒绝',async()=>{await assert.rejects(c.startLessonWithContent(teacher,'appointment','b2','2000-01-01'),/已更新/);await assert.rejects(c.startLessonWithContent(teacher,'appointment','pending',version));const before=await one('SELECT * FROM "CourseAppointment"');assert.equal((await c.startLessonWithContent(teacher,'appointment','b2',version)).bindingId,'b2');const after=await one('SELECT * FROM "CourseAppointment"');assert.equal(after.dszt,'10');for(const k of ['kcid','yykcid','bindingId','teacherPaySnapshot','creditCostsSnapshot'])assert.deepEqual(after[k],before[k]);});
+test('学习记录接受本学员所选开课而非强制排课开课;拒绝他人开课和非所选词库的单词',async()=>{const order={szyh:20,pl:30,dszt:10,bindingId:'b1'};assert.equal((await c.lessonStudyBinding({bindingId:'b2'},teacher,20,order,[{GeneralID:1002}])).objectId,'b2');await assert.rejects(c.lessonStudyBinding({bindingId:'foreign'},teacher,20,order),/未开通/);await assert.rejects(c.lessonStudyBinding({bindingId:'b2'},teacher,20,order,[{GeneralID:1001}]),/不匹配/);await assert.rejects(c.lessonStudyBinding({bindingId:'b2'},teacher,20,{...order,dszt:30}),/进行中/);});
+
+test('实际学习写入事务按所选开课隔离记录,重试和更新保留业务ID且不覆盖另一词库',async()=>{
+ await db.exec(`CREATE TABLE "DailyStudyRecord"("objectId" text primary key,company text,id numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" numeric,dsid text,pl text,"bindingId" text,xxqs text,learned numeric,ygg numeric,djq numeric,con text,dqrq text,szmdid text,fxrl text);
+ ALTER TABLE "CommonModel" ADD "createdAt" timestamptz,ADD "sourceKey" text,ADD "tableName" text,ADD inputer text,ADD hits numeric;`);
+ await db.exec(read('../sql/mobile-teaching.sql'));
+ class Obj{constructor(name,data={}){this.className=name;this.data=data;this.id=data.objectId;}set(k,v){this.data[k]=v;}get(k){return this.data[k];}toJSON(){return{...this.data,company:this.data.company?.id?{__type:'Pointer',className:'Company',objectId:this.data.company.id}:this.data.company};}}
+ c.Parse={Object:Obj,Query:class{constructor(name){this.name=name;}async get(id){return new Obj(this.name,await one('SELECT * FROM "'+this.name+'" WHERE "objectId"=$1',[id]));}}};
+ vm.runInContext(read('../cloud/mobile-teaching.js')+';authorizeRequestedUser=async(current,uid)=>Number(uid);',c);
+ const run=(bind,word,key,check=0)=>c.upsertStudyRecord({uid:20,orderId:901,requestKey:key,addon:JSON.stringify({bindingId:bind,xxqs:[{GeneralID:word,Title:'word',check}]})},teacher);
+ const first=await run('b1',1001,'study-first-0001');await run('b2',1002,'study-second-0001');
+ const updated=await run('b1',1001,'study-first-0002',1);assert.equal(first.GeneralID,updated.GeneralID);
+ await run('b1',1001,'study-first-0002',1);
+ const rows=await q('SELECT "bindingId",ygg,id FROM "DailyStudyRecord" ORDER BY "bindingId"');assert.equal(rows.length,2);assert.equal(Number(rows[0].ygg),1);assert.equal(Number(rows[1].ygg),0);
+ assert.equal((await one('SELECT count(*)::int n FROM "CommonModel" WHERE "modelId"=56')).n,2);
+ assert.equal((await one('SELECT "teacherPaySnapshot" FROM "CourseAppointment"')).teacherPaySnapshot,'37');
+});

+ 14 - 0
scripts/tests/lesson-workflow.test.mjs

@@ -163,3 +163,17 @@ test('旧客户端状态写入和旧分步完课明确拒绝,不触发数据
  for(const status of [11,20,30]) await assert.rejects(c.updateOrderStatus({status,orderId:1,content:'{}'},{}),/刷新课程详情/);
  for(const status of [11,20,30]) await assert.rejects(c.updateOrderStatus({status,orderId:1,content:'{}'},{}),/刷新课程详情/);
  await assert.rejects(c.completeOrderWithFeedback({},{}),/一次结课/);
  await assert.rejects(c.completeOrderWithFeedback({},{}),/一次结课/);
 });
 });
+
+test('同一预约多个已开通词库汇总学习词汇,按实际词库生成复习且仍只扣预约课时',async()=>{
+ const a=await appointment(),before=await balance();
+ await db.query(`INSERT INTO "CourseBinding" SELECT * FROM jsonb_populate_record(NULL::"CourseBinding",$1::jsonb)`,[JSON.stringify({objectId:'second-book',company:'co',id:8888,yhid:'20',kcid:'102',categoryKey:'trial',enrollmentStatus:'active'})]);
+ for(const [id,generalId,bind,words]of [[7777,7778,binding,[{GeneralID:1,Title:'apple'},{GeneralID:2,Title:'book'}]],[8881,8882,'second-book',[{GeneralID:2,Title:'book'},{GeneralID:3,Title:'cat'}]]]){
+  await db.query('INSERT INTO "DailyStudyRecord" ("objectId",company,id,"userId",dsid,"bindingId",xxqs,fxrl,"updatedAt") VALUES($1,\'co\',$2,20,$3,$4,$5,\'20300101\',now())',['multi-'+id,id,a.gid,bind,JSON.stringify(words)]);
+  await db.query('INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES($1,\'co\',56,$2,$3,99)',['multi-common-'+id,id,generalId]);
+ }
+ await complete(a.gid,a.updatedAt,'multi-book-complete');assert.equal(await balance(),before-1);
+ const words=JSON.parse((await one('SELECT kzsj FROM "LessonRecord" WHERE yyds=$1',[a.gid])).kzsj);assert.equal(words.length,3);
+ const reviews=(await db.query('SELECT kcid,xxjlid FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1 ORDER BY kcid',[a.gid])).rows;
+ assert.equal(reviews.length,2);assert.deepEqual(reviews.map(r=>Number(r.kcid)),[100,102]);
+ await complete(a.gid,a.updatedAt,'multi-book-complete');assert.equal(await balance(),before-1);
+});

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