| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- import { TeachingCourseCategory, TeachingRule } from '../admin.models';
- import { TEACHING_STAGE_OPTIONS, teachingRateMatrix } from './operations-payroll.component';
- describe('operations payroll teaching standards', () => {
- it('uses the three standard school stages when presenting database course ancestry', () => {
- expect(TEACHING_STAGE_OPTIONS.map((stage) => stage.name)).toEqual(['小学', '初中', '高中']);
- });
- it('builds the current online and offline price matrix without using an older version', () => {
- const categories = [
- { key: 'trial', name: '体验训练', sortOrder: 10, active: true },
- { key: 'word_self_study', name: '单词自学课', sortOrder: 20, active: true },
- ] as TeachingCourseCategory[];
- const rule = (categoryKey: string, deliveryMode: 'online' | 'offline', teacherPay: number, effectiveFrom: string): TeachingRule => ({
- objectId: `${categoryKey}:${deliveryMode}:${effectiveFrom}`,
- categoryKey,
- categoryName: categories.find((category) => category.key === categoryKey)?.name || categoryKey,
- durationMinutes: 60,
- deliveryMode,
- deliveryLabel: deliveryMode === 'online' ? '线上' : '线下',
- teacherPay,
- creditCosts: [],
- effectiveFrom,
- active: true,
- updatedAt: '',
- });
- const matrix = teachingRateMatrix([
- rule('trial', 'online', 40, '2026-09-01'),
- rule('trial', 'online', 30, '2026-09-09'),
- rule('trial', 'offline', 40, '2026-09-09'),
- rule('word_self_study', 'online', 20, '2026-09-09'),
- rule('word_self_study', 'offline', 30, '2026-09-09'),
- ], categories, '2026-09-09');
- expect(matrix.map((row) => [row.categoryName, row.durationMinutes, row.online?.teacherPay, row.offline?.teacherPay])).toEqual([
- ['体验训练', 60, 30, 40],
- ['单词自学课', 60, 20, 30],
- ]);
- });
- });
- import { TestBed } from '@angular/core/testing';
- import { OperationsPayrollComponent } from './operations-payroll.component';
- import { OperationsService } from '../operations.service';
- import { AdminSessionService } from '../admin-session.service';
- import { PayrollOverview, PayrollSummary } from '../admin.models';
- describe('payroll interactions', () => {
- let component:OperationsPayrollComponent;
- let service:jasmine.SpyObj<OperationsService>;
- const overview=(month:string)=>({month,lines:[],coachItems:[],sourceStatus:{available:true,mode:'parse'},stores:[]} as unknown as PayrollOverview);
- beforeEach(()=>{
- service=jasmine.createSpyObj('OperationsService',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate','payrollDetail']);
- 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});
- service.teachingStandards.and.resolveTo({categories:[],rules:[],mappings:[],summary:{courseCount:0,confirmedCount:0,unmappedCount:0,activeRuleCount:0},refreshedAt:''});service.request.and.resolveTo({items:[]});
- TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:service},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});
- component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
- });
- it('loads exact coach details and ignores a late response after switching teacher',async()=>{
- let old!:(v:PayrollOverview)=>void;const current={...overview('2026-09'),payableAmount:70,lines:[{coachId:2,amount:70}]} as PayrollOverview;
- service.payrollOverview.and.returnValues(new Promise(r=>old=r),Promise.resolve(current));
- 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;
- 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});
- });
- it('keeps failed details explicit and never replaces overview after closing drawer',async()=>{
- service.payrollOverview.and.rejectWith(new Error('明细服务暂不可用'));await component.openCoach({coachId:1,coachName:'one'});expect(component.coachError()).toContain('暂不可用');expect(component.coachLoading()).toBeFalse();
- 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);
- });
- it('refetches an open coach when an older in-flight page load applies its summary',async()=>{
- let finish!:(v:PayrollOverview)=>void;const current={...overview('2026-09'),lines:[{coachId:2,amount:70}]} as PayrollOverview;
- service.payrollOverview.and.returnValues(new Promise(r=>finish=r),Promise.resolve(current),Promise.resolve(current));
- const load=component.load();await component.openCoach({coachId:2,coachName:'two'});finish(overview('2026-09'));await load;await Promise.resolve();
- expect(component.coachLines().length).toBe(1);expect(service.payrollOverview.calls.count()).toBe(3);
- });
- it('preserves the active batch drawer on background refresh',async()=>{
- const batch={objectId:'batch',lines:[]} as never;component.detail.set(batch);await component.load(false,true);expect(component.detail()).toBe(batch);
- });
- it('releases batch loading when a coach supersedes the pending batch request',async()=>{
- component.loading.set(false);let finish!:(v:any)=>void;service.payrollDetail.and.returnValue(new Promise(r=>finish=r));
- 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;
- expect(component.loading()).toBeFalse();expect(component.batchLoading()).toBeFalse();expect(component.detail()).toBeNull();
- });
- it('ignores an older query response after month changes',async()=>{
- let resolveOld!:(v:PayrollOverview)=>void;service.payrollOverview.and.returnValues(new Promise(r=>resolveOld=r),Promise.resolve(overview('2026-10')));
- 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');
- });
- it('reset keeps month and clears search, store and the teacher deep link',async()=>{
- 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}));
- });
- it('selects payroll months through the shared calendar-style picker',()=>{
- 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();
- });
- it('retains adjustment request identity after a lost response and sends batch version',async()=>{
- 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('网络中断'));
- 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}));
- });
- it('does not submit an adjustment twice while saving',async()=>{
- component.saving.set(true);await component.addAdjustment();expect(service.mutate).not.toHaveBeenCalled();
- });
- it('loads batch pages independently of income and teacher filters',async()=>{
- 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');
- });
- it('keeps successful regions when a secondary request fails',async()=>{
- service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用'));
- await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('费用标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
- });
- it('explains missing writing evidence before submitting a mapping',async()=>{
- 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'};
- await component.saveWritingMapping(row);
- expect(service.mutate).not.toHaveBeenCalled();
- expect(component.error()).toContain('请填写“灵灵”的核对依据');
- });
- it('submits a writing mapping once its category and evidence are complete',async()=>{
- 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'};
- service.mutate.and.resolveTo({updated:true});
- await component.saveWritingMapping(row);
- expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z');
- expect(service.request).toHaveBeenCalledWith('ops/course-mappings/students',{month:component.month()});
- expect(service.payrollOverview).toHaveBeenCalled();
- expect(component.message()).toBe('该预约的学段分类已确认,费用统计已刷新');
- });
- it('ignores an older batch page response',async()=>{
- 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}));
- 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);
- });
- it('does not create a payroll draft when the legacy source is not settled',async()=>{
- component.overview.set(overview('2026-09'));
- await component.create();expect(service.mutate).not.toHaveBeenCalled();expect(component.error()).toContain('旧系统数据尚未追平');
- });
- });
- describe('payroll progressive readiness',()=>{
- let component:OperationsPayrollComponent;
- let ops:jasmine.SpyObj<OperationsService>;
- const value=(month:string,ready=false)=>({month,lines:[],coachItems:[],stores:[],sourceStatus:{sync:{settlementReady:ready}}} as unknown as PayrollOverview);
- beforeEach(()=>{
- ops=jasmine.createSpyObj<OperationsService>('ops',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate']);
- 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:[]});
- TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:AdminSessionService,useValue:{isStore:()=>false,session:()=>({operationsRole:'ops-manager'})}}]});
- component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
- });
- it('shows real amounts while verification is pending and never authorizes settlement early',async()=>{
- let finish!:(v:PayrollOverview)=>void;
- ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):new Promise(resolve=>finish=resolve));
- const pending=component.load(true,false,true);
- for(let i=0;i<8;i++)await Promise.resolve();
- expect(component.overview()).not.toBeNull();expect(component.loading()).toBeFalse();expect(component.verifying()).toBeTrue();
- await component.create();expect(ops.mutate).not.toHaveBeenCalled();
- finish(value(component.month(),true));await pending;
- expect(component.verifying()).toBeFalse();expect(component.overview()?.sourceStatus.sync?.settlementReady).toBeTrue();
- });
- it('retains inspectable amounts but keeps settlement closed after verification fails',async()=>{
- ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):Promise.reject(new Error('timeout')));
- await component.load(true,false,true);expect(component.overview()).not.toBeNull();expect(component.overview()?.sourceStatus.sync?.settlementReady).toBeFalse();expect(component.error()).toContain('核验失败');
- });
- it('does not let a previous month verification replace the newly selected month',async()=>{
- let old!:(v:PayrollOverview)=>void;
- 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)));
- component.month.set('2026-09');const first=component.load(true,false,true);for(let i=0;i<8;i++)await Promise.resolve();
- 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');
- });
- });
|