Преглед на файлове

fix(admin): reconcile uncertain cancellations and coalesce bulk refreshes

彭峰 преди 3 часа
родител
ревизия
0c206db7d5

+ 21 - 0
projects/xiaoshu-admin/src/app/appointment-cancellation.spec.ts

@@ -0,0 +1,21 @@
+import { cancelAppointmentWithReadback } from './appointment-cancellation';
+import { ScheduleAppointment } from './admin.models';
+describe('取消预约结果核对',()=>{
+ const row={targetId:'target',generalId:123,updatedAt:'version'} as ScheduleAppointment;
+ it('响应丢失但状态已取消时返回成功,只提交一次',async()=>{
+  const ops={mutate:jasmine.createSpy().and.rejectWith(Error('连接中断')),list:jasmine.createSpy().and.resolveTo({items:[{targetId:'other',cancelled:true},{targetId:'target',cancelled:true}]})};
+  await expectAsync(cancelAppointmentWithReadback(ops as any,row,'调整',true)).toBeResolved();
+  expect(ops.mutate).toHaveBeenCalledTimes(1);expect(ops.list).toHaveBeenCalledWith('appointments',1,100,'123',{});
+ });
+ it('目标未取消时保留原错误,不重发写入',async()=>{
+  for(const items of [[],[{targetId:'other',cancelled:true}],[{targetId:'target',cancelled:false}]]){
+   const ops={mutate:jasmine.createSpy().and.rejectWith(Error('连接中断')),list:jasmine.createSpy().and.resolveTo({items})};
+   await expectAsync(cancelAppointmentWithReadback(ops as any,row,'调整',true)).toBeRejectedWithError('连接中断');expect(ops.mutate).toHaveBeenCalledTimes(1);
+  }
+ });
+ it('核对查询也失败时保留原错误,不重发写入',async()=>{
+  const ops={mutate:jasmine.createSpy().and.rejectWith(Error('连接中断')),list:jasmine.createSpy().and.rejectWith(Error('核对失败'))};
+  await expectAsync(cancelAppointmentWithReadback(ops as any,row,'调整',true)).toBeRejectedWithError('连接中断');
+  expect(ops.mutate).toHaveBeenCalledTimes(1);expect(ops.list).toHaveBeenCalledTimes(1);
+ });
+});

+ 21 - 0
projects/xiaoshu-admin/src/app/appointment-cancellation.ts

@@ -0,0 +1,21 @@
+import { ScheduleAppointment } from './admin.models';
+import { OperationsService } from './operations.service';
+
+/** A lost response is not proof of a failed write. Read back the exact target; never replay. */
+export async function cancelAppointmentWithReadback(
+  operations: Pick<OperationsService, 'mutate' | 'list'>,
+  row: ScheduleAppointment, reason: string, onlyUnstarted: boolean,
+): Promise<void> {
+  try {
+    await operations.mutate('ops/appointments/cancel', row.targetId, reason,
+      onlyUnstarted ? { onlyUnstarted: true } : {}, row.updatedAt);
+  } catch (error) {
+    if (row.generalId) {
+      try {
+        const result = await operations.list('appointments', 1, 100, String(row.generalId), {});
+        if (result.items.some(item => item['targetId'] === row.targetId && item['cancelled'] === true)) return;
+      } catch { /* Keep the original uncertain outcome if verification also fails. */ }
+    }
+    throw error;
+  }
+}

+ 18 - 0
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -122,4 +122,22 @@ describe('CloudFunctionsService', () => {
     tick(500);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(failure).toBeDefined();
   }));
 
+  it('coalesces batch mutation notifications until the batch ends',async()=>{
+    let changes=0;service.dataChanges$.subscribe(()=>changes++);
+    await service.batchDataChanges(async()=>{
+      for(let i=0;i<3;i++){
+        const pending=service.operations('ops/appointments/cancel');
+        http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({success:true,data:{}});
+        await pending;expect(changes).toBe(0);
+      }
+    });
+    expect(changes).toBe(1);
+  });
+
+  it('announces one final reconciliation even when a whole batch fails',async()=>{
+    let changes=0;service.dataChanges$.subscribe(()=>changes++);
+    await expectAsync(service.batchDataChanges(async()=>{throw Error('uncertain');})).toBeRejectedWithError('uncertain');
+    expect(changes).toBe(1);
+  });
+
 });

