Răsfoiți Sursa

fix: correct scheduling checks and whole-day appointment validity

彭峰 16 ore în urmă
părinte
comite
81f47271c1

+ 6 - 5
projects/xiaoshu-admin/src/app/pages/operations-schedule-status.ts

@@ -35,13 +35,14 @@ const scheduleStatusLabels: Partial<Record<number, string>> = {
 };
 
 export function scheduleStatusTone(
-  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'statusUnverified'>,
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'statusUnverified'> & Partial<Pick<ScheduleAppointment, 'startsAt'>>,
   now = Date.now(),
 ): ScheduleStatusTone {
   if (item.cancelled) return 'status-cancelled';
   if (item.statusUnverified) return 'status-unverified';
-  const endTime = Date.parse(item.endsAt);
-  if ((item.statusCode === 0 || item.statusCode === 10) && Number.isFinite(endTime) && endTime < now) return 'status-overdue';
+  const scheduledTime = Date.parse(item.startsAt || item.endsAt);
+  const businessDay = (value: number) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
+  if ((item.statusCode === 0 || item.statusCode === 10) && Number.isFinite(scheduledTime) && businessDay(scheduledTime) < businessDay(now)) return 'status-overdue';
   if (item.statusCode === 0) return 'status-upcoming';
   if (item.statusCode === 10) return 'status-live';
   if (item.statusCode === 11) return 'status-coach-ended';
@@ -52,7 +53,7 @@ export function scheduleStatusTone(
 }
 
 export function scheduleStatusLabel(
-  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'statusLabel' | 'endsAt' | 'statusUnverified'>,
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'statusLabel' | 'endsAt' | 'statusUnverified'> & Partial<Pick<ScheduleAppointment, 'startsAt'>>,
   now = Date.now(),
 ): string {
   const tone = scheduleStatusTone(item, now);
@@ -63,7 +64,7 @@ export function scheduleStatusLabel(
 }
 
 export function scheduleMatchesStatus(
-  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'statusUnverified' | 'coachId' | 'coachAccountMissing'>,
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'statusUnverified' | 'coachId' | 'coachAccountMissing'> & Partial<Pick<ScheduleAppointment, 'startsAt'>>,
   filter: ScheduleStatusFilter,
   now = Date.now(),
 ): boolean {

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

@@ -99,6 +99,8 @@
   <div class="backdrop" (click)="closeDrawer()"></div>
   <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
     <header><div><p>SCHEDULE FORM</p><h2>{{editing()?'修改排课':'新增排课'}}</h2><span>搜索并确认学员、课程和陪练,再检查时间冲突</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
+    @if(error()){<p class="notice error" role="alert" data-testid="schedule-form-error">{{error()}}</p>}
+    <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>
     <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
@@ -164,7 +166,7 @@
         @for(item of previewCandidates();track item.index){<article [class.conflict]="!item.available"><i>{{item.index+1}}</i><div><strong>{{item.startsAt|date:'yyyy-MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</strong><span>{{item.available?'可以排课':item.reasons.join(';')}}</span></div><em>{{item.available?'可提交':'需调整'}}</em></article>}
       </div>
     }
-    <footer><button type="button" (click)="preflight()" [disabled]="saving()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
+    <footer><button type="button" (click)="preflight()" [disabled]="saving()||checking()">{{checking()?'正在检查…':'检查冲突'}}</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
   </aside>
 }
 @if(feedback();as report){

+ 51 - 6
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -41,16 +41,22 @@ describe('陪练排课泳道', () => {
 describe('排课状态视觉分类', () => {
   const now = Date.parse('2026-08-20T10:00:00+08:00');
 
-  it('将已经结束但仍待开始的预约统一识别为逾期未完成', () => {
+  it('超过预约结束时间后当天仍待开始', () => {
     const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z' };
-    expect(scheduleStatusTone(item, now)).toBe('status-overdue');
-    expect(scheduleStatusLabel(item, now)).toBe('逾期未完成');
+    expect(scheduleStatusTone(item, now)).toBe('status-upcoming');
+    expect(scheduleStatusLabel(item, now)).toBe('待开始');
   });
 
-  it('已开始但超过结束时间的记录使用同一个逾期状态', () => {
+  it('已开始的课程当天保持上课中', () => {
     const item = { cancelled: false, statusCode: 10, statusLabel: '上课中', endsAt: '2026-08-20T01:00:00.000Z' };
-    expect(scheduleStatusTone(item, now)).toBe('status-overdue');
-    expect(scheduleStatusLabel(item, now)).toBe('逾期未完成');
+    expect(scheduleStatusTone(item, now)).toBe('status-live');
+    expect(scheduleStatusLabel(item, now)).toBe('上课中');
+  });
+
+  it('北京时间次日零点才过期,跨午夜课程仍按预约开始日期计算', () => {
+    const item = { cancelled: false, statusCode: 0, startsAt: '2026-08-20T15:30:00Z', endsAt: '2026-08-20T16:30:00Z' };
+    expect(scheduleStatusTone(item, Date.parse('2026-08-20T15:59:59Z'))).toBe('status-upcoming');
+    expect(scheduleStatusTone(item, Date.parse('2026-08-20T16:00:00Z'))).toBe('status-overdue');
   });
 
   it('持续同步不可用时旧预约只标记待核对,不当作缺课', () => {
@@ -193,3 +199,42 @@ describe('排课数据加载提示', () => {
     expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('false');
   });
 });
+
+describe('排课表单预检', () => {
+  async function form(request: () => Promise<Record<string, unknown>>) {
+    await TestBed.configureTestingModule({
+      imports: [OperationsScheduleComponent],
+      providers: [
+        { provide: OperationsService, useValue: { request } },
+        { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
+      ],
+    }).compileComponents();
+    const fixture = TestBed.createComponent(OperationsScheduleComponent);
+    const component = fixture.componentInstance;
+    spyOn(component, 'load').and.resolveTo();
+    fixture.detectChanges();await fixture.whenStable();
+    component.openCreate();component.studentId.set(20);component.selectedMemberObjectId.set('member');component.courseId.set(100);
+    return { fixture, component };
+  }
+  it('预检失败直接显示在抽屉内,旧的成功结果不能继续保存', async () => {
+    let failed = false;
+    const { fixture, component } = await form(async () => { if(failed)throw Error('可用课时不足');return { availableCount:1, conflictCount:0 }; });
+    await component.preflight();expect(component.canSavePreview()).toBeTrue();
+    failed=true;await component.preflight();fixture.detectChanges();
+    expect(component.canSavePreview()).toBeFalse();
+    expect(fixture.nativeElement.querySelector('[data-testid="schedule-form-error"]').textContent).toContain('可用课时不足');
+  });
+  it('等待响应时改时间,返回的旧预检结果失效', async () => {
+    let finish!: (value: Record<string, unknown>) => void;
+    const { component } = await form(() => new Promise(resolve => { finish=resolve; }));
+    const pending=component.preflight();expect(component.checking()).toBeTrue();
+    component.updateStartTime('12:00');finish({ availableCount:1, conflictCount:0 });await pending;
+    expect(component.preview()).toBeNull();expect(component.canSavePreview()).toBeFalse();expect(component.checking()).toBeFalse();
+  });
+  it('修改排课正确还原北京时间,而不是 UTC 小时', async () => {
+    const { component } = await form(async()=>({}));
+    component.recurrence.set('weekly');component.occurrences.set(5);
+    component.openEdit({ startsAt:'2026-10-04T01:00:00Z',studentId:20,studentObjectId:'member',courseId:100,coachId:30,classType:2 } as ScheduleAppointment);
+    expect(component.date()).toBe('2026-10-04');expect(component.startTime()).toBe('09:00');expect(component.recurrence()).toBe('once');expect(component.occurrences()).toBe(1);
+  });
+});

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

@@ -128,7 +128,7 @@ export function scheduleCoachBusy(
 @Component({ selector:'app-operations-schedule', standalone:true, imports: [AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit, OnDestroy {
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly 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 bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null); readonly checking=signal(false);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService); private readonly liveRefresh=inject(OperationsLiveRefreshService);
   readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0);
@@ -142,10 +142,10 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
   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 initialRange=true; private saveKey=''; private saveFingerprint=''; private stopLiveRefresh?:()=>void; private loadRequest=0;
+  private preflightRequest=0; private previewFingerprint=''; private initialRange=true; private saveKey=''; private saveFingerprint=''; private stopLiveRefresh?:()=>void; private loadRequest=0;
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
   previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
-  canSavePreview():boolean{return Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
+  canSavePreview():boolean{return !this.checking()&&this.previewFingerprint===JSON.stringify([this.editing()?.targetId||'',this.payload()])&&Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
   saveButtonLabel():string{if(this.saving())return'保存中…';if(!this.preview())return'请先检查冲突';if(this.previewConflictCount()&&this.previewCandidates().length>1&&this.previewAvailableCount()>0)return`仅保存 ${this.previewAvailableCount()} 节无冲突排课`;return this.editing()?'确认修改':'确认保存';}
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
@@ -159,16 +159,16 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   async applyScheduleFilters():Promise<void>{if(this.searching())return;this.searching.set(true);this.scheduleSearch.set(this.scheduleSearchInput().trim());this.statusFilter.set(this.statusFilterInput());await new Promise(resolve=>setTimeout(resolve,220));this.searching.set(false);}
   clearScheduleFilters():void{this.scheduleSearchInput.set('');this.statusFilterInput.set('all');this.scheduleSearch.set('');this.statusFilter.set('all');this.searching.set(false);}
   visibleCoachCount(coachId:number):number{return this.visibleAppointments().filter(item=>item.coachId===coachId).length;}
-  openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
-  openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
-  closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
+  openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
+  openEdit(item:ScheduleAppointment):void{++this.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
+  closeDrawer():void{++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
   async viewFeedback(item:ScheduleAppointment):Promise<void>{if(item.readOnly){this.message.set('这条排课来自旧系统实时接口;完课反馈尚未同步到新库,请在原业务端查看。');return;}this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
   closeFeedback():void{this.feedback.set(null);}
   setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
   openSearch(field:SearchField):void{this.activeSearch.set(field);}
   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.openSearch('course');}
-  updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
+  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.activeSearch.set(courses.length?'course':null);}this.preview.set(null);}
   chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.bindingId.set(course.bindingId||'');this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该开课分类缺少有效课时规则,请在排课与工资标准中配置。');}
   chooseCoach(coach:ScheduleCalendarData['coaches'][number]):void{this.coachId.set(coach.userId);this.coachSearch.set(`${coach.displayName}${coach.mobileMasked?` · ${coach.mobileMasked}`:''}`);this.activeSearch.set(null);this.preview.set(null);}
@@ -181,7 +181,20 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   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()};}
-  async preflight():Promise<void>{this.error.set('');if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能排课;请先补齐该会员的迁移标识');return;}try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
+  async preflight():Promise<void>{
+    if(this.checking()||this.saving())return;
+    this.error.set('');this.preview.set(null);this.previewFingerprint='';
+    if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}
+    if(!this.studentId()){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{
+      const result=await this.operations.request<Record<string,unknown>>('ops/appointments/preflight',{targetId,payload});
+      if(request!==this.preflightRequest||!this.drawerOpen()||fingerprint!==JSON.stringify([this.editing()?.targetId||'',this.payload()]))return;
+      this.previewFingerprint=fingerprint;this.preview.set(result);
+    }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 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);}}
 }

Fișier diff suprimat deoarece este prea mare
+ 1 - 1
scripts/deploy-admin-functions.mjs


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

@@ -40,11 +40,27 @@ BEGIN
  IF NOT t=ANY(ARRAY['CreditTransaction','EnrollmentCommand','CourseBinding','CommonModel','LessonRecord','CourseAppointment','CourseCreditReservation','UserExpDomP','UserSIcon','UserExpHis','UserUserPoint','SysLog']) THEN RAISE EXCEPTION 'Invalid business table'; END IF;
  EXECUTE format('INSERT INTO %I SELECT * FROM jsonb_populate_record(NULL::%I,$1)',t,t) USING d;
 END $$;
+-- Appointment validity is the whole scheduled day in Asia/Shanghai, independent of duration.
+CREATE OR REPLACE FUNCTION xs_appointment_day(business_day text,scheduled_at text) RETURNS date LANGUAGE plpgsql IMMUTABLE AS $$
+DECLARE raw text=replace(trim(COALESCE(NULLIF(business_day,''),scheduled_at,'')),'/','-');
+BEGIN
+ IF raw ~ '^\d{8}$' THEN raw=substr(raw,1,4)||'-'||substr(raw,5,2)||'-'||substr(raw,7,2);END IF;
+ IF raw !~ '^\d{4}-\d{2}-\d{2}' THEN RETURN NULL;END IF;
+ IF NULLIF(business_day,'') IS NULL AND raw ~ '(Z|[+-]\d{2}:?\d{2})$' THEN RETURN (raw::timestamptz AT TIME ZONE 'Asia/Shanghai')::date;END IF;
+ RETURN left(raw,10)::date;
+EXCEPTION WHEN invalid_datetime_format OR datetime_field_overflow THEN RETURN NULL;
+END $$;
+CREATE OR REPLACE FUNCTION xs_appointment_valid_at(business_day text,scheduled_at text,at_time timestamptz DEFAULT now()) RETURNS boolean LANGUAGE sql STABLE AS $$
+ SELECT COALESCE(xs_appointment_day(business_day,scheduled_at)=(at_time AT TIME ZONE 'Asia/Shanghai')::date,false)
+$$;
 CREATE OR REPLACE FUNCTION xs_reserved(co text, uid numeric, account text, excluded text DEFAULT '') RETURNS numeric LANGUAGE sql STABLE AS $$
  SELECT COALESCE(sum((cost->>'amount')::numeric),0) FROM "CourseCreditReservation" r,
  LATERAL jsonb_array_elements(COALESCE(r."creditCosts"::jsonb,'[]')) cost
  WHERE r."company"=co AND r."studentId"=uid AND r."state"='reserved'
  AND COALESCE(r."appointmentId",'')<>excluded AND cost->>'account'=account
+ AND NOT EXISTS(SELECT 1 FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"=c."itemId"
+  WHERE c."company"=r."company" AND c."modelId"=54 AND c."objectId"=r."appointmentId"
+  AND (c."status"=-2 OR COALESCE(a."dszt"::text,'0') NOT IN ('0','10') OR (COALESCE(a."dszt"::text,'0')='0' AND xs_appointment_day(a."bxrq",a."yysj")<(now() AT TIME ZONE 'Asia/Shanghai')::date)))
 $$;
 CREATE OR REPLACE FUNCTION xs_ledger(co text, uid numeric, account text, delta numeric, before_value numeric, tx text, actor text, reason text, method text DEFAULT '') RETURNS void LANGUAGE plpgsql AS $$
 DECLARE t text; typ int;
@@ -258,6 +274,7 @@ CREATE OR REPLACE FUNCTION xs_schedule_save(co text,actor text,req jsonb,entries
 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
  IF length(idem)<8 OR jsonb_array_length(entries)=0 THEN RAISE EXCEPTION '排课请求无效';END IF;
+ IF edit_id<>'' AND jsonb_array_length(entries)<>1 THEN RAISE EXCEPTION '修改排课仅支持单节课程';END IF;
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT * INTO old FROM "EnrollmentCommand" WHERE "company"=co AND "idempotencyKey"=idem;
  IF FOUND THEN IF old."request"<>req THEN RAISE EXCEPTION '请求编号已用于其他排课操作';END IF;RETURN old."result";END IF;
@@ -331,6 +348,7 @@ BEGIN
  PERFORM xs_validate_appointment(co,aid);
  SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c."company"=a0."company" AND c."modelId"=54 AND c."itemId"=a0."id" WHERE c."company"=co AND c."objectId"=aid FOR UPDATE OF a0;
  IF a."dszt"::text NOT IN ('0','10') THEN RAISE EXCEPTION '预约状态已变更';END IF;
+ IF a."dszt"::text='0' AND NOT xs_appointment_valid_at(a."bxrq",a."yysj") THEN RAISE EXCEPTION '课程仅在预约当天 00:00 至 24:00 有效,请调整排课日期';END IF;
  IF COALESCE(NULLIF(a."pl"::text,''),'0')='0' THEN RAISE EXCEPTION '请先分配教师';END IF;
  UPDATE "CourseAppointment" SET "dszt"='10',"kssj"=COALESCE(NULLIF("kssj",''),now()::text),"updatedAt"=now() WHERE "objectId"=a."objectId";
 END $$;

+ 14 - 1
scripts/tests/member-enrollment-gateway.test.mjs

@@ -14,7 +14,12 @@ test('同一内容有多个开课分类时旧请求拒绝,指定 bindingId 匹
 test('开课请求成功后的重试复用结果,不重新校验已经耗尽的余额',async()=>{const c=setup(),req={uid:20,category:'word',courseIds:[100],actor:'a',reason:'正式开课'};c.enrollmentOptions=async()=>{throw Error('不应重新计算资格');};c.Psql={oneOrNone:async()=>({request:req,result:{bindingIds:['b'],count:1}}),one:async(sql,args)=>({same:JSON.stringify(JSON.parse(args[0]))===JSON.stringify(JSON.parse(args[1]))})};assert.deepEqual(plain(await c.saveEnrollment({current:{id:'a'}},{reason:'正式开课',idempotencyKey:'test-0001'},{studentId:20,categoryKey:'word',courseIds:[100]})),{bindingIds:['b'],count:1});});
 test('批量排课跨规则日期分别保存快照',async()=>{const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.enrollmentBindings=async()=>[{bindingId:'b',courseId:100,categoryKey:'trial'}];c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{UserExp:10,UserPoint:10}});const result=await c.preflightAppointments({}, {},{studentId:20,courseId:100,bindingId:'b',durationMinutes:60,deliveryMode:'online',date:'2026-09-08',startTime:'10:00',recurrence:'daily',occurrences:2});assert.deepEqual(plain(result.candidates.map(r=>r.teachingRule.teacherPay)),[40,30]);assert.equal(result.creditAvailability.find(r=>r.account==='UserExp').required,2);});
 test('新后台列表按学员精确查询,筛选发生在全站记录上限之前',async()=>{const c=setup();const calls=[];c.Psql={query:async(sql,args)=>{calls.push([sql,args]);return[];}};for(const kind of ['course-bindings','appointments','lessons','learning-records'])await c.businessRows(kind,{}, {studentId:20});await c.moneyRows({}, {studentId:20});for(const [sql,args] of calls){assert.equal(args[1],'20');assert.ok(sql.indexOf('$2')<sql.indexOf('LIMIT 5000'));}});
-test('旧同步不能覆盖账号、开课和预约,跨分类进度查询不得随机选择第一条',async()=>{const c=setup();await assert.rejects(c.applyUserSync({}, {},{}),/禁止旧数据覆盖/);for(const kind of ['users','course-bindings','appointments','lessons'])await assert.rejects(c.applyProjectionRecord({},'upsert',{}, {},kind),/禁止旧数据覆盖/);});
+test('旧同步不能覆盖账号或云端创建的开课预约,未授权投影被拒绝',async()=>{
+ const c=setup();await assert.rejects(c.applyUserSync({}, {},{}),/禁止旧数据覆盖/);
+ for(const dataset of ['course-bindings','appointments','lessons'])assert.throws(()=>c.assertLegacyProjectionWritable({get:key=>key==='sourceKey'?'cloud:test':''},dataset),/未覆盖新数据/);
+ await assert.rejects(c.applyProjectionRecord({},'upsert',{}, {},'users'),/禁止旧数据覆盖/);
+ for(const kind of ['course-bindings','appointments','lessons'])await assert.rejects(c.applyProjectionRecord({},'upsert',{}, {},kind),/未授权的 Parse 投影/);
+});
 test('用户端通用内容接口不能绕过开课、预留及完课事务',async()=>{const c={};vm.createContext(c);vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c);for(const modelId of [54,58,59])await assert.rejects(c.createContentPair({content:JSON.stringify({modelId}),addon:'{}'},{}),/正式完课流程/);});
 
 test('会员预约详情沿用开课分类并识别回收站取消状态',async()=>{const c=setup();c.Psql={query:async()=>[{targetId:'a',generalId:1,commonStatus:-2,szyh:'20',pl:'30',dslx:'2',dszt:'0',yysj:'2026-09-09 10:00',courseCategoryKey:'word_self_study',courseCategoryName:'单词自学课',bindingId:'b',durationMinutes:60}]};const page=await c.businessRows('appointments',{}, {studentId:20,page:1,pageSize:20});assert.equal(page.items[0].bindingId,'b');assert.equal(page.items[0].classTypeLabel,'单词自学课·60分钟');assert.equal(page.items[0].statusLabel,'已取消');});
@@ -31,3 +36,11 @@ test('会员概览未结束数、体验记录与下次预约排除已取消,
   assert.equal(row.appointment_count,3);assert.equal(row.unfinished,1);assert.equal(row.trial_count,0);assert.equal(row.next_at,'2099-02-01');assert.equal(row.abnormal,0);
  }finally{await db.close();}
 });
+
+test('同日每半小时重复的60分钟课程提前识别批内重叠,可跳过冲突项',async()=>{
+ const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.enrollmentBindings=async()=>[{bindingId:'b',courseId:100,categoryKey:'word'}];c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
+ const payload={studentId:20,courseId:100,bindingId:'b',durationMinutes:60,deliveryMode:'online',date:'2026-10-04',startTime:'09:00',recurrence:'same_day_30',occurrences:3};
+ const result=await c.preflightAppointments({}, {},payload);
+ 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'),/单节/);
+});

+ 37 - 2
scripts/tests/member-enrollment.test.mjs

@@ -106,7 +106,7 @@ test('双流水或审计失败时总部入账与业务单全部回滚',async()=>
 });
 test('最后一节课的竞争提交仅一张预约成功,完课后重试原开课仍成功',async()=>{
  const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
- const results=await Promise.allSettled([saveSchedule([scheduleEntry(b,'2031-01-01 10:00')],'last-credit-01'),saveSchedule([scheduleEntry(b,'2031-01-02 10:00')],'last-credit-02')]);
+ const today=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});const results=await Promise.allSettled([saveSchedule([scheduleEntry(b,today+' 10:00')],'last-credit-01'),saveSchedule([scheduleEntry(b,today+' 11:00')],'last-credit-02')]);
  assert.equal(results.filter(r=>r.status==='fulfilled').length,1);const aid=results.find(r=>r.status==='fulfilled').value.targetIds[0];
  await query('SELECT xs_start_appointment(\'co\',$1)',[aid]);assert.equal((await one('SELECT "dszt" FROM "CourseAppointment" a JOIN "CommonModel" c ON c."itemId"=a."id" AND c."modelId"=54 WHERE c."objectId"=$1',[aid])).dszt,'10');
  await query('SELECT xs_complete_appointment(\'co\',$1,\'teacher\')',[aid]);assert.equal(await balance('student'),0);
@@ -120,7 +120,7 @@ test('运营网关开课排课与教师网关完课联调,共用同一事务
  await post('fund','store',1,'integration-fund','UserExp');await post('transfer','student',1,'integration-transfer','UserExp');
  const context={current:{id:'admin'}},result=await c.saveEnrollment(context,{reason:'联调开课',idempotencyKey:'integration-enroll'},{studentId:20,categoryKey:'trial',courseIds:[100]});
  assert.equal(result.count,1);const bid=result.bindingIds[0];
- const saved=await c.saveEnrollmentSchedule(context,{reason:'联调排课',idempotencyKey:'integration-schedule'},{studentId:20,coachId:30,bindingId:bid,courseId:100,durationMinutes:60,deliveryMode:'online',date:'2032-01-01',startTime:'10:00',recurrence:'once',occurrences:1});
+ const saved=await c.saveEnrollmentSchedule(context,{reason:'联调排课',idempotencyKey:'integration-schedule'},{studentId:20,coachId:30,bindingId:bid,courseId:100,durationMinutes:60,deliveryMode:'online',date:new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),startTime:'12:00',recurrence:'once',occurrences:1});
  const aid=saved.targetIds[0],row=await one('SELECT a.*,c."generalId" FROM "CourseAppointment" a JOIN "CommonModel" c ON c."itemId"=a."id" AND c."modelId"=54 WHERE c."objectId"=$1',[aid]);assert.equal(row.courseCategoryKey,'trial');assert.equal(Number(row.teacherPaySnapshot),30);
  const app={Date,Psql:{query,one,oneOrNone:one,none:query},DEFAULT_COMPANY_ID:'co',number:Number,fail:(_,msg)=>{throw Error(msg);},memoryReviewsForOrder:async()=>({count:0,created:[]})};vm.createContext(app);vm.runInContext(readFileSync(new URL('../cloud/member-enrollment-app.js',import.meta.url),'utf8'),app);
  await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,10);await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,11);
@@ -146,3 +146,38 @@ test('已有唯一约束下重复迁移,冲突历史绑定保留进度并标
  const history=await query('SELECT * FROM "CourseBinding" WHERE "objectId" IN (\'history1\',\'history2\') ORDER BY "objectId"');assert.deepEqual(history.map(r=>r.enrollmentStatus),['pending','pending']);assert.deepEqual(history.map(r=>Number(r.yxx)),[5,7]);
  assert.equal(Number((await one('SELECT "nodeId" FROM "CommonModel" WHERE "objectId"=\'hc1\'')).nodeId),28);
 });
+
+test('课程全天有效:当天零点、提前与延后均可,前一天与次日零点不可',async()=>{
+ for(const [at,valid] of [['2026-10-03T23:59:59+08:00',false],['2026-10-04T00:00:00+08:00',true],['2026-10-04T08:00:00+08:00',true],['2026-10-04T23:59:59+08:00',true],['2026-10-05T00:00:00+08:00',false]]){
+  assert.equal((await one('SELECT xs_appointment_valid_at($1,$2,$3::timestamptz) AS valid',['2026-10-04','2026-10-04 09:00',at])).valid,valid,at);
+ }
+ assert.equal((await one("SELECT xs_appointment_day('', '2026-10-03T16:30:00Z')::text AS day")).day,'2026-10-04');
+});
+test('教师不能提前到预约前一天开课,拒绝后不写入开课状态',async()=>{
+ const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
+ const saved=await saveSchedule([scheduleEntry(b,'2098-10-04 09:00')],'future-day-test'),aid=saved.targetIds[0];
+ await assert.rejects(query('SELECT xs_start_appointment(\'co\',$1)',[aid]),/预约当天/);
+ assert.equal((await one('SELECT a."dszt" 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])).dszt,'0');
+ await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试清理\')',[aid]);
+});
+test('当天未开课继续预留课时,昨日未开课不再占用,已开课保留至完课',async()=>{
+ await db.exec('BEGIN');
+ try{
+  await db.exec('ALTER TABLE "CourseCreditReservation" ADD COLUMN "releasedAt" timestamptz,ADD COLUMN "releaseReason" text');
+  const today=(await one("SELECT (now() AT TIME ZONE 'Asia/Shanghai')::date::text AS day")).day;
+  const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
+  const saved=await saveSchedule([scheduleEntry(b,today+' 00:00')],'expiry-hold-test'),aid=saved.targetIds[0];
+  assert.equal(Number((await one("SELECT xs_reserved('co',20,'Purse') AS reserved")).reserved),1);
+  const {default:vm}=await import('node:vm'),src=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8'),ctx={Date,Psql:{query}};
+  vm.createContext(ctx);vm.runInContext(src.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],ctx);ctx.companyIdOf=()=> 'co';
+  await ctx.releaseExpiredReservations({},{});
+  assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
+  await query('UPDATE "CourseAppointment" SET "bxrq"=((now() AT TIME ZONE \'Asia/Shanghai\')::date-1)::text WHERE "bindingId"=$1 AND "dszt"=\'0\'',[b]);
+  assert.equal(Number((await one("SELECT xs_reserved('co',20,'Purse') AS reserved")).reserved),0);
+  await query('UPDATE "CourseAppointment" SET "dszt"=\'10\' WHERE "bindingId"=$1 AND "dszt"=\'0\'',[b]);
+  assert.equal(Number((await one("SELECT xs_reserved('co',20,'Purse') AS reserved")).reserved),1);
+  await ctx.releaseExpiredReservations({},{});assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
+  await query('UPDATE "CourseAppointment" SET "dszt"=\'0\' WHERE "bindingId"=$1 AND "dszt"=\'10\'',[b]);
+  await ctx.releaseExpiredReservations({},{});assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'released');
+ }finally{await db.exec('ROLLBACK');}
+});

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff