operations-payroll.component.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { TeachingCourseCategory, TeachingRule } from '../admin.models';
  2. import { TEACHING_STAGE_OPTIONS, teachingRateMatrix } from './operations-payroll.component';
  3. describe('operations payroll teaching standards', () => {
  4. it('uses the three standard school stages when presenting database course ancestry', () => {
  5. expect(TEACHING_STAGE_OPTIONS.map((stage) => stage.name)).toEqual(['小学', '初中', '高中']);
  6. });
  7. it('builds the current online and offline price matrix without using an older version', () => {
  8. const categories = [
  9. { key: 'trial', name: '体验训练', sortOrder: 10, active: true },
  10. { key: 'word_self_study', name: '单词自学课', sortOrder: 20, active: true },
  11. ] as TeachingCourseCategory[];
  12. const rule = (categoryKey: string, deliveryMode: 'online' | 'offline', teacherPay: number, effectiveFrom: string): TeachingRule => ({
  13. objectId: `${categoryKey}:${deliveryMode}:${effectiveFrom}`,
  14. categoryKey,
  15. categoryName: categories.find((category) => category.key === categoryKey)?.name || categoryKey,
  16. durationMinutes: 60,
  17. deliveryMode,
  18. deliveryLabel: deliveryMode === 'online' ? '线上' : '线下',
  19. teacherPay,
  20. creditCosts: [],
  21. effectiveFrom,
  22. active: true,
  23. updatedAt: '',
  24. });
  25. const matrix = teachingRateMatrix([
  26. rule('trial', 'online', 40, '2026-09-01'),
  27. rule('trial', 'online', 30, '2026-09-09'),
  28. rule('trial', 'offline', 40, '2026-09-09'),
  29. rule('word_self_study', 'online', 20, '2026-09-09'),
  30. rule('word_self_study', 'offline', 30, '2026-09-09'),
  31. ], categories, '2026-09-09');
  32. expect(matrix.map((row) => [row.categoryName, row.durationMinutes, row.online?.teacherPay, row.offline?.teacherPay])).toEqual([
  33. ['体验训练', 60, 30, 40],
  34. ['单词自学课', 60, 20, 30],
  35. ]);
  36. });
  37. });
  38. import { TestBed } from '@angular/core/testing';
  39. import { OperationsPayrollComponent } from './operations-payroll.component';
  40. import { OperationsService } from '../operations.service';
  41. import { AdminSessionService } from '../admin-session.service';
  42. import { PayrollOverview, PayrollSummary } from '../admin.models';
  43. describe('payroll interactions', () => {
  44. let component:OperationsPayrollComponent;
  45. let service:jasmine.SpyObj<OperationsService>;
  46. const overview=(month:string)=>({month,lines:[],coachItems:[],sourceStatus:{available:true,mode:'parse'},stores:[]} as unknown as PayrollOverview);
  47. beforeEach(()=>{
  48. service=jasmine.createSpyObj('OperationsService',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate','payrollDetail']);
  49. service.payrollOverview.and.resolveTo(overview('2026-09'));service.payrolls.and.resolveTo({items:[],total:0,page:1,pageSize:20});service.payRates.and.resolveTo({items:[],total:0,page:1,pageSize:100});
  50. service.teachingStandards.and.resolveTo({categories:[],rules:[],mappings:[],summary:{courseCount:0,confirmedCount:0,unmappedCount:0,activeRuleCount:0},refreshedAt:''});service.request.and.resolveTo({items:[]});
  51. TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:service},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});
  52. component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
  53. });
  54. it('loads exact coach details and ignores a late response after switching teacher',async()=>{
  55. let old!:(v:PayrollOverview)=>void;const current={...overview('2026-09'),payableAmount:70,lines:[{coachId:2,amount:70}]} as PayrollOverview;
  56. service.payrollOverview.and.returnValues(new Promise(r=>old=r),Promise.resolve(current));
  57. const first=component.openCoach({coachId:1,coachName:'one'});expect(component.coachLoading()).toBeTrue();await component.openCoach({coachId:2,coachName:'two'});old(overview('2026-08'));await first;
  58. expect(component.overview()).toBe(current);expect(component.coachLines().length).toBe(1);expect(component.coachLoading()).toBeFalse();expect(service.payrollOverview.calls.mostRecent().args[5]).toEqual({lineCoachId:2});
  59. });
  60. it('keeps failed details explicit and never replaces overview after closing drawer',async()=>{
  61. service.payrollOverview.and.rejectWith(new Error('明细服务暂不可用'));await component.openCoach({coachId:1,coachName:'one'});expect(component.coachError()).toContain('暂不可用');expect(component.coachLoading()).toBeFalse();
  62. let finish!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValue(new Promise(r=>finish=r));const request=component.openCoach({coachId:1,coachName:'one'});component.closeDrawer();finish(overview('2026-08'));await request;expect(component.overview()).toBeNull();expect(component.selectedCoachId()).toBe(0);
  63. });
  64. it('refetches an open coach when an older in-flight page load applies its summary',async()=>{
  65. let finish!:(v:PayrollOverview)=>void;const current={...overview('2026-09'),lines:[{coachId:2,amount:70}]} as PayrollOverview;
  66. service.payrollOverview.and.returnValues(new Promise(r=>finish=r),Promise.resolve(current),Promise.resolve(current));
  67. const load=component.load();await component.openCoach({coachId:2,coachName:'two'});finish(overview('2026-09'));await load;await Promise.resolve();
  68. expect(component.coachLines().length).toBe(1);expect(service.payrollOverview.calls.count()).toBe(3);
  69. });
  70. it('preserves the active batch drawer on background refresh',async()=>{
  71. const batch={objectId:'batch',lines:[]} as never;component.detail.set(batch);await component.load(false,true);expect(component.detail()).toBe(batch);
  72. });
  73. it('releases batch loading when a coach supersedes the pending batch request',async()=>{
  74. component.loading.set(false);let finish!:(v:any)=>void;service.payrollDetail.and.returnValue(new Promise(r=>finish=r));
  75. const request=component.open({objectId:'batch'} as PayrollSummary);expect(component.batchLoading()).toBeTrue();await component.openCoach({coachId:2,coachName:'two'});finish({objectId:'batch',lines:[]});await request;
  76. expect(component.loading()).toBeFalse();expect(component.batchLoading()).toBeFalse();expect(component.detail()).toBeNull();
  77. });
  78. it('ignores an older query response after month changes',async()=>{
  79. let resolveOld!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValues(new Promise(r=>resolveOld=r),Promise.resolve(overview('2026-10')));
  80. const old=component.load();component.month.set('2026-10');await component.load();resolveOld(overview('2026-09'));await old;expect(component.overview()?.month).toBe('2026-10');
  81. });
  82. it('reset keeps month and clears search, store and the teacher deep link',async()=>{
  83. component.month.set('2026-08');component.storeId.set(10);component.search.set('老师');component.rewardCoachId.set(30);component.openRewards();await component.resetFilters();expect(component.rewardFilters()).toEqual(jasmine.objectContaining({month:'2026-08',storeId:0,search:'',coachId:0,resetRevision:1}));
  84. });
  85. it('selects payroll months through the shared calendar-style picker',()=>{
  86. component.month.set('2026-09');component.message.set('2026年9月全公司费用草稿已生成');component.detail.set({objectId:'old-month'} as never);const event=new Event('click');spyOn(event,'stopPropagation');component.toggleMonthPicker(event);expect(component.monthPickerOpen()).toBeTrue();expect(component.monthPickerYear()).toBe(2026);component.shiftMonthPickerYear(1,event);component.selectMonth(0);expect(component.month()).toBe('2027-01');expect(component.monthLabel()).toBe('2027年1月');expect(component.monthPickerOpen()).toBeFalse();expect(component.message()).toBe('');expect(component.detail()).toBeNull();
  87. });
  88. it('retains adjustment request identity after a lost response and sends batch version',async()=>{
  89. component.detail.set({objectId:'batch',version:3,status:'draft'} as never);component.adjustmentCoachId.set(30);component.adjustmentAmount.set(10);component.adjustmentReason.set('补计费用');service.mutate.and.rejectWith(new Error('网络中断'));
  90. await component.addAdjustment();await component.addAdjustment();const calls=service.mutate.calls.allArgs();expect(calls.length).toBe(2);expect(calls[0][5]).toBe(calls[1][5]);expect(calls[0][3]).toEqual(jasmine.objectContaining({version:3,amount:10,coachId:30}));
  91. });
  92. it('does not submit an adjustment twice while saving',async()=>{
  93. component.saving.set(true);await component.addAdjustment();expect(service.mutate).not.toHaveBeenCalled();
  94. });
  95. it('loads batch pages independently of income and teacher filters',async()=>{
  96. await component.loadBatchPage(2);expect(service.payrolls).toHaveBeenCalledWith(2,20,'');component.allBatchMonths.set(false);component.month.set('2026-08');await component.loadBatchPage(1);expect(service.payrolls).toHaveBeenCalledWith(1,20,'2026-08');
  97. });
  98. it('keeps successful regions when a secondary request fails',async()=>{
  99. service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用'));
  100. await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('费用标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
  101. });
  102. it('explains missing writing evidence before submitting a mapping',async()=>{
  103. const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:'',updatedAt:'2026-09-02T12:00:00.000Z'};
  104. await component.saveWritingMapping(row);
  105. expect(service.mutate).not.toHaveBeenCalled();
  106. expect(component.error()).toContain('请填写“灵灵”的核对依据');
  107. });
  108. it('submits a writing mapping once its category and evidence are complete',async()=>{
  109. const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:' 已核实为小学 ',updatedAt:'2026-09-02T12:00:00.000Z'};
  110. service.mutate.and.resolveTo({updated:true});
  111. await component.saveWritingMapping(row);
  112. expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z');
  113. expect(service.request).toHaveBeenCalledWith('ops/course-mappings/students',{month:component.month()});
  114. expect(service.payrollOverview).toHaveBeenCalled();
  115. expect(component.message()).toBe('该预约的学段分类已确认,费用统计已刷新');
  116. });
  117. it('ignores an older batch page response',async()=>{
  118. let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));
  119. const old=component.loadBatchPage(2);await component.loadBatchPage(3);resolveOld({items:[],total:50,page:2,pageSize:20});await old;expect(component.page()?.page).toBe(3);
  120. });
  121. it('does not create a payroll draft when the legacy source is not settled',async()=>{
  122. component.overview.set(overview('2026-09'));
  123. await component.create();expect(service.mutate).not.toHaveBeenCalled();expect(component.error()).toContain('旧系统数据尚未追平');
  124. });
  125. });
  126. describe('payroll progressive readiness',()=>{
  127. let component:OperationsPayrollComponent;
  128. let ops:jasmine.SpyObj<OperationsService>;
  129. const value=(month:string,ready=false)=>({month,lines:[],coachItems:[],stores:[],sourceStatus:{sync:{settlementReady:ready}}} as unknown as PayrollOverview);
  130. beforeEach(()=>{
  131. ops=jasmine.createSpyObj<OperationsService>('ops',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate']);
  132. ops.payrolls.and.resolveTo({items:[]} as never);ops.payRates.and.resolveTo({items:[]} as never);ops.teachingStandards.and.resolveTo({mappings:[],rules:[],categories:[]} as never);ops.request.and.resolveTo({items:[]});
  133. TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:AdminSessionService,useValue:{isStore:()=>false,session:()=>({operationsRole:'ops-manager'})}}]});
  134. component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
  135. });
  136. it('shows real amounts while verification is pending and never authorizes settlement early',async()=>{
  137. let finish!:(v:PayrollOverview)=>void;
  138. ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):new Promise(resolve=>finish=resolve));
  139. const pending=component.load(true,false,true);
  140. for(let i=0;i<8;i++)await Promise.resolve();
  141. expect(component.overview()).not.toBeNull();expect(component.loading()).toBeFalse();expect(component.verifying()).toBeTrue();
  142. await component.create();expect(ops.mutate).not.toHaveBeenCalled();
  143. finish(value(component.month(),true));await pending;
  144. expect(component.verifying()).toBeFalse();expect(component.overview()?.sourceStatus.sync?.settlementReady).toBeTrue();
  145. });
  146. it('retains inspectable amounts but keeps settlement closed after verification fails',async()=>{
  147. ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):Promise.reject(new Error('timeout')));
  148. await component.load(true,false,true);expect(component.overview()).not.toBeNull();expect(component.overview()?.sourceStatus.sync?.settlementReady).toBeFalse();expect(component.error()).toContain('核验失败');
  149. });
  150. it('does not let a previous month verification replace the newly selected month',async()=>{
  151. let old!:(v:PayrollOverview)=>void;
  152. ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):month==='2026-09'?new Promise(resolve=>old=resolve):Promise.resolve(value(month,true)));
  153. component.month.set('2026-09');const first=component.load(true,false,true);for(let i=0;i<8;i++)await Promise.resolve();
  154. component.month.set('2026-10');await component.load(true,false,true);old(value('2026-09',true));await first;expect(component.overview()?.month).toBe('2026-10');
  155. });
  156. });