operations-payroll.component.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  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('ignores an older query response after month changes',async()=>{
  55. let resolveOld!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValues(new Promise(r=>resolveOld=r),Promise.resolve(overview('2026-10')));
  56. 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');
  57. });
  58. it('reset keeps month and clears search, store and the teacher deep link',async()=>{
  59. 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}));
  60. });
  61. it('selects payroll months through the shared calendar-style picker',()=>{
  62. 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();
  63. });
  64. it('retains adjustment request identity after a lost response and sends batch version',async()=>{
  65. 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('网络中断'));
  66. 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}));
  67. });
  68. it('does not submit an adjustment twice while saving',async()=>{
  69. component.saving.set(true);await component.addAdjustment();expect(service.mutate).not.toHaveBeenCalled();
  70. });
  71. it('loads batch pages independently of income and teacher filters',async()=>{
  72. 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');
  73. });
  74. it('keeps successful regions when a secondary request fails',async()=>{
  75. service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用'));
  76. await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('费用标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
  77. });
  78. it('explains missing writing evidence before submitting a mapping',async()=>{
  79. 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'};
  80. await component.saveWritingMapping(row);
  81. expect(service.mutate).not.toHaveBeenCalled();
  82. expect(component.error()).toContain('请填写“灵灵”的核对依据');
  83. });
  84. it('submits a writing mapping once its category and evidence are complete',async()=>{
  85. 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'};
  86. service.mutate.and.resolveTo({updated:true});
  87. await component.saveWritingMapping(row);
  88. expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z');
  89. expect(service.request).toHaveBeenCalledWith('ops/course-mappings/students',{month:component.month()});
  90. expect(service.payrollOverview).toHaveBeenCalled();
  91. expect(component.message()).toBe('该预约的学段分类已确认,费用统计已刷新');
  92. });
  93. it('ignores an older batch page response',async()=>{
  94. 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}));
  95. 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);
  96. });
  97. it('does not create a payroll draft when the legacy source is not settled',async()=>{
  98. component.overview.set(overview('2026-09'));
  99. await component.create();expect(service.mutate).not.toHaveBeenCalled();expect(component.error()).toContain('旧系统数据尚未追平');
  100. });
  101. });