+ 18 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -40,7 +40,7 @@ export class CloudFunctionsService {
 
   // Explicitly allow only reads. Unknown operations and all submissions must never replay.
   private isReadOperation(operation: string): boolean {
-    return /^ops\/(?:users\/(?:list|detail|notes|audit)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/preflight)$/.test(operation);
+    return /^ops\/(?:users\/(?:list|detail|notes|audit)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/(?:preflight|list))$/.test(operation);
   }
 
   private transportMessage(readOnly: boolean): string {
@@ -88,7 +88,24 @@ export class CloudFunctionsService {
     return channel;
   }
 
+  private changeBatchDepth = 0;
+  private pendingDataChange = false;
+
+  async batchDataChanges<T>(work: () => Promise<T>): Promise<T> {
+    this.changeBatchDepth++;
+    try { return await work(); }
+    finally {
+      // Even a lost write response may have committed. Refresh once at the end.
+      this.pendingDataChange = true;
+      if (--this.changeBatchDepth === 0 && this.pendingDataChange) {
+        this.pendingDataChange = false;
+        this.announceDataChange();
+      }
+    }
+  }
+
   private announceDataChange(): void {
+    if (this.changeBatchDepth) { this.pendingDataChange = true; return; }
     this.dataChanges$.next('local');
     this.dataChannel?.postMessage({ changedAt: new Date().toISOString() });
   }

+ 4 - 0
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -278,6 +278,10 @@ export class OperationsService {
     return this.functions.operations<OperationsPage>(operation, { page, pageSize, search, filters });
   }
 
+  batchMutations<T>(work: () => Promise<T>): Promise<T> {
+    return this.functions.batchDataChanges(work);
+  }
+
   mutate<T>(operation: string, targetId: string, reason: string, payload: Record<string, unknown> = {}, expectedUpdatedAt = '', idempotencyKey = ''): Promise<T> {
     return this.functions.operations<T>(operation, { targetId, reason, payload, expectedUpdatedAt, idempotencyKey: idempotencyKey || crypto.randomUUID() });
   }

+ 12 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -225,6 +225,18 @@ describe('排课明细与取消', () => {
     expect(mutate).not.toHaveBeenCalled();
   });
 
+  it('取消成功后刷新失败,明确保留取消成功结论', async()=>{
+    await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers:[
+      {provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
+      {provide:OperationsService,useValue:{mutate:async()=>({})}},
+      {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}
+    ]}).compileComponents();
+    const page=TestBed.createComponent(OperationsScheduleComponent).componentInstance;
+    spyOn(page,'load').and.callFake(async()=>{page.error.set('运营云函数执行失败');});
+    page.openDetails({targetId:'a',updatedAt:'v',statusCode:0,cancelled:false,readOnly:false} as ScheduleAppointment);page.cancelReason.set('调整');
+    await page.cancelFromDetails();expect(page.message()).toBe('排课已取消');expect(page.error()).toContain('但课表刷新失败');expect(page.error()).toContain('无需再次取消');expect(page.detail()).toBeNull();
+  });
+
   it('对可写排课填写原因后调用取消接口并刷新', async () => {
     const mutate = jasmine.createSpy('mutate').and.resolveTo({});
     await TestBed.configureTestingModule({

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

@@ -1,3 +1,4 @@
+import { cancelAppointmentWithReadback } from '../appointment-cancellation';
 import { AdminSessionService } from '../admin-session.service';
 import { ScheduleBulkManagerComponent } from './schedule-bulk-manager.component';
 import { LessonRecordDetailComponent } from './lesson-record-detail.component';
@@ -178,8 +179,9 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
     if(!reason){this.detailError.set('请填写取消原因');return;}
     this.saving.set(true);this.detailError.set('');
     try{
-      await this.operations.mutate('ops/appointments/cancel',item.targetId,reason,{},item.updatedAt);
+      await cancelAppointmentWithReadback(this.operations,item,reason,false);
       this.detail.set(null);this.message.set('排课已取消');await this.load();
+      if(this.error())this.error.set('排课已取消,但课表刷新失败,请点击刷新重试;无需再次取消。');
     }catch(error){this.detailError.set(error instanceof Error?error.message:'取消排课失败');}
     finally{this.saving.set(false);}
   }
@@ -197,7 +199,7 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
     this.navigationSubscriptions.add(this.operations.dataChanges$?.subscribe(() => this.dataRevision++));
     this.navigationSubscriptions.add(this.route.queryParamMap?.pipe(skip(1)).subscribe(() => this.queueActivation()));
     void this.activateSchedule();
-    this.stopLiveRefresh=this.liveRefresh.watch(['courses','appointments','account'],()=>{if(this.loading()||this.saving()||this.searching())return false;void this.load(true);return true;});
+    this.stopLiveRefresh=this.liveRefresh.watch(['courses','appointments','account'],()=>{if(this.bulkManagerOpen()||this.loading()||this.saving()||this.searching())return false;void this.load(true);return true;});
   }
   onOperationsRouteAttach():void {
     this.attachedIntent = true;

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

@@ -5,7 +5,7 @@ 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}]});});
+ beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['list','mutate','batchMutations']);ops.batchMutations.and.callFake(async work=>work());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,'杨珍',{dateFrom:'',dateTo:''});await c.selectAllMatching();expect(c.selected().size).toBe(199);expect(c.selected().has('a199')).toBeFalse();expect(c.selected().has('a200')).toBeFalse();
@@ -59,4 +59,18 @@ describe('跨日期预约批量管理',()=>{
   const count=ops.list.calls.count();await c.selectAllMatching();expect(ops.list.calls.count()).toBe(count);
   spyOn(window,'confirm').and.returnValue(true);await c.cancelSelected();expect(ops.mutate).not.toHaveBeenCalled();
  });
