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

fix(schedule): allow writing trial and self-study without prior enrollment

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

+ 9 - 6
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -133,7 +133,7 @@
     } @else {
     <p class="notice">课程在上课当天 00:00 至 24:00 有效,可当天提前或延后上课;预约时间和时长用于安排课程与检查冲突。</p>
     <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="setMode('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="setMode('time')">时间优先</button></div>
-    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选学员与课型;只有一门已开教材时会自动带出':'先选学员、课型时长和教材,再选上课时间;无冲突的陪练排在前面'}}</span></p>
+    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选学员与课型;只有一门已开教材时会自动带出':'先选学员、课型时长和上课时间;单词类课程需选择教材,无冲突的陪练排在前面'}}</span></p>
     <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
       <label class="search-field member-step">
         <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
@@ -156,29 +156,32 @@
           @for(standard of courseStandards;track standard.key){<option [value]="standard.key">{{standard.label}} · {{standard.duration}}分钟{{studentId()?' · '+standardAvailability(standard.categoryKey):''}}</option>}
         </select>
       </label>
+      @if(!textbookFree()){
       <label class="search-field course-step">
-        <span>课程教材 <small>当前课型已开 {{categoryCourses().length}} 门,可搜索名称或 ID</small></span>
+        <span>课程教材 <small>{{enrollmentFreeTextbook()?'可直接选择教材,无需提前开课':'当前课型已开 '+categoryCourses().length+' 门'}},可搜索名称或 ID</small></span>
         <span class="search-control" [class.selected]="courseId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
-          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="!studentId()?'请先选择学员':categoryCourses().length?'选择 '+standardLabel()+' 的教材':'该课型尚未开通教材'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" (click)="openSearch('course')" #courseSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
+          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="!studentId()?'请先选择学员':categoryCourses().length?'选择 '+standardLabel()+' 的教材':enrollmentFreeTextbook()?'暂无可用教材':'该课型尚未开通教材'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" (click)="openSearch('course')" #courseSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
           @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
         </span>
         @if(activeSearch()==='course'){
           <span class="search-results" [xsAnchoredMenu]="courseSearchInput" (xsDismiss)="activeSearch.set(null)" id="course-search-results" role="listbox">
             @for(course of filteredCourses();track course.bindingId||course.courseId){
               <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId && bindingId()===(course.bindingId||'')" (click)="chooseCourse(course)"><span><strong>{{course.categoryName}} · {{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?((course.stageName ? course.stageName+' · ' : '')+course.categoryName):'待处理课程分类'}}</small></span></button>
-            } @empty {<span class="search-empty">{{studentId()?'该课型没有匹配的已开教材,可清空搜索或在下方开课':'请先选择学员,再选择其已绑定课程'}}</span>}
+            } @empty {<span class="search-empty">{{studentId()?(enrollmentFreeTextbook()?'没有匹配的可用教材,请调整搜索':'该课型没有匹配的已开教材,可清空搜索或在下方开课'):'请先选择学员,再选择其已绑定课程'}}</span>}
           </span>
         }
       </label>
       @if(studentId()){
         <section class="wide course-guidance" aria-live="polite">
-          @if(!categoryCourses().length){<p>该学员尚未开通「{{standardLabel()}}」教材。先开课,再排课;开课本身不扣课时。</p>}
+          @if(enrollmentFreeTextbook()){<p>直接选择本次使用的教材即可排课,无需提前开课。排课仍按当前课型校验并预留可用课时。</p>}
+          @else if(!categoryCourses().length){<p>该学员尚未开通「{{standardLabel()}}」教材。先开课,再排课;开课本身不扣课时。</p>}
           @else if(selectedCourse()&&!courseSelectionValid()){<p class="exception">所选教材没有当前课型、时长的有效规则,请调整标准或在“排课与工资标准”中配置。</p>}
           @else{<p>当前课型:{{standardLabel()}} · {{durationMinutes()}}分钟。教材用于确定本次学习内容。</p>}
-          <button type="button" (click)="openEnrollment()">{{categoryCourses().length?'开通其他教材':'为该学员开课'}}</button>
+          @if(!enrollmentFreeTextbook()){<button type="button" (click)="openEnrollment()">{{categoryCourses().length?'开通其他教材':'为该学员开课'}}</button>}
         </section>
       }
+      }@else{<section class="wide course-guidance" aria-live="polite"><p>{{standardLabel()}}无需开课,也无需选择单词教材。排课将按所选时长校验并预留可用课时,课时不足时请先充值。</p></section>}
       <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
       <app-operations-date-filter class="date-step" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
       <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>

