Răsfoiți Sursa

fix(schedule): support legacy trials and bulk cancellation

彭峰 23 ore în urmă
părinte
comite
1189cf9056

+ 7 - 0
docs/qa/legacy-trial-appointment-2026-11-04.md

@@ -0,0 +1,7 @@
+# 历史预约体验课识别修复(2026-11-04)
+
+沈佳惠高三(旧用户 1872)的预约 888557 是 11 月 3 日在旧系统创建、11 月 4 日上课的体验课;预约已经同步到新系统。体验课标记为 `dslx=3`,但准备课时时按单词教材的类型判断为普通课,因普通单词课时不足而报错。该学员有 1 节体验课时。
+
+修复优先识别没有明确非体验类型的历史体验课,允许使用原有单词教材绑定,并保留绑定的单词课程类型,使同一绑定后续普通课仍按普通课计费。明确的其他课程类型和已有费用快照不被覆盖。准备操作重复执行保持幂等。
+
+使用真实预约数据在数据库回滚事务中执行修复后的准备、重复准备、开始和结束流程:体验课扣减 1 节体验课时,不扣普通课时,教师费用快照 30 元,结课状态正常;所有操作均已回滚,未改变真实课时或课程状态。自动测试覆盖体验课、普通课共用教材、余额不足和幂等。

+ 9 - 0
docs/qa/schedule-cross-date-bulk-2026-11-04.md

@@ -0,0 +1,9 @@
+# 跨日期预约批量管理(2026-11-04)
+
+排课中心提供跨日期列表,按学员、老师或课程搜索,支持每页 10、25、50、100 条、本页全选和所有搜索结果跨页全选。跨页勾选可以逐条取消;查询结果在读取途中变化时要求重新查询。
+
+仅可选择状态已确认的未开始预约。后端在事务中再次检查未开始状态和预约版本,复用原有取消、课时预留释放、门店权限与审计流程。取消结果逐条汇总;失败或结果待核对的预约保留选择及提示。新系统取消不会直接删除旧系统记录,需另行核对旧系统。
+
+验证:后台相关自动测试 75 项通过;管理端排课及批量管理测试 40 项通过,构建和云函数定义校验通过。隔离的本地浏览器模拟接口验证跨页全选、取消勾选、提交和桌面及手机布局;没有取消真实预约。
+
+上线需同步管理端静态资源、运营网关的预约查询和取消处理、`xs_schedule_cancel_unstarted` 数据库函数;体验课修复还需更新 `xs_prepare_legacy_appointment`。部署后需核对线上实际版本和操作结果。

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

@@ -7,6 +7,7 @@
   <div><p>课程安排</p><h1>排课中心</h1><span>查看课表、定位待处理预约,或为学员安排新课</span></div>
   <div class="header-actions">
     <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="loading()?icons.LoaderCircle:icons.RefreshCw" [size]="17" [class.spin]="loading()" />{{loading()?'正在加载…':'刷新'}}</button>
+    <button type="button" (click)="bulkManagerOpen.set(true)">跨日期预约管理</button>
     <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17" />新增排课</button>
   </div>
 </header>
@@ -228,3 +229,5 @@
 }
 
 @if(lessonAppointmentId()){<app-lesson-record-detail [appointmentId]="lessonAppointmentId()" (closed)="lessonAppointmentId.set('')" />}
+
+@if(bulkManagerOpen()){<app-schedule-bulk-manager (closed)="bulkManagerOpen.set(false)" (changed)="load(true)" />}

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

@@ -1,4 +1,5 @@
 import { AdminSessionService } from '../admin-session.service';
+import { ScheduleBulkManagerComponent } from './schedule-bulk-manager.component';
 import { LessonRecordDetailComponent } from './lesson-record-detail.component';
 import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
@@ -130,8 +131,9 @@ export function scheduleCoachBusy(
   });
 }
 