+ 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);let running=0,max=0;
+  ops.mutate.and.callFake(async()=>{running++;max=Math.max(max,running);await Promise.resolve();running--;throw Error('连接中断');});
+  ops.list.and.resolveTo({items:rows.slice(0,2).map(r=>({...r,cancelled:true})),total:2} as any);
+  await c.cancelSelected();expect(max).toBe(1);expect(c.selected().size).toBe(0);expect(c.error()).toBe('');expect(c.message()).toContain('已取消 2 条');expect(ops.batchMutations).toHaveBeenCalledTimes(1);
+ });
+
+ it('全部结果不确定时仍通知父页核对,关闭窗口后再核对一次',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance;c.searchInput='杨珍';await c.searchAppointments();c.toggleRow(rows[0] as any);
+  spyOn(window,'confirm').and.returnValue(true);ops.mutate.and.rejectWith(Error('连接中断'));ops.list.and.rejectWith(Error('核对失败'));const changed=spyOn(c.changed,'emit');
+  await c.cancelSelected();expect(changed).toHaveBeenCalledTimes(1);expect(c.selected().size).toBe(1);c.close();expect(changed).toHaveBeenCalledTimes(2);
+ });
+
 });

+ 8 - 7
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.ts

@@ -1,3 +1,4 @@
+import { cancelAppointmentWithReadback } from '../appointment-cancellation';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -62,13 +63,13 @@ export class ScheduleBulkManagerComponent implements OnInit {
   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 worker=async()=>{while(index<items.length){const row=items[index++];try{await cancelAppointmentWithReadback(this.operations,row,reason,true);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 this.operations.batchMutations(async()=>{await 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.
+   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?new Date(x.row.startsAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):''}:${x.message}`).join(';')+(failed.length>5?';其余请刷新核对':''));
+   this.changed.emit();
+  });}finally{this.saving.set(false);}
+  // Unverified targets stay selected; only explicit user action can submit them again.
  }
- close():void{if(!this.saving()&&!this.selecting())this.closed.emit();}
+ close():void{if(!this.saving()&&!this.selecting()){this.closed.emit();if(this.progress().total>0&&this.selected().size)this.changed.emit();}}
 }