+ 22 - 7
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -357,7 +357,7 @@ describe('排课完整课型选择与教材联动',()=>{
     fixture.detectChanges();
     const options=Array.from(fixture.nativeElement.querySelectorAll('select[aria-label="课型与时长"] option')) as HTMLOptionElement[];
     expect(options.length).toBe(10);
-    expect(options.map(row=>row.textContent?.split(' · 待开课')[0].split(' · 已开')[0])).toEqual([
+    expect(options.map(row=>row.textContent?.split(' · 待开课')[0].split(' · 已开')[0].split(' · 无需开课')[0])).toEqual([
       '单词课 · 30分钟','单词课 · 60分钟','体验课 · 60分钟','小学语法写作 · 30分钟','初中语法写作 · 30分钟','高中语法写作 · 30分钟','小学语法写作 · 60分钟','初中语法写作 · 60分钟','高中语法写作 · 60分钟','单词自学课 · 60分钟',
     ]);
     expect(options.find(row=>row.value==='word_self_study:60')?.disabled).toBeFalse();
@@ -367,12 +367,27 @@ describe('排课完整课型选择与教材联动',()=>{
     page.selectStandard('trial:60');expect(page.bindingId()).toBe('trial');expect(page.durationMinutes()).toBe(60);expect(page.preview()).toBeNull();
     await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({bindingId:'trial',courseId:100,durationMinutes:60}));
   });
-  it('未开课的分类清空旧教材、禁止预检,并提供当前分类的开课入口',async()=>{
-    page.selectStandard('high_writing:60');fixture.detectChanges();
-    expect(page.courseId()).toBe(0);expect(page.bindingId()).toBe('');expect(page.durationMinutes()).toBe(60);
-    expect(page.courseSelectionValid()).toBeFalse();expect(fixture.nativeElement.textContent).toContain('为该学员开课');
-    await page.preflight();expect(request).not.toHaveBeenCalled();
-    page.openEnrollment();expect(page.enrollmentOpen()).toBeTrue();expect(page.categoryKey()).toBe('high_writing');
+  it('体验和自学展示全量教材,无需已有开课记录且区分同一教材的课型',async()=>{
+    page.data.update(data=>({...data!,courses:['trial','word_self_study'].map(categoryKey=>({...course('',categoryKey),memberIds:[],availableToAll:true}))}));
+    for(const category of ['trial','word_self_study']){
+      page.selectStandard(`${category}:60`);fixture.detectChanges();
+      expect(page.courseSelectionValid()).toBeTrue();expect(page.selectedCourse()?.categoryKey).toBe(category);
+      expect(fixture.nativeElement.querySelector('.course-step')).not.toBeNull();expect(fixture.nativeElement.textContent).toContain('无需提前开课');
+      expect(fixture.nativeElement.textContent).not.toContain('为该学员开课');
+      await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({categoryKey:category,courseId:100,bindingId:'',durationMinutes:60}));
+    }
+  });
+  it('六种语法写作直接排课,隐藏教材和开课入口',async()=>{
+    for(const category of ['primary_writing','middle_writing','high_writing'])for(const duration of [30,60]){
+      page.selectStandard(`${category}:${duration}`);fixture.detectChanges();
+      expect(page.courseId()).toBe(0);expect(page.bindingId()).toBe('');expect(page.courseSelectionValid()).toBeTrue();
+      expect(fixture.nativeElement.querySelector('.course-step')).toBeNull();
+      expect(fixture.nativeElement.textContent).toContain('无需选择单词教材');
+      expect(page.standardAvailability(category)).toBe('无需开课');
+      await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({categoryKey:category,courseId:0,bindingId:'',durationMinutes:duration}));
+    }
+    page.data.update(data=>({...data!,courses:[]}));page.selectStandard('word:30');await page.preflight();
+    expect(page.courseSelectionValid()).toBeFalse();expect(page.error()).toContain('选择会员和课程');
   });
   it('选择60分钟后切换教材不重置为30分钟,并展示超过12门的全部教材',()=>{
     page.data.update(data=>({...data!,courses:Array.from({length:15},(_,i)=>course('word-'+i,'word',100+i))}));

+ 11 - 7
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -145,14 +145,16 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   readonly studentCourses=computed(()=>scheduleCoursesForStudent(this.data()?.courses||[],this.studentId()));
   readonly courseStandards=SCHEDULE_COURSE_STANDARDS;
   readonly categoryKey=signal('word');
+  readonly enrollmentFreeTextbook=computed(()=>['trial','word_self_study'].includes(this.categoryKey()));
+  readonly textbookFree=computed(()=>['primary_writing','middle_writing','high_writing'].includes(this.categoryKey()));
   readonly standardKey=computed(()=>`${this.categoryKey()}:${this.durationMinutes()}`);
   readonly standardLabel=computed(()=>this.courseStandards.find(row=>row.key===this.standardKey())?.label||'当前课型');
   readonly categoryCourses=computed(()=>this.studentCourses().filter(course=>course.categoryKey===this.categoryKey()));
   readonly filteredCourses=computed(()=>this.categoryCourses().filter(course=>matchesScheduleSearch(this.courseId()?'':this.courseSearch(),[course.courseName,course.categoryName,course.courseId])));
   readonly enrollmentOpen=signal(false);
   @ViewChild(MemberEnrollmentComponent) enrollment?:MemberEnrollmentComponent;
-  readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
-  readonly courseSelectionValid=computed(()=>{const course=this.selectedCourse();return Boolean(course?.mappingConfirmed&&course.categoryKey===this.categoryKey()&&course.supportedDurations?.includes(this.durationMinutes()));});
+  readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&course.categoryKey===this.categoryKey()&&(this.enrollmentFreeTextbook()||!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
+  readonly courseSelectionValid=computed(()=>{if(this.textbookFree())return this.studentId()>0&&[30,60].includes(this.durationMinutes());const course=this.selectedCourse();return Boolean(course?.mappingConfirmed&&course.categoryKey===this.categoryKey()&&course.supportedDurations?.includes(this.durationMinutes()));});
   readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).sort((a,b)=>this.mode()==='time'?(Number(this.coachBusyAtSelectedTime(a.userId))-Number(this.coachBusyAtSelectedTime(b.userId)))||(Number(b.availabilityConfigured)-Number(a.availabilityConfigured))||a.displayName.localeCompare(b.displayName,'zh-CN'):a.displayName.localeCompare(b.displayName,'zh-CN')).slice(0,12));
   readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
   private preflightRequest=0; private previewFingerprint=''; private initialRange=true; private saveKey=''; private saveFingerprint=''; private stopLiveRefresh?:()=>void; private loadRequest=0;
@@ -242,7 +244,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.preview.set(null);this.openSearch('member');}
   updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.bindingId.set('');this.preview.set(null);this.openSearch('course');}
   updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.preview.set(null);this.openSearch('coach');}
-  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');const courses=scheduleCoursesForStudent(this.data()?.courses||[],member.userId);if(courses.length===1){this.chooseCourse(courses[0]);if(courses[0].mappingConfirmed)this.message.set(`已自动选择该学员唯一的已开课程:${courses[0].courseName}`);}else{this.selectStandard(this.standardKey());}this.preview.set(null);}
+  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');const courses=scheduleCoursesForStudent(this.data()?.courses||[],member.userId);if(courses.length===1&&!this.textbookFree()&&!this.enrollmentFreeTextbook()){this.chooseCourse(courses[0]);if(courses[0].mappingConfirmed)this.message.set(`已自动选择该学员唯一的已开课程:${courses[0].courseName}`);}else{this.selectStandard(this.standardKey());}this.preview.set(null);}
   chooseCourse(course:ScheduleCalendarData['courses'][number]):void{
     if(!scheduleCourseAvailable(course,this.studentId()))return;
     const sameCategory=course.categoryKey===this.categoryKey();
@@ -252,6 +254,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   }
   standardAvailability(categoryKey:string):string{
     if(!this.studentId())return '';
+    if(['primary_writing','middle_writing','high_writing','trial','word_self_study'].includes(categoryKey))return '无需开课';
     const count=this.studentCourses().filter(course=>course.categoryKey===categoryKey).length;
     return count?`已开 ${count} 门教材`:'待开课';
   }
@@ -259,6 +262,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
     const standard=this.courseStandards.find(row=>row.key===key);if(!standard)return;
     const previous=this.selectedCourse();
     this.categoryKey.set(standard.categoryKey);this.durationMinutes.set(standard.duration);this.error.set('');this.preview.set(null);
+    if(this.textbookFree()){this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.activeSearch.set(null);return;}
     const courses=this.categoryCourses();
     const course=courses.find(row=>previous&&row.bindingId===previous.bindingId&&row.courseId===previous.courseId)||(courses.length===1?courses[0]:null);
     if(course)this.chooseCourse(course);
@@ -282,13 +286,13 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   coachBusyAtSelectedTime(coachId:number):boolean{return scheduleCoachBusy(this.data()?.appointments||[],coachId,`${this.date()}T${this.startTime()}:00+08:00`,this.durationMinutes(),this.editing()?.targetId||'');}
   coachCandidateHint(coach:ScheduleCalendarData['coaches'][number]):string{return this.coachBusyAtSelectedTime(coach.userId)?'该时段已有排课':coach.availabilityConfigured?'该时段可继续预检':'未设置可用时间';}
   memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
-  payload():Record<string,unknown>{return{bindingId:this.bindingId(),studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),durationMinutes:this.durationMinutes(),deliveryMethod:this.deliveryMethod(),deliveryMode:this.deliveryMethod()==='线下'?'offline':'online',date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
+  payload():Record<string,unknown>{return{categoryKey:this.categoryKey(),bindingId:this.textbookFree()?'':this.bindingId(),studentId:this.studentId(),courseId:this.textbookFree()?0:this.courseId(),coachId:this.coachId(),classType:this.classType(),durationMinutes:this.durationMinutes(),deliveryMethod:this.deliveryMethod(),deliveryMode:this.deliveryMethod()==='线下'?'offline':'online',date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
   async preflight():Promise<void>{
     if(this.checking()||this.saving()||this.enrollmentOpen())return;
     this.error.set('');this.preview.set(null);this.previewFingerprint='';
-    if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}
+    if(!this.selectedMemberObjectId()||(!this.textbookFree()&&!this.courseId())){this.error.set('请先选择会员和课程');return;}
     if(!this.studentId()){this.error.set('该会员缺少排课编号,暂时不能排课');return;}
-    if(!this.courseSelectionValid()){this.error.set('请选择该课型下的有效已开教材,并确认所选时长有可用规则');return;}
+    if(!this.courseSelectionValid()){this.error.set('请选择该课型下的有效教材,并确认所选时长有可用规则');return;}
     const request=++this.preflightRequest,targetId=this.editing()?.targetId||'',payload=this.payload(),fingerprint=JSON.stringify([targetId,payload]);
     this.checking.set(true);
     try{
@@ -298,6 +302,6 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
     }catch(error){if(request===this.preflightRequest&&fingerprint===JSON.stringify([this.editing()?.targetId||'',this.payload()]))this.error.set(error instanceof Error?error.message:'排课预检失败');}
     finally{if(request===this.preflightRequest)this.checking.set(false);}
   }
-  async save():Promise<void>{if(this.saving())return;if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const fingerprint=JSON.stringify([editing?.targetId||'',this.payload(),skipConflicts,this.reason()]);if(this.saveFingerprint!==fingerprint){this.saveFingerprint=fingerprint;this.saveKey=crypto.randomUUID();}const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'',this.saveKey);this.saveFingerprint='';this.saveKey='';this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
+  async save():Promise<void>{if(this.saving())return;if(!this.selectedMemberObjectId()||(!this.textbookFree()&&!this.courseId())||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const fingerprint=JSON.stringify([editing?.targetId||'',this.payload(),skipConflicts,this.reason()]);if(this.saveFingerprint!==fingerprint){this.saveFingerprint=fingerprint;this.saveKey=crypto.randomUUID();}const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'',this.saveKey);this.saveFingerprint='';this.saveKey='';this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
   async action(item:ScheduleAppointment,operation:string):Promise<void>{if(item.readOnly){this.message.set('实时业务数据暂不支持从新后台修改,请在原业务端完成操作。');return;}const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
 }

+ 3 - 2
scripts/cloud/lesson-workflow.js

@@ -53,7 +53,7 @@ async function appLessonReview(input, current) {
 async function lessonOrderCapabilities(order, current) {
   assertLessonTenant(current);
   const id = String(order.GeneralID), uid = ownLegacyId(current), status = number(order.dszt);
-  const stateSql='SELECT c."objectId",a."sourceKey",a.kcid,a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."creditHoldReason",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" 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."generalId"::text=$2';
+  const stateSql='SELECT c."objectId",a."sourceKey",a.kcid,a."courseCategoryKey",a."updatedAt"::text AS "updatedAt",xs_appointment_valid_at(a.bxrq,a.yysj) AS "validToday",a."creditHoldStatus",a."creditHoldReason",a."bindingId",a."creditCostsSnapshot",a."kssj",a."jssj" 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."generalId"::text=$2';
   let state = await Psql.oneOrNone(stateSql, [DEFAULT_COMPANY_ID,id]);
   if (!state) fail(404, '预约不存在');
   let preparationReason='';
@@ -67,12 +67,13 @@ async function lessonOrderCapabilities(order, current) {
   const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
   const submittedReport = reviewed && [30,40].includes(status) ? await Psql.oneOrNone('SELECT "feedbackStatus" FROM "LessonCompletionReport" WHERE company=$1 AND "appointmentGeneralId"::text=$2 LIMIT 1',[DEFAULT_COMPANY_ID,id]) : null;
   const feedbackLocked = Boolean(submittedReport && submittedReport.feedbackStatus !== 'pending');
+  const freeWriting = ['primary_writing','middle_writing','high_writing'].includes(state.courseCategoryKey) && String(state.kcid)==='0' && !state.bindingId;
   const teacher = uid === number(order.pl), student = uid === number(order.szyh), actions = [];
   let blockedReason = '';
   if (order.cancelled) blockedReason = '预约已取消';
   else if (teacher) {
     if (status === 0 && !state.validToday) blockedReason = '课程仅在预约当天可开始,其他日期请联系管理员改期';
-    else if (status === 0 && (preparationReason||!state.bindingId || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = preparationReason||state.creditHoldReason||'开课记录或课时凭据尚未核实,请联系管理员';
+    else if (status === 0 && (preparationReason||(!state.bindingId&&!freeWriting) || !Array.isArray(state.creditCostsSnapshot) || !state.creditCostsSnapshot.length || state.creditHoldStatus && state.creditHoldStatus !== 'ready')) blockedReason = preparationReason||state.creditHoldReason||'开课记录或课时凭据尚未核实,请联系管理员';
     else if (status === 0) actions.push('start');
     if (status === 10) actions.push('continue','complete');
     if ([11,20].includes(status)) actions.push('complete');

+ 20 - 1
scripts/cloud/member-enrollment.js

@@ -64,7 +64,21 @@ async function enrollmentBindings(context,input,uid=0) {
   const rows=await Psql.query('SELECT b.*,c."objectId" AS "targetId",c."status",n."nodeName" AS "courseName" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid"::text WHERE b."company"=$1 AND ($2::numeric=0 OR b."yhid"::text=$2::text) AND c."status"=99 AND b."enrollmentStatus"=\'active\' AND COALESCE(n."zstatus",99)=99 AND ($3::text=\'\' OR EXISTS(SELECT 1 FROM \"_User\" s WHERE s.company=b.company AND s.\"legacyUserId\"::text=b.yhid::text AND s.\"identityType\"=\'member\' AND s.\"legacyUserData\"->>\'ParentUserID\'=$3 AND COALESCE(s.\"isDeleted\",false)=false))',[companyIdOf(context,input),uid,context.storeId?String(context.storeId):'']);
   return rows.map(row=>({bindingId:text(row.objectId),targetId:text(row.targetId),studentId:number(row.yhid),courseId:number(row.kcid),courseName:text(row.courseName),categoryKey:text(row.categoryKey),categoryName:TEACHING_CATEGORY_LABELS.get(text(row.categoryKey))||text(row.categoryName),learnedCount:number(row.yxx),wordCount:number(row.cksl)}));
 }
+function textbookFreeWriting(category) { return ['primary_writing','middle_writing','high_writing'].includes(text(category)); }
 async function enrollmentTeachingContext(context,input,payload,date) {
+  if(textbookFreeWriting(payload.categoryKey)){
+    if(number(payload.courseId)!==0||text(payload.bindingId))fail(400,'语法写作课无需绑定单词教材');
+    const category=text(payload.categoryKey),rules=await teachingRuleRows(context,input),rule=teachingRuleFor(rules,category,appointmentDuration(payload),deliveryMode(payload.deliveryMode||payload.deliveryMethod),date);
+    if(!rule)fail(409,'该语法写作课没有对应时长和授课方式的有效规则');
+    return{bindingId:'',mapping:{categoryKey:category,categoryName:TEACHING_CATEGORY_LABELS.get(category)},rule};
+  }
+  if(['trial','word_self_study'].includes(text(payload.categoryKey))){
+    const category=text(payload.categoryKey),catalog=await enrollmentCatalog(context,input),course=catalog.find(row=>row.courseId===number(payload.courseId)&&row.active&&row.wordCount>0);
+    if(!course)fail(409,'请选择有效且有学习内容的教材');
+    const rules=await teachingRuleRows(context,input),rule=teachingRuleFor(rules,category,appointmentDuration(payload),deliveryMode(payload.deliveryMode||payload.deliveryMethod),date);
+    if(appointmentDuration(payload)!==60||!rule)fail(409,'该课型没有对应时长和授课方式的有效规则');
+    return{bindingId:'',mapping:{categoryKey:category,categoryName:TEACHING_CATEGORY_LABELS.get(category)},rule};
+  }
   const rows=(await enrollmentBindings(context,input,number(payload.studentId))).filter(row=>row.courseId===number(payload.courseId)&&(!payload.bindingId||row.bindingId===text(payload.bindingId)));
   if(rows.length!==1)fail(409,rows.length?'该内容有多个开课分类,请选择具体开课记录':'该课程尚未有效开课,请先开课或确认历史分类');
   const binding=rows[0],rules=await teachingRuleRows(context,input),rule=teachingRuleFor(rules,binding.categoryKey,appointmentDuration(payload),deliveryMode(payload.deliveryMode||payload.deliveryMethod),date);
@@ -73,7 +87,12 @@ async function enrollmentTeachingContext(context,input,payload,date) {
 }
 async function enrollmentSchedule(context,input,data) {
   const [bindings,rules]=await Promise.all([enrollmentBindings(context,input),teachingRuleRows(context,input)]),date=text(input.dateFrom)||businessDate();
-  const courses=bindings.map(b=>{const active=rules.filter(r=>r.categoryKey===b.categoryKey&&teachingRuleFor(rules,b.categoryKey,r.durationMinutes,r.deliveryMode,date)?.objectId===r.objectId);return{...b,courseCategoryKey:b.categoryKey,courseCategoryName:b.categoryName,memberIds:[b.studentId],availableToAll:false,mappingConfirmed:active.length>0,supportedDurations:[...new Set(active.map(r=>r.durationMinutes))],supportedDeliveryModes:[...new Set(active.map(r=>r.deliveryMode))],categoryName:b.categoryName};});
+  const courses=bindings.filter(b=>!['trial','word_self_study'].includes(b.categoryKey)).map(b=>{const active=rules.filter(r=>r.categoryKey===b.categoryKey&&teachingRuleFor(rules,b.categoryKey,r.durationMinutes,r.deliveryMode,date)?.objectId===r.objectId);return{...b,courseCategoryKey:b.categoryKey,courseCategoryName:b.categoryName,memberIds:[b.studentId],availableToAll:false,mappingConfirmed:active.length>0,supportedDurations:[...new Set(active.map(r=>r.durationMinutes))],supportedDeliveryModes:[...new Set(active.map(r=>r.deliveryMode))],categoryName:b.categoryName};});
+  const catalog=await enrollmentCatalog(context,input);
+  for(const category of ['trial','word_self_study']){
+    const active=rules.filter(r=>r.categoryKey===category&&r.durationMinutes===60&&teachingRuleFor(rules,category,60,r.deliveryMode,date)?.objectId===r.objectId);
+    for(const row of catalog.filter(row=>row.active&&row.wordCount>0))courses.push({...row,bindingId:'',categoryKey:category,categoryName:TEACHING_CATEGORY_LABELS.get(category),memberIds:[],availableToAll:true,mappingConfirmed:active.length>0,supportedDurations:active.length?[60]:[],supportedDeliveryModes:[...new Set(active.map(r=>r.deliveryMode))]});
+  }
   return{...data,courses};
 }
 async function toggleEnrollment(context,input,restore) {

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


+ 27 - 0
scripts/sql/member-enrollment.sql

@@ -230,9 +230,12 @@ BEGIN
  IF a."objectId" IS NULL OR c."status"=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
  SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
+ -- Writing lessons use a category and credit rule, not a vocabulary binding.
+ IF NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND COALESCE(a."kcid"::text,'')='0' AND COALESCE(a."bindingId",'')='') THEN
  SELECT count(*) INTO matches FROM "CourseBinding" b JOIN "CommonModel" bc ON bc."company"=b."company" AND bc."modelId"=58 AND bc."itemId"=b."id" WHERE b."company"=co AND b."yhid"::text=a."szyh"::text AND b."kcid"::text=a."kcid"::text AND b."enrollmentStatus"='active' AND bc."status"=99 AND (NULLIF(a."bindingId",'') IS NULL OR b."objectId"=a."bindingId");
  PERFORM xs_course_size(co,a."kcid"::text);
  IF matches<>1 THEN RAISE EXCEPTION '预约必须关联唯一有效开课记录,请先确认分类';END IF;
+ END IF;
  IF COALESCE(a."creditHoldStatus",'ready')<>'ready' THEN RAISE EXCEPTION '历史预约课时待处理';END IF;
  IF jsonb_array_length(COALESCE(a."creditCostsSnapshot"::jsonb,'[]'))=0 THEN RAISE EXCEPTION '预约缺少课时快照';END IF;
  FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
@@ -272,6 +275,22 @@ BEGIN
  UPDATE "CourseAppointment" SET "dszt"='11',"jssj"=COALESCE(NULLIF("jssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
  RETURN jsonb_build_object('lessonId',lid,'idempotent',false,'periodDeducted',NOT charged);
 END $$;
+-- Selected textbooks are linked atomically with trial/self-study scheduling.
+CREATE OR REPLACE FUNCTION xs_schedule_textbook(co text,actor text,uid numeric,course text,category text) RETURNS text LANGUAGE plpgsql AS $$
+DECLARE bid text; ident bigint; content_count numeric; course_name text; category_name text;
+BEGIN
+ IF category NOT IN ('trial','word_self_study') THEN RAISE EXCEPTION '该课型需要提前开课';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ content_count=xs_course_size(co,course);
+ SELECT b."objectId" INTO bid FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id AND c.status=99 WHERE b.company=co AND b.yhid::text=uid::text AND b.kcid::text=course AND b."categoryKey"=category AND b."enrollmentStatus"='active' ORDER BY b."createdAt",b."objectId" LIMIT 1;
+ IF bid IS NOT NULL THEN RETURN bid;END IF;
+ SELECT "nodeName" INTO course_name FROM "Node" WHERE company=co AND "nodeId"::text=course;
+ category_name=CASE category WHEN 'trial' THEN '体验课' ELSE '单词自学课' END;
+ bid=xs_object_id();ident=nextval('xiaoshu_business_id');
+ PERFORM xs_insert('CourseBinding',jsonb_build_object('objectId',bid,'company',co,'id',ident,'createdAt',now(),'updatedAt',now(),'sourceKey','schedule-textbook:'||bid,'yhid',uid,'kcid',course::numeric,'yxx',0,'cksl',content_count,'syjd','','categoryKey',category,'categoryName',category_name,'enrollmentStatus','active'));
+ PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'createTime',now(),'sourceKey','schedule-textbook:'||bid,'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',58,'nodeId',28,'tableName','ZL_C_kcbd','status',99,'title',course_name,'inputer',actor));
+ RETURN bid;
+END $$;
 CREATE OR REPLACE FUNCTION xs_schedule_save(co text,actor text,req jsonb,entries jsonb,idem text,edit_id text DEFAULT '',expected text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE old "EnrollmentCommand"; entry jsonb; uid numeric; student "_User"; b "CourseBinding"; cid text; aid text; ident bigint; common_data jsonb; addon_data jsonb; ids jsonb='[]'; costs jsonb; old_c "CommonModel"; old_a "CourseAppointment"; requested_start timestamptz; requested_end timestamptz;
 BEGIN
@@ -292,9 +311,17 @@ BEGIN
  FOR entry IN SELECT value FROM jsonb_array_elements(entries) LOOP
   addon_data=entry->'addon';uid=(addon_data->>'szyh')::numeric;costs=addon_data->'creditCostsSnapshot';
   SELECT * INTO student FROM "_User" WHERE "company"=co AND "legacyUserId"=uid;
+  IF student."objectId" IS NULL OR COALESCE(student."isDisabled",false) OR COALESCE((to_jsonb(student)->>'isDeleted')::boolean,false) THEN RAISE EXCEPTION '学员不存在或已停用';END IF;
+  IF addon_data->>'courseCategoryKey' IN ('trial','word_self_study') THEN
+   IF (addon_data->>'durationMinutes')::numeric IS DISTINCT FROM 60 THEN RAISE EXCEPTION '体验课和单词自学课仅支持60分钟';END IF;
+   addon_data=jsonb_set(addon_data,'{bindingId}',to_jsonb(xs_schedule_textbook(co,actor,uid,addon_data->>'kcid',addon_data->>'courseCategoryKey')));
+  END IF;
+  -- Writing lessons use a category and credit rule, not a vocabulary binding.
+  IF NOT (COALESCE(addon_data->>'courseCategoryKey','') IN ('primary_writing','middle_writing','high_writing') AND COALESCE(addon_data->>'kcid','')='0' AND COALESCE(addon_data->>'bindingId','')='') THEN
   SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=addon_data->>'bindingId' AND "yhid"::text=uid::text AND "enrollmentStatus"='active';
   IF b."objectId" IS NULL OR b."kcid"::text<>addon_data->>'kcid' OR b."categoryKey"<>addon_data->>'courseCategoryKey' OR COALESCE(student."isDisabled",false) THEN RAISE EXCEPTION '学员或开课记录已失效';END IF;
   PERFORM xs_course_size(co,b."kcid"::text);
+  END IF;
   requested_start=(addon_data->>'yysj')::timestamp AT TIME ZONE 'Asia/Shanghai';requested_end=requested_start+make_interval(mins=>(addon_data->>'durationMinutes')::int);
   IF EXISTS(SELECT 1 FROM "CourseAppointment" a JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" WHERE a."company"=co AND c."status"<>-2 AND c."objectId"<>edit_id AND a."dszt"::text IN ('0','10','11','20') AND (a."szyh"::text=uid::text OR (NULLIF(addon_data->>'pl','') IS NOT NULL AND a."pl"::text=addon_data->>'pl')) AND COALESCE(a."yysj",'') ~ '^\d{4}-\d{2}-\d{2} ' AND (a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')<requested_end AND (a."yysj"::timestamp AT TIME ZONE 'Asia/Shanghai')+make_interval(mins=>COALESCE(a."durationMinutes",30)::int)>requested_start) THEN RAISE EXCEPTION '会员或老师时间冲突,请重新检查排课';END IF;
   IF edit_id='' THEN

+ 60 - 0
scripts/sql/writing-without-enrollment.sql

@@ -0,0 +1,60 @@
+-- Selected textbooks are linked atomically with trial/self-study scheduling.
+CREATE OR REPLACE FUNCTION xs_schedule_textbook(co text,actor text,uid numeric,course text,category text) RETURNS text LANGUAGE plpgsql AS $$
+DECLARE bid text; ident bigint; content_count numeric; course_name text; category_name text;
+BEGIN
+ IF category NOT IN ('trial','word_self_study') THEN RAISE EXCEPTION '该课型需要提前开课';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ content_count=xs_course_size(co,course);
+ SELECT b."objectId" INTO bid FROM "CourseBinding" b JOIN "CommonModel" c ON c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id AND c.status=99 WHERE b.company=co AND b.yhid::text=uid::text AND b.kcid::text=course AND b."categoryKey"=category AND b."enrollmentStatus"='active' ORDER BY b."createdAt",b."objectId" LIMIT 1;
+ IF bid IS NOT NULL THEN RETURN bid;END IF;
+ SELECT "nodeName" INTO course_name FROM "Node" WHERE company=co AND "nodeId"::text=course;
+ category_name=CASE category WHEN 'trial' THEN '体验课' ELSE '单词自学课' END;
+ bid=xs_object_id();ident=nextval('xiaoshu_business_id');
+ PERFORM xs_insert('CourseBinding',jsonb_build_object('objectId',bid,'company',co,'id',ident,'createdAt',now(),'updatedAt',now(),'sourceKey','schedule-textbook:'||bid,'yhid',uid,'kcid',course::numeric,'yxx',0,'cksl',content_count,'syjd','','categoryKey',category,'categoryName',category_name,'enrollmentStatus','active'));
+ PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'createTime',now(),'sourceKey','schedule-textbook:'||bid,'generalId',nextval('xiaoshu_business_id'),'itemId',ident,'modelId',58,'nodeId',28,'tableName','ZL_C_kcbd','status',99,'title',course_name,'inputer',actor));
+ RETURN bid;
+END $$;
+
+DO $patch$
+DECLARE definition text; original text;
+BEGIN
+ SELECT pg_get_functiondef(p.oid) INTO definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND p.proname='xs_validate_appointment';
+ IF definition IS NULL THEN RAISE EXCEPTION 'xs_validate_appointment missing';END IF;
+ IF position('Writing lessons use a category and credit rule' in definition)=0 THEN
+  original=definition;
+  definition=replace(definition,$old$ SELECT count(*) INTO matches FROM "CourseBinding" b JOIN "CommonModel" bc ON bc."company"=b."company" AND bc."modelId"=58 AND bc."itemId"=b."id" WHERE b."company"=co AND b."yhid"::text=a."szyh"::text AND b."kcid"::text=a."kcid"::text AND b."enrollmentStatus"='active' AND bc."status"=99 AND (NULLIF(a."bindingId",'') IS NULL OR b."objectId"=a."bindingId");
+ PERFORM xs_course_size(co,a."kcid"::text);
+ IF matches<>1 THEN RAISE EXCEPTION '预约必须关联唯一有效开课记录,请先确认分类';END IF;
+$old$,$new$ -- Writing lessons use a category and credit rule, not a vocabulary binding.
+ IF NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND COALESCE(a."kcid"::text,'')='0' AND COALESCE(a."bindingId",'')='') THEN
+ SELECT count(*) INTO matches FROM "CourseBinding" b JOIN "CommonModel" bc ON bc."company"=b."company" AND bc."modelId"=58 AND bc."itemId"=b."id" WHERE b."company"=co AND b."yhid"::text=a."szyh"::text AND b."kcid"::text=a."kcid"::text AND b."enrollmentStatus"='active' AND bc."status"=99 AND (NULLIF(a."bindingId",'') IS NULL OR b."objectId"=a."bindingId");
+ PERFORM xs_course_size(co,a."kcid"::text);
+ IF matches<>1 THEN RAISE EXCEPTION '预约必须关联唯一有效开课记录,请先确认分类';END IF;
+ END IF;
+$new$);
+  IF definition=original THEN RAISE EXCEPTION 'xs_validate_appointment baseline changed; review required';END IF;
+  EXECUTE definition;
+ END IF;
+ SELECT pg_get_functiondef(p.oid) INTO definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND p.proname='xs_schedule_save';
+ IF definition IS NULL THEN RAISE EXCEPTION 'xs_schedule_save missing';END IF;
+ IF position('xs_schedule_textbook(co,actor,uid' in definition)=0 THEN
+  original=definition;
+  definition=replace(definition,$old$  SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=addon_data->>'bindingId' AND "yhid"::text=uid::text AND "enrollmentStatus"='active';
+  IF b."objectId" IS NULL OR b."kcid"::text<>addon_data->>'kcid' OR b."categoryKey"<>addon_data->>'courseCategoryKey' OR COALESCE(student."isDisabled",false) THEN RAISE EXCEPTION '学员或开课记录已失效';END IF;
+  PERFORM xs_course_size(co,b."kcid"::text);
+$old$,$new$  IF student."objectId" IS NULL OR COALESCE(student."isDisabled",false) OR COALESCE((to_jsonb(student)->>'isDeleted')::boolean,false) THEN RAISE EXCEPTION '学员不存在或已停用';END IF;
+  IF addon_data->>'courseCategoryKey' IN ('trial','word_self_study') THEN
+   IF (addon_data->>'durationMinutes')::numeric IS DISTINCT FROM 60 THEN RAISE EXCEPTION '体验课和单词自学课仅支持60分钟';END IF;
+   addon_data=jsonb_set(addon_data,'{bindingId}',to_jsonb(xs_schedule_textbook(co,actor,uid,addon_data->>'kcid',addon_data->>'courseCategoryKey')));
+  END IF;
+  -- Writing lessons use a category and credit rule, not a vocabulary binding.
+  IF NOT (COALESCE(addon_data->>'courseCategoryKey','') IN ('primary_writing','middle_writing','high_writing') AND COALESCE(addon_data->>'kcid','')='0' AND COALESCE(addon_data->>'bindingId','')='') THEN
+  SELECT * INTO b FROM "CourseBinding" WHERE "company"=co AND "objectId"=addon_data->>'bindingId' AND "yhid"::text=uid::text AND "enrollmentStatus"='active';
+  IF b."objectId" IS NULL OR b."kcid"::text<>addon_data->>'kcid' OR b."categoryKey"<>addon_data->>'courseCategoryKey' OR COALESCE(student."isDisabled",false) THEN RAISE EXCEPTION '学员或开课记录已失效';END IF;
+  PERFORM xs_course_size(co,b."kcid"::text);
+  END IF;
+$new$);
+  IF definition=original THEN RAISE EXCEPTION 'xs_schedule_save baseline changed; review required';END IF;
+  EXECUTE definition;
+ END IF;
+END $patch$;

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

@@ -318,3 +318,14 @@ test('学生评价后锁定报告,包括旧评分;先评价仍允许一次
  await one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,report.revision,'legacy-rating-first-submit',JSON.stringify(feedback)]);
  assert.equal((await context.lessonOrderCapabilities(order,user)).feedbackLocked,true);
 });
+
+test('教师可以开始免教材语法写作,单词课仍要求有效开课凭据',async()=>{
+ const {default:vm}=await import('node:vm');
+ let state={kcid:'0',courseCategoryKey:'primary_writing',bindingId:'',validToday:true,creditHoldStatus:'ready',creditCostsSnapshot:[{account:'Purse',amount:1}]};
+ const context={DEFAULT_COMPANY_ID:'co',number:Number,ownLegacyId:()=>30,fail:(_c,m)=>{throw Error(m);},Psql:{oneOrNone:async(sql)=>sql.includes('FROM "CourseAppointment"')?state:null}};
+ vm.createContext(context);vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
+ const teacher={get:k=>k==='company'?{id:'co'}:null},order={GeneralID:1,pl:30,szyh:20,dszt:0};
+ for(const category of ['primary_writing','middle_writing','high_writing']){state.courseCategoryKey=category;assert.ok((await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));}
+ state.courseCategoryKey='word';assert.ok(!(await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));
+ state.courseCategoryKey='high_writing';state.creditHoldStatus='insufficient';assert.ok(!(await context.lessonOrderCapabilities(order,teacher)).allowedActions.includes('start'));
+});

+ 25 - 0
scripts/tests/member-enrollment-gateway.test.mjs

@@ -44,3 +44,28 @@ test('同日每半小时重复的60分钟课程提前识别批内重叠,可跳
  assert.equal(result.availableCount,2);assert.deepEqual(plain(result.conflictIndexes),[1]);assert.match(result.conflicts[0].reason,/本批次/);
  await assert.rejects(c.preflightAppointments({}, {},payload,'edit-id'),/单节/);
 });
+
+test('三个学段的语法写作均无需教材或开课,按时长选规则并校验余额',async()=>{
+ const c=setup();c.Psql={oneOrNone:async()=>{throw Error('无需查询词库');}};c.enrollmentBindings=async()=>{throw Error('无需开课绑定');};c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
+ for(const categoryKey of ['primary_writing','middle_writing','high_writing'])for(const durationMinutes of [30,60]){
+  const result=await c.preflightAppointments({}, {},{studentId:20,courseId:0,bindingId:'',categoryKey,durationMinutes,deliveryMode:'online',date:'2026-10-22',startTime:'10:00'});
+  assert.equal(result.bindingId,'');assert.equal(result.courseCategoryKey,categoryKey);assert.equal(result.teachingRule.durationMinutes,durationMinutes);assert.match(result.courseName,/写作/);assert.ok(result.creditAvailability.length>0);
+ }
+ c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{}});
+ await assert.rejects(c.preflightAppointments({}, {},{studentId:20,courseId:0,categoryKey:'primary_writing',durationMinutes:30,date:'2026-10-22',startTime:'10:00'}),/可用课时不足/);
+ await assert.rejects(c.preflightAppointments({}, {},{studentId:20,courseId:0,categoryKey:'word',durationMinutes:30,date:'2026-10-22',startTime:'10:00'}),/必填/);
+ await assert.rejects(c.enrollmentTeachingContext({}, {},{categoryKey:'high_writing',courseId:100,durationMinutes:30},'2026-10-22'),/无需绑定/);
+});
+
+test('体验和自学直接选教材,无绑定可预检,目录对所有学员开放且过滤空教材',async()=>{
+ const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
+ for(const categoryKey of ['trial','word_self_study']){
+  const payload={studentId:20,courseId:100,categoryKey,durationMinutes:60,deliveryMode:'online',date:'2026-10-22',startTime:'10:00'};
+  const r=await c.preflightAppointments({}, {},payload);assert.equal(r.courseCategoryKey,categoryKey);assert.equal(r.bindingId,'');
+  await assert.rejects(c.preflightAppointments({}, {},{...payload,courseId:0}),/必填/);
+  await assert.rejects(c.preflightAppointments({}, {},{...payload,courseId:999}),/有效/);
+  await assert.rejects(c.preflightAppointments({}, {},{...payload,durationMinutes:30}),/有效规则/);
+ }
+ const result=await c.enrollmentSchedule({}, {dateFrom:'2026-10-22'},{});assert.equal(result.courses.length,6);assert.ok(result.courses.every(row=>row.availableToAll&&row.mappingConfirmed&&row.supportedDurations[0]===60));
+ c.enrollmentCatalog=async()=>[{courseId:100,active:false,wordCount:20},{courseId:101,active:true,wordCount:0}];assert.equal((await c.enrollmentSchedule({}, {},{})).courses.length,0);
+});

+ 43 - 0
scripts/tests/member-enrollment.test.mjs

@@ -195,3 +195,46 @@ test('当天未开课继续预留课时,昨日未开课不再占用,已开
   await ctx.releaseExpiredReservations({},{});assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'released');
  }finally{await db.exec('ROLLBACK');}
 });
+
+test('无教材写作课可排课、取消恢复、上课完课,并保留预留和幂等扣减',async()=>{
+ await db.exec(`INSERT INTO "_User" VALUES ('writing-student','co',99,1,'member',false,false,'{"Purse":10,"SilverCoin":10,"UserPoint":10}',now())`);
+ const date=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;
+ let index=0,first;
+ for(const category of ['primary_writing','middle_writing','high_writing'])for(const duration of [30,60]){
+  const e=scheduleEntry('',date+' '+String(8+index*2).padStart(2,'0')+':00');Object.assign(e.addon,{szyh:'99',pl:'98',fxpl:'98',kcid:'0',yykcid:'0',courseCategoryKey:category,courseCategoryName:category,durationMinutes:duration,creditCostsSnapshot:[{account:duration===30?'Purse':'SilverCoin',amount:1},{account:'UserPoint',amount:duration/60}]});
+  const saved=await saveSchedule([e],'writing-'+index+'-save');assert.equal(saved.created,1);if(!first)first=saved.targetIds[0];index++;
+ }
+ assert.equal(Number((await one('SELECT count(*) n FROM "CourseBinding" WHERE yhid=\'99\'')).n),0);
+ assert.equal(await balance('writing-student'),10);
+ await one('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[first]);
+ await one('SELECT xs_schedule_cancel(\'co\',$1,true,\'admin\',\'测试恢复\')',[first]);
+ await one('SELECT xs_start_appointment(\'co\',$1)',[first]);
+ await one('SELECT xs_complete_appointment(\'co\',$1,\'teacher\')',[first]);
+ assert.equal(await balance('writing-student'),9);assert.equal(await balance('writing-student','UserPoint'),9.5);
+ await one('SELECT xs_complete_appointment(\'co\',$1,\'teacher\')',[first]);assert.equal(await balance('writing-student'),9);
+ const bad=scheduleEntry('',date+' 22:00',100);Object.assign(bad.addon,{szyh:'99',pl:'98',fxpl:'98',kcid:'0',yykcid:'0',courseCategoryKey:'high_writing'});
+ await assert.rejects(saveSchedule([bad],'writing-overdraw'),/不足/);
+ bad.addon.courseCategoryKey='word';await assert.rejects(saveSchedule([bad],'word-without-binding'),/失效/);
+ await db.exec(readFileSync(new URL('../sql/writing-without-enrollment.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('../sql/writing-without-enrollment.sql',import.meta.url),'utf8'));
+});
+
+test('体验和自学排课原子关联教材,复用绑定,失败回滚,不额外扣费',async()=>{
+ await db.exec(`INSERT INTO "_User" VALUES ('direct-student','co',97,1,'member',false,false,'{"Purse":10,"SilverCoin":10,"UserPoint":10,"UserExp":10}',now())`);
+ const date=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;
+ let index=0;
+ for(const category of ['trial','word_self_study']){
+  let firstBinding;
+  for(let repeat=0;repeat<2;repeat++){
+   const e=scheduleEntry('',date+' '+String(7+index*2).padStart(2,'0')+':00');Object.assign(e.addon,{szyh:'97',pl:'96',fxpl:'96',courseCategoryKey:category,durationMinutes:60,creditCostsSnapshot:[{account:category==='trial'?'UserExp':'SilverCoin',amount:1}]});
+   const saved=await saveSchedule([e],'direct-textbook-'+index++),aid=saved.targetIds[0];
+   const a=await one('SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" c ON c.company=a.company AND c."itemId"=a.id AND c."modelId"=54 WHERE c."objectId"=$1',[aid]);assert.ok(a.bindingId);if(firstBinding)assert.equal(a.bindingId,firstBinding);firstBinding=a.bindingId;
+   if(!repeat){await one('SELECT xs_start_appointment(\'co\',$1)',[aid]);await one('SELECT xs_complete_appointment(\'co\',$1,\'teacher\')',[aid]);}
+  }
+  assert.equal((await one('SELECT count(*)::int n FROM "CourseBinding" WHERE yhid=\'97\' AND "categoryKey"=$1',[category])).n,1);
+ }
+ assert.equal(await balance('direct-student','UserExp'),9);assert.equal(await balance('direct-student','SilverCoin'),9);
+ const e=scheduleEntry('',date+' 21:00',100);Object.assign(e.addon,{szyh:'99',pl:'96',fxpl:'96',courseCategoryKey:'trial',durationMinutes:60});
+ await assert.rejects(saveSchedule([e],'direct-textbook-no-credit'),/不足/);
+ assert.equal((await one('SELECT count(*)::int n FROM "CourseBinding" WHERE yhid=\'99\'')).n,0);
+});

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