-@Component({ selector:'app-operations-schedule', host:{class:'schedule-ui'}, standalone:true, imports: [MemberEnrollmentComponent,LessonRecordDetailComponent,AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
+@Component({ selector:'app-operations-schedule', host:{class:'schedule-ui'}, standalone:true, imports: [ScheduleBulkManagerComponent,MemberEnrollmentComponent,LessonRecordDetailComponent,AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit, OnDestroy {
+  readonly bulkManagerOpen=signal(false);
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly lessonAppointmentId=signal(''); readonly feedback=signal<Record<string,unknown>|null>(null); readonly detail=signal<ScheduleAppointment|null>(null); readonly cancelReason=signal(''); readonly detailError=signal(''); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null); readonly checking=signal(false);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);

+ 14 - 0
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.html

@@ -0,0 +1,14 @@
+<div class="bulk-backdrop" (click)="close()"></div>
+<section class="bulk-dialog" role="dialog" aria-modal="true" aria-labelledby="bulk-title">
+ <header><div><h2 id="bulk-title">跨日期预约管理</h2><p>按学员或老师姓名查询,勾选后批量取消未开始的预约</p></div><button type="button" class="close" aria-label="关闭" (click)="close()" [disabled]="saving()||selecting()">×</button></header>
+ <form class="search" (ngSubmit)="searchAppointments()"><label><span>学员、老师或课程名称</span><input name="search" [(ngModel)]="searchInput" placeholder="例如:杨珍、灵灵" /></label><button type="submit" [disabled]="loading()||selecting()||saving()">查询</button></form>
+ <p class="hint">显示全部日期。仅未开始的预约可批量取消;已开始、已结束及只读预约请逐项核对。取消会释放课时预留,旧系统原预约需在旧系统另行核对。</p>
+ @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+ <div class="controls"><div><strong>共 {{total()}} 条</strong><span>已选 {{selected().size}} 条</span></div><div><button type="button" (click)="selectAllMatching()" [disabled]="loading()||selecting()||saving()">{{selecting()?'正在选择…':'全选全部查询结果'}}</button><button type="button" (click)="clearSelection()" [disabled]="!selected().size||selecting()||saving()">清空选择</button></div></div>
+ <div class="table-wrap" [attr.aria-busy]="loading()"><table><thead><tr><th><input type="checkbox" aria-label="选择本页全部未开始预约" [checked]="pageSelected()" (change)="togglePage()" [disabled]="!selectablePage().length||loading()||selecting()||saving()" /></th><th>预约时间</th><th>学员</th><th>老师</th><th>课程</th><th>状态</th></tr></thead><tbody>
+ @for(row of rows();track row.targetId){<tr><td><input type="checkbox" [attr.aria-label]="'选择 '+row.studentName+' 的预约'" [checked]="selected().has(row.targetId)" [disabled]="!canSelect(row)||loading()||selecting()||saving()" (change)="toggleRow(row)" /></td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.studentName}}</td><td>{{row.coachName}}</td><td>{{row.courseName}}</td><td>{{row.statusLabel || (row.cancelled?'已取消':row.statusCode===0?'未开始':'不可批量取消')}}</td></tr>}
+ @empty{<tr><td colspan="6" class="empty">{{loading()?'正在加载…':'没有找到预约'}}</td></tr>}
+ </tbody></table></div>
+ <div class="pagination"><label>每页 <select [ngModel]="pageSize()" (ngModelChange)="changePageSize(+$event)" [disabled]="loading()||selecting()||saving()"><option [ngValue]="10">10</option><option [ngValue]="25">25</option><option [ngValue]="50">50</option><option [ngValue]="100">100</option></select> 条</label><div><button type="button" (click)="changePage(page()-1)" [disabled]="page()<=1||loading()||selecting()||saving()">上一页</button><span>第 {{page()}} / {{pageCount()}} 页</span><button type="button" (click)="changePage(page()+1)" [disabled]="page()>=pageCount()||loading()||selecting()||saving()">下一页</button></div></div>
+ <footer><label>取消原因 <input [(ngModel)]="reason" [disabled]="saving()" maxlength="200" /></label><button class="danger" type="button" (click)="cancelSelected()" [disabled]="!selected().size||loading()||selecting()||saving()">{{saving()?'正在处理 '+progress().done+' / '+progress().total:'批量取消所选预约'}}</button></footer>
+</section>

+ 9 - 0
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.scss

@@ -0,0 +1,9 @@
+:host{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:20px;color:#20332e}
+.bulk-backdrop{position:absolute;inset:0;background:rgba(16,37,30,.48)}
+.bulk-dialog{position:relative;display:flex;flex-direction:column;gap:14px;width:min(1100px,100%);max-height:calc(100vh - 40px);padding:22px;background:#fff;border-radius:16px;box-shadow:0 18px 60px #173c3040;overflow:auto}
+header,.controls,.pagination,footer{display:flex;align-items:center;justify-content:space-between;gap:12px}h2,p{margin:0}header p,.hint{color:#62766e;font-size:13px;line-height:1.5}header h2{font-size:21px;margin-bottom:5px}.close{font-size:25px;line-height:1;padding:0 8px}
+button,input,select{font:inherit}button{background:#fff;border:1px solid #cdded7;border-radius:8px;color:#176c57;padding:9px 13px;cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}input:not([type=checkbox]),select{border:1px solid #cdded7;border-radius:8px;padding:9px 11px;min-width:0}input[type=checkbox]{width:17px;height:17px;accent-color:#19765d;cursor:pointer}
+.search{display:flex;gap:10px;align-items:end}.search label{display:grid;gap:5px;flex:1;font-size:13px}.search button{background:#176c57;color:#fff}.controls>div,.pagination>div{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.controls span{font-size:13px;color:#60756d}.notice{padding:10px;border-radius:8px;background:#eaf6f0;color:#165e49;font-size:13px}.notice.error{background:#fff0ee;color:#a32826}
+.table-wrap{overflow:auto;border:1px solid #dce8e3;border-radius:9px;min-height:160px}table{border-collapse:collapse;width:100%;min-width:700px;text-align:left;font-size:13px}th,td{padding:11px 10px;border-bottom:1px solid #e4ece8}th{background:#f1f7f4;color:#52685f}tbody tr:last-child td{border-bottom:0}.empty{text-align:center;color:#71847b;padding:35px}.pagination{font-size:13px}.pagination label,.pagination div{display:flex;align-items:center;gap:8px}footer{padding-top:8px;border-top:1px solid #e0e9e4}footer label{display:flex;align-items:center;gap:8px;font-size:13px;flex:1}footer input{flex:1;max-width:360px}.danger{background:#b63e35;color:white;border-color:#b63e35}
+.pagination label{white-space:nowrap;flex:0 0 auto}.pagination select{width:75px;flex:none}
+@media(max-width:640px){:host{padding:8px}.bulk-dialog{padding:15px;max-height:calc(100vh - 16px)}.controls,.pagination,footer{align-items:stretch;flex-direction:column}footer label{flex-wrap:wrap}footer input{max-width:none}.search{align-items:stretch}.search button{align-self:end}}

+ 21 - 0
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.spec.ts

@@ -0,0 +1,21 @@
+import { TestBed } from '@angular/core/testing';
+import { ScheduleBulkManagerComponent } from './schedule-bulk-manager.component';
+import { OperationsService } from '../operations.service';
+
+describe('跨日期预约批量管理',()=>{
+ let ops:jasmine.SpyObj<OperationsService>;
+ const rows=Array.from({length:201},(_,i)=>({targetId:'a'+i,generalId:i+1,studentName:'学员'+i,coachName:'杨珍',courseName:'课程',startsAt:'2026-11-05T10:00:00+08:00',updatedAt:'2026-11-01T00:00:00.000Z',statusCode:i===199?10:0,cancelled:i===200,readOnly:false}));
+ beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['list','mutate']);ops.list.and.callFake(async(_kind,page,size,search)=>({page,pageSize:size,total:rows.length,items:rows.slice((page-1)*size,page*size),refreshedAt:''}));TestBed.configureTestingModule({imports:[ScheduleBulkManagerComponent],providers:[{provide:OperationsService,useValue:ops}]});});
+ it('按姓名跨日期查询,全选所有匹配页,排除已取消和训练中课程,并可取消单项',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance;c.searchInput='杨珍';await c.searchAppointments();
+  expect(ops.list).toHaveBeenCalledWith('appointments',1,25,'杨珍');await c.selectAllMatching();expect(c.selected().size).toBe(199);expect(c.selected().has('a199')).toBeFalse();expect(c.selected().has('a200')).toBeFalse();
+  c.toggleRow(rows[0] as any);expect(c.selected().size).toBe(198);
+ });
+ it('批量请求附带版本和未开始保护,部分失败保留待核对项',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance;c.searchInput='杨珍';await c.searchAppointments();c.toggleRow(rows[0] as any);c.toggleRow(rows[1] as any);
+  spyOn(window,'confirm').and.returnValue(true);ops.mutate.and.callFake(async(_op,id)=>{if(id==='a1')throw Error('预约已变更');return {} as any;});
+  await c.cancelSelected();expect(ops.mutate.calls.count()).toBe(2);expect(ops.mutate.calls.allArgs()[0]).toEqual(['ops/appointments/cancel','a0','批量调整排课',{onlyUnstarted:true},rows[0].updatedAt]);
+  expect(c.selected().has('a0')).toBeFalse();expect(c.selected().has('a1')).toBeTrue();expect(c.message()).toContain('1');expect(c.error()).toContain('预约已变更');
+  expect(c.rows()[0].cancelled).toBeTrue();expect(c.canSelect({...rows[0],statusUnverified:true} as any)).toBeFalse();
+ });
+});

+ 61 - 0
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.ts

@@ -0,0 +1,61 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ScheduleAppointment } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+/** Cross-date search and explicit selection; each cancellation retains the normal audit and credit workflow. */
+@Component({selector:'app-schedule-bulk-manager',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./schedule-bulk-manager.component.html',styleUrls:['./schedule-bulk-manager.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+export class ScheduleBulkManagerComponent implements OnInit {
+ @Output() closed=new EventEmitter<void>();
+ @Output() changed=new EventEmitter<void>();
+ private readonly operations=inject(OperationsService);
+ readonly rows=signal<ScheduleAppointment[]>([]);readonly selected=signal(new Map<string,ScheduleAppointment>());
+ readonly page=signal(1);readonly pageSize=signal(25);readonly total=signal(0);readonly query=signal('');
+ readonly loading=signal(false);readonly selecting=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');
+ readonly progress=signal({done:0,total:0});readonly pageCount=computed(()=>Math.max(1,Math.ceil(this.total()/this.pageSize())));
+ readonly selectablePage=computed(()=>this.rows().filter(row=>this.canSelect(row)));
+ readonly pageSelected=computed(()=>this.selectablePage().length>0&&this.selectablePage().every(row=>this.selected().has(row.targetId)));
+ searchInput='';reason='批量调整排课';private request=0;
+ ngOnInit():void{void this.loadPage();}
+ canSelect(row:ScheduleAppointment):boolean{return Boolean(row.targetId&&row.updatedAt)&&!row.readOnly&&!row.statusUnverified&&!row.cancelled&&Number(row.statusCode)===0;}
+ async searchAppointments():Promise<void>{if(this.loading()||this.selecting()||this.saving())return;this.query.set(this.searchInput.trim());this.page.set(1);this.selected.set(new Map());this.message.set('');await this.loadPage();}
+ async loadPage():Promise<void>{
+  const id=++this.request;this.loading.set(true);this.error.set('');
+  try{const result=await this.operations.list('appointments',this.page(),this.pageSize(),this.query());if(id!==this.request)return;
+   const last=Math.max(1,Math.ceil(result.total/this.pageSize()));if(this.page()>last){this.page.set(last);await this.loadPage();return;}
+   this.rows.set(result.items as unknown as ScheduleAppointment[]);this.total.set(result.total);
+  }catch(e){if(id===this.request)this.error.set(e instanceof Error?e.message:'预约查询失败,请重试');}
+  finally{if(id===this.request)this.loading.set(false);}
+ }
+ async changePage(page:number):Promise<void>{if(this.loading()||this.selecting()||this.saving()||page<1||page>this.pageCount())return;this.page.set(page);await this.loadPage();}
+ async changePageSize(size:number):Promise<void>{if(this.loading()||this.selecting()||this.saving())return;this.pageSize.set(Number(size));this.page.set(1);await this.loadPage();}
+ toggleRow(row:ScheduleAppointment):void{if(this.loading()||this.selecting()||this.saving()||!this.canSelect(row))return;this.message.set('');this.selected.update(current=>{const next=new Map(current);if(next.has(row.targetId))next.delete(row.targetId);else next.set(row.targetId,row);return next;});}
+ togglePage():void{if(this.loading()||this.selecting()||this.saving())return;this.message.set('');const clear=this.pageSelected();this.selected.update(current=>{const next=new Map(current);for(const row of this.selectablePage())if(clear)next.delete(row.targetId);else next.set(row.targetId,row);return next;});}
+ async selectAllMatching():Promise<void>{
+  if(this.loading()||this.selecting()||this.saving())return;
+  if(!this.query()){this.error.set('请先按学员或老师姓名搜索,再全选全部结果');return;}
+  this.selecting.set(true);this.error.set('');this.message.set('');
+  try{const found=new Map<string,ScheduleAppointment>();const first=await this.operations.list('appointments',1,100,this.query()),total=first.total;
+   const add=(items:Record<string,unknown>[])=>{for(const raw of items){const row=raw as ScheduleAppointment;if(row.targetId)found.set(row.targetId,row);}};
+   add(first.items);for(let page=2;page<=Math.ceil(total/100);page++){const result=await this.operations.list('appointments',page,100,this.query());if(result.total!==total)throw Error('查询结果已变化,请重新搜索后再全选');add(result.items);}
+   if(found.size!==total)throw Error('查询结果已变化,请重新搜索后再全选');
+   this.selected.set(new Map([...found].filter(([,row])=>this.canSelect(row))));this.message.set(`已选择 ${this.selected().size} 条未开始的预约`);
+  }catch(e){this.error.set(e instanceof Error?e.message:'全选失败,请重新搜索');}finally{this.selecting.set(false);}
+ }
+ clearSelection():void{if(!this.saving()&&!this.selecting()){this.selected.set(new Map());this.message.set('');}}
+ async cancelSelected():Promise<void>{
+  if(this.loading()||this.selecting()||this.saving())return;const items=[...this.selected().values()].filter(row=>this.canSelect(row));const reason=this.reason.trim();
+  if(!items.length){this.error.set('请先勾选未开始的预约');return;}if(!reason){this.error.set('请填写取消原因');return;}
+  if(!window.confirm(`确认取消所选的 ${items.length} 条未开始预约?此操作会释放对应课时预留。`))return;
+  this.saving.set(true);this.error.set('');this.message.set('');this.progress.set({done:0,total:items.length});let index=0;const succeeded:string[]=[],failed:{row:ScheduleAppointment;message:string}[]=[];
+  const worker=async()=>{while(index<items.length){const row=items[index++];try{await this.operations.mutate('ops/appointments/cancel',row.targetId,reason,{onlyUnstarted:true},row.updatedAt);succeeded.push(row.targetId);}catch(e){failed.push({row,message:e instanceof Error?e.message:'结果待核对'});}finally{this.progress.update(p=>({...p,done:p.done+1}));}}};
+  try{await Promise.all(Array.from({length:Math.min(4,items.length)},()=>worker()));this.selected.update(current=>{const next=new Map(current);for(const id of succeeded)next.delete(id);return next;});
+   const completed=new Set(succeeded);this.rows.update(rows=>rows.map(row=>completed.has(row.targetId)?{...row,cancelled:true,statusLabel:'已取消'}:row));
+   this.message.set(`已取消 ${succeeded.length} 条${failed.length?`,${failed.length} 条未成功或待核对`:''}`);if(failed.length)this.error.set(failed.slice(0,5).map(x=>`${x.row.studentName} ${x.row.startsAt?.slice(0,16)||''}:${x.message}`).join(';')+(failed.length>5?';其余请刷新核对':''));
+   if(succeeded.length)this.changed.emit();
+  }finally{this.saving.set(false);}
+  // Keep failure details visible; refresh on the user's next search to avoid hiding uncertain outcomes.
+ }
+ close():void{if(!this.saving()&&!this.selecting())this.closed.emit();}
+}

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

@@ -116,5 +116,6 @@ async function saveEnrollmentSchedule(context,input,payload,editing=false) {
 async function cancelEnrollmentSchedule(context,input,restore){
  requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),pair=await appointmentPair(context,input,text(input.targetId));assertVersion(pair.common,input.expectedUpdatedAt);
 
+ if(!restore && input.payload?.onlyUnstarted===true) return(await enrollmentSql('SELECT xs_schedule_cancel_unstarted($1,$2,$3,$4,$5) AS result',[pointerId(pair.common.get('company')),pair.common.id,context.current.id,reason,text(input.expectedUpdatedAt)],{context,studentId:number(pair.addon.get('szyh')),targetId:pair.common.id})).result;
  return(await enrollmentSql('SELECT xs_schedule_cancel($1,$2,$3,$4,$5,$6) AS result',[pointerId(pair.common.get('company')),pair.common.id,restore,context.current.id,reason,text(input.expectedUpdatedAt)],{context,studentId:number(pair.addon.get('szyh')),targetId:pair.common.id})).result;
 }

+ 1 - 1
scripts/deploy-admin-functions.mjs

@@ -3311,7 +3311,7 @@ async function businessRecordSqlPage(kind,sql,args,input){
   lessons:['targetId','generalId','xymz','studentRealName','studentNickname','studentUsername','coachRealName','coachNickname','coachUsername','kcmc','courseTitle','kcid','payrollCategoryKey','payrollCategoryName','pjnr','pldp','plpjsj'],
   'learning-records':common.concat(['userId','dqrq','learned','ygg','djq'])
  }[kind];if(!fields)fail(400,'记录类型无效');
- const terms=fields.map(key=>'record."'+key+'"::text');terms.push("record.\"studentLegacy\"->>'HoneyName'");
+ const terms=fields.map(key=>'record."'+key+'"::text');terms.push("record.\"studentLegacy\"->>'HoneyName'");if(kind==='appointments')terms.push("record.\"coachLegacy\"->>'HoneyName'");
  let where='';if(paging.search){values.push('%'+paging.search.replace(/[\\%_]/g,'\\$&')+'%');where=" WHERE LOWER(CONCAT_WS(' ',"+terms.join(',')+')) LIKE $'+values.length+" ESCAPE E'\\\\'";}
  if(input._storeId){values.push(String(input._storeId));where+=(where?' AND ':' WHERE ')+"record.\"studentLegacy\"->>'ParentUserID'=$"+values.length;}
  values.push(paging.pageSize,(paging.page-1)*paging.pageSize);

+ 14 - 7
scripts/sql/legacy-appointment-preparation.sql

@@ -3,7 +3,7 @@
 CREATE OR REPLACE FUNCTION xs_prepare_legacy_appointment(co text,aid text,actor text,seed_rules jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE c "CommonModel";a "CourseAppointment";b "CourseBinding";u "_User";op "_User";
  category text;category_name text;course text;day date;minutes numeric;mode text;rule jsonb;costs jsonb;cost jsonb;
- matches integer;notes_legacy boolean=false;existing "CourseCreditReservation";before_data jsonb;changed boolean=false;
+ matches integer;notes_legacy boolean=false;legacy_trial boolean=false;existing "CourseCreditReservation";before_data jsonb;changed boolean=false;
 BEGIN
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT * INTO op FROM "_User" WHERE company=co AND "objectId"=actor;
@@ -24,6 +24,9 @@ BEGIN
  course=a.kcid::text;
  IF NULLIF(a.yykcid::text,'') IS NOT NULL AND a.yykcid::text<>course THEN RAISE EXCEPTION '预约课程编号与排课课程不一致,请管理员核实';END IF;
  category=NULLIF(a."courseCategoryKey",'');
+ -- The legacy lesson type identifies a trial; the textbook profile describes its words, not its price.
+ legacy_trial=a.dslx::text='3' AND (category IS NULL OR category='trial');
+ IF legacy_trial THEN category='trial'; END IF;
  -- The legacy writing course has no vocabulary binding; only a per-appointment human confirmation is sufficient.
  notes_legacy=COALESCE(course='398' AND category IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(to_jsonb(a)->>'categoryEvidence','')))>=2 AND NULLIF(a."bindingId",'') IS NULL,false);
  IF notes_legacy THEN
@@ -32,14 +35,14 @@ BEGIN
  PERFORM xs_course_size(co,course);
  SELECT count(*) INTO matches FROM "CourseBinding" cb JOIN "CommonModel" bc ON bc.company=cb.company AND bc."modelId"=58 AND bc."itemId"=cb.id
  WHERE cb.company=co AND cb.yhid::text=a.szyh::text AND cb.kcid::text=course AND bc.status=99
- AND COALESCE(cb."enrollmentStatus",'pending') IN ('active','pending') AND (category IS NULL OR cb."categoryKey"=category OR cb."categoryKey" IS NULL)
+ AND COALESCE(cb."enrollmentStatus",'pending') IN ('active','pending') AND (category IS NULL OR cb."categoryKey"=category OR cb."categoryKey" IS NULL OR (legacy_trial AND cb."categoryKey"='word' AND cb."enrollmentStatus"='active'))
  AND (NULLIF(a."bindingId",'') IS NULL OR cb."objectId"=a."bindingId");
  IF matches<>1 THEN RAISE EXCEPTION '预约缺少唯一开课记录,请由管理员确认分类';END IF;
  SELECT cb.* INTO b FROM "CourseBinding" cb JOIN "CommonModel" bc ON bc.company=cb.company AND bc."modelId"=58 AND bc."itemId"=cb.id
  WHERE cb.company=co AND cb.yhid::text=a.szyh::text AND cb.kcid::text=course AND bc.status=99
- AND COALESCE(cb."enrollmentStatus",'pending') IN ('active','pending') AND (category IS NULL OR cb."categoryKey"=category OR cb."categoryKey" IS NULL)
+ AND COALESCE(cb."enrollmentStatus",'pending') IN ('active','pending') AND (category IS NULL OR cb."categoryKey"=category OR cb."categoryKey" IS NULL OR (legacy_trial AND cb."categoryKey"='word' AND cb."enrollmentStatus"='active'))
  AND (NULLIF(a."bindingId",'') IS NULL OR cb."objectId"=a."bindingId") FOR UPDATE OF cb;
- category=COALESCE(category,NULLIF(b."categoryKey",''));category_name=b."categoryName";
+ category=COALESCE(category,NULLIF(b."categoryKey",''));category_name=CASE WHEN legacy_trial THEN '体验课' ELSE b."categoryName" END;
  IF category IS NULL THEN
   SELECT min(p."categoryKey") INTO category FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true HAVING count(DISTINCT p."categoryKey")=1;
   IF category NOT IN ('word','trial','word_self_study') THEN RAISE EXCEPTION '语法写作开课需按学员确认';END IF;
@@ -47,8 +50,8 @@ BEGIN
  category_name=COALESCE(NULLIF(category_name,''),CASE category WHEN 'word' THEN '单词课' WHEN 'trial' THEN '体验课' WHEN 'word_self_study' THEN '单词自学课' WHEN 'primary_writing' THEN '小学语法写作课' WHEN 'middle_writing' THEN '中学语法写作课' WHEN 'high_writing' THEN '高中语法写作课' END);
  IF category IS NULL OR category NOT IN ('word','trial','word_self_study','primary_writing','middle_writing','high_writing') THEN RAISE EXCEPTION '开课分类尚未确认';END IF;
  IF COALESCE(b."enrollmentStatus",'pending')='pending' AND (COALESCE(b."sourceKey",'') !~ '^legacy[:\-]' OR NOT EXISTS(
- SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"=category)
- OR EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"<>category)) THEN RAISE EXCEPTION '历史开课分类需管理员核实';END IF;
+ SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND (p."categoryKey"=category OR (legacy_trial AND b."categoryKey" IS NULL AND p."categoryKey"='word')))
+ OR EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"<>category AND NOT (legacy_trial AND b."categoryKey" IS NULL AND p."categoryKey"='word'))) THEN RAISE EXCEPTION '历史开课分类需管理员核实';END IF;
  END IF;
  IF EXISTS(SELECT 1 FROM "TeachingCourseCategory" tc WHERE tc.company=co AND tc.key=category AND tc.active=false) THEN RAISE EXCEPTION '该课程分类已停用';END IF;
  minutes=COALESCE(a."durationMinutes",CASE a.dslx WHEN '1' THEN 30 ELSE 60 END);
@@ -72,7 +75,11 @@ BEGIN
  SELECT * INTO existing FROM "CourseCreditReservation" WHERE company=co AND "appointmentId"=aid AND state IN ('reserved','consumed') FOR UPDATE;
  IF FOUND AND (existing.state IS DISTINCT FROM 'reserved' OR existing."studentId"::text IS DISTINCT FROM a.szyh::text OR existing."creditCosts"::jsonb IS DISTINCT FROM costs) THEN RAISE EXCEPTION '现有课时凭据冲突,请管理员核实';END IF;
  IF NOT notes_legacy AND (COALESCE(b."enrollmentStatus",'pending')='pending' OR b."categoryKey" IS NULL) THEN
-  UPDATE "CourseBinding" SET "enrollmentStatus"='active',"categoryKey"=category,"categoryName"=category_name,"updatedAt"=now() WHERE "objectId"=b."objectId";changed=true;
+  -- A legacy trial may use a shared word textbook; preserve its word classification for later ordinary lessons.
+  UPDATE "CourseBinding" SET "enrollmentStatus"='active',
+   "categoryKey"=CASE WHEN legacy_trial AND b."categoryKey" IS NULL AND EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"='word') THEN 'word' ELSE category END,
+   "categoryName"=CASE WHEN legacy_trial AND b."categoryKey" IS NULL AND EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=co AND p."courseId"::text=course AND p.confirmed=true AND p."categoryKey"='word') THEN '单词课' ELSE category_name END,
+   "updatedAt"=now() WHERE "objectId"=b."objectId";changed=true;
  END IF;
  IF a."bindingId" IS DISTINCT FROM b."objectId" OR a.kcid::text IS DISTINCT FROM course OR a."courseCategoryKey" IS NULL OR a."teacherPaySnapshot" IS NULL OR COALESCE(jsonb_array_length(a."creditCostsSnapshot"::jsonb),0)=0 OR a."creditHoldStatus" IS DISTINCT FROM 'ready' THEN
   UPDATE "CourseAppointment" SET "bindingId"=b."objectId",kcid=course,"courseCategoryKey"=category,"courseCategoryName"=COALESCE(NULLIF("courseCategoryName",''),category_name),

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

@@ -371,6 +371,19 @@ BEGIN
  RETURN jsonb_build_object('targetId',aid,'restored',restore);
 END $$;
 
+-- Bulk operations may only cancel appointments that have not started, even if the page became stale.
+CREATE OR REPLACE FUNCTION xs_schedule_cancel_unstarted(co text,aid text,actor text,reason text,expected text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE current_status text;
+BEGIN
+ IF COALESCE(expected,'')='' THEN RAISE EXCEPTION '缺少预约版本,请刷新后重试';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT a.dszt::text INTO current_status FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId"
+ WHERE c.company=co AND c."objectId"=aid AND c."modelId"=54 AND COALESCE(c.status,99)<>-2 FOR UPDATE OF a;
+ IF current_status IS DISTINCT FROM '0' THEN RAISE EXCEPTION '仅未开始的预约可批量取消'; END IF;
+ RETURN xs_schedule_cancel(co,aid,false,actor,reason,expected);
+END $$;
+REVOKE ALL ON FUNCTION xs_schedule_cancel_unstarted(text,text,text,text,text) FROM PUBLIC;
+
 CREATE OR REPLACE FUNCTION xs_start_appointment(co text,aid text) RETURNS void LANGUAGE plpgsql AS $$
 DECLARE a "CourseAppointment";
 BEGIN

+ 14 - 0
scripts/tests/batch-cancel-guard.test.mjs

@@ -0,0 +1,14 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
+test('批量取消要求未开始且版本匹配,防止选择后已上课的预约被取消',async()=>{
+ const db=new PGlite();try{
+  await db.exec(`CREATE TABLE "CommonModel"(company text,"objectId" text,"modelId" numeric,"itemId" numeric,"updatedAt" timestamptz,status numeric);
+   CREATE TABLE "CourseAppointment"(company text,id numeric,dszt text);
+   INSERT INTO "CommonModel" VALUES('co','one',54,1,'2026-11-04 10:00+08',99),('co','active',54,2,'2026-11-04 10:00+08',99);
+   INSERT INTO "CourseAppointment" VALUES('co',1,'0'),('co',2,'10');
+   CREATE FUNCTION xs_schedule_cancel(co text,aid text,restore boolean,actor text,reason text,expected text) RETURNS jsonb LANGUAGE plpgsql AS $$DECLARE v timestamptz;BEGIN SELECT "updatedAt" INTO v FROM "CommonModel" WHERE company=co AND "objectId"=aid;IF date_trunc('milliseconds',v)<>expected::timestamptz THEN RAISE EXCEPTION '预约已变更';END IF;UPDATE "CommonModel" SET status=-2 WHERE company=co AND "objectId"=aid;RETURN jsonb_build_object('targetId',aid);END$$;`);
+  const src=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8'),def=src.match(/CREATE OR REPLACE FUNCTION xs_schedule_cancel_unstarted\([\s\S]*?\$\$;/)?.[0];assert.ok(def,'缺少未开始预约保护函数');await db.exec(def);
+  const run=(id,version='2026-11-04T02:00:00.000Z')=>db.query('SELECT xs_schedule_cancel_unstarted($1,$2,$3,$4,$5)',['co',id,'manager','批量调整',version]);
+  await assert.rejects(run('active'),/未开始/);await assert.rejects(run('one',''),/版本/);await assert.rejects(run('one','2026-11-03'),/变更/);
+  await run('one');assert.equal((await db.query('SELECT status FROM "CommonModel" WHERE "objectId"=\'one\'')).rows[0].status,'-2');assert.equal((await db.query('SELECT status FROM "CommonModel" WHERE "objectId"=\'active\'')).rows[0].status,'99');
+ }finally{await db.close();}
+});

+ 35 - 0
scripts/tests/legacy-appointment-preparation.test.mjs

@@ -60,3 +60,38 @@ test('旧阅读写作预约经逐人确认高中后免词库核验,保留原
  assert.equal((await prepare(id)).changed,false);
 });
 test('旧阅读写作未确认学段不得自动预留',async()=>{const id=await fixture({kcid:'398',yykcid:'398'});await assert.rejects(prepare(id),/单词|分类|课程/);});
+test('旧系统体验课按体验课时预留,不按词库的普通课分类扣费',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":1,"UserPoint":0,"Purse":0,"SilverCoin":0}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"categoryName"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding';`);
+  const id=await fixture({dslx:'3'}),before=await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\'');
+  const first=await prepare(id),a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]);
+  assert.equal(first.changed,true);assert.equal(a.courseCategoryKey,'trial');assert.equal(a.creditHoldStatus,'ready');assert.equal(Number(a.teacherPaySnapshot),30);assert.deepEqual(a.creditCostsSnapshot.map(c=>[c.account,Number(c.amount)]),[['UserExp',1]]);
+  assert.equal((await one('SELECT "categoryKey" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).categoryKey,'word');assert.deepEqual(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\''),before);
+  assert.equal((await prepare(id)).changed,false);assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1);
+ }finally{await db.exec('ROLLBACK');}
+});
+
+test('旧体验课可复用已开通的普通词库,不重写词库分类且准备幂等',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":1,"UserPoint":0,"Purse":0,"SilverCoin":0}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"='word',"categoryName"='单词课',"enrollmentStatus"='active' WHERE "objectId"='binding'`);
+  const id=await fixture({dslx:'3'});assert.equal((await prepare(id)).changed,true);assert.equal((await prepare(id)).changed,false);
+  const b=await one(`SELECT "categoryKey","enrollmentStatus" FROM "CourseBinding" WHERE "objectId"='binding'`);assert.equal(b.categoryKey,'word');assert.equal(b.enrollmentStatus,'active');
+ }finally{await db.exec('ROLLBACK');}
+});
+test('体验课时不足时拒绝预留,且不激活原有开课记录',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":0,"UserPoint":30,"Purse":30,"SilverCoin":30}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding'`);
+  const id=await fixture({dslx:'3'});await db.exec('SAVEPOINT insufficient');await assert.rejects(prepare(id),/UserExp/);await db.exec('ROLLBACK TO SAVEPOINT insufficient');
+  assert.equal((await one(`SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"='binding'`)).enrollmentStatus,null);assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,0);
+ }finally{await db.exec('ROLLBACK');}
+});
+
+test('先准备体验课再准备同教材普通课,各自使用正确课时且不改写共享词库分类',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":1,"UserPoint":100,"Purse":0,"SilverCoin":100}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"categoryName"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding'`);
+  const trial=await fixture({dslx:'3'});await prepare(trial);
+  const normal=await fixture({dslx:'2'});await prepare(normal);
+  const a=await one('SELECT "courseCategoryKey","creditCostsSnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[normal.slice(0,-1)]);
+  assert.equal(a.courseCategoryKey,'word');assert.deepEqual(a.creditCostsSnapshot.map(c=>c.account).sort(),['SilverCoin','UserPoint']);
+ }finally{await db.exec('ROLLBACK');}
+});