operations-payroll.component.ts 27 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import { DropdownDirective } from '../shared/dropdown.directive';
  2. import { ActivatedRoute } from '@angular/router';
  3. import { ReviewRewardsComponent } from './review-rewards.component';
  4. import { ReviewRewardFilters } from '../admin.models';
  5. import { CommonModule } from '@angular/common';
  6. import { ChangeDetectionStrategy, Component, HostListener, OnInit, computed, inject, signal } from '@angular/core';
  7. import { FormsModule } from '@angular/forms';
  8. import { Banknote, Brain, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, Search, Settings2, TrendingUp, X } from 'lucide-angular';
  9. import { utils, writeFile } from 'xlsx';
  10. import { CourseTeachingMapping, CreditReservationReconciliation, OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary, TeachingCourseCategory, TeachingCreditAccount, TeachingRule, TeachingStageKey, TeachingStandardsData } from '../admin.models';
  11. import { AdminSessionService } from '../admin-session.service';
  12. import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
  13. import { OperationsService } from '../operations.service';
  14. const localDateKey=(value:Date):string=>value.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
  15. export const TEACHING_STAGE_OPTIONS:Array<{key:TeachingStageKey;name:string}>=[{key:'primary',name:'小学'},{key:'middle',name:'初中'},{key:'high',name:'高中'}];
  16. const TEACHING_CATEGORY_STAGE:Record<string,TeachingStageKey|undefined>={primary_writing:'primary',middle_writing:'middle',high_writing:'high'};
  17. const TEACHING_CATEGORY_DURATIONS:Record<string,number[]>={word:[30,60],trial:[60],primary_writing:[30,60],middle_writing:[30,60],high_writing:[30,60],word_self_study:[60]};
  18. export interface TeachingRateMatrixRow{categoryKey:string;categoryName:string;durationMinutes:number;online?:TeachingRule;offline?:TeachingRule;}
  19. export function teachingRateMatrix(rules:TeachingRule[],categories:TeachingCourseCategory[],date=localDateKey(new Date())):TeachingRateMatrixRow[]{const rows:TeachingRateMatrixRow[]=[];for(const category of categories.filter((row)=>row.active)){for(const durationMinutes of TEACHING_CATEGORY_DURATIONS[category.key]||[]){const applicable=rules.filter((rule)=>rule.active&&rule.categoryKey===category.key&&rule.durationMinutes===durationMinutes&&rule.effectiveFrom<=date).sort((a,b)=>b.effectiveFrom.localeCompare(a.effectiveFrom)||Number(a.isDefault)-Number(b.isDefault));rows.push({categoryKey:category.key,categoryName:category.name,durationMinutes,online:applicable.find((rule)=>rule.deliveryMode==='online'),offline:applicable.find((rule)=>rule.deliveryMode==='offline')});}}return rows;}
  20. interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string}
  21. @Component({selector:'app-operations-payroll',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent,OperationsDateFilterComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
  22. export class OperationsPayrollComponent implements OnInit{
  23. readonly adjustmentCoaches=signal<Array<{coachId:number;coachName:string}>>([]);
  24. readonly writingRows=signal<WritingMappingRow[]>([]);
  25. readonly allBatchMonths=signal(true);private loadRequest=0;private batchRequest=0;private openRequest=0;private resetRevision=0;private pendingCommand:{signature:string;id:string}|null=null;
  26. private async payrollCommand<T>(operation:string,target:string,reason:string,payload:Record<string,unknown>={},updatedAt=''):Promise<T>{
  27. const body={...payload,version:this.detail()?.version};const signature=JSON.stringify({operation,target,reason,body});
  28. if(this.pendingCommand?.signature!==signature)this.pendingCommand={signature,id:crypto.randomUUID()};
  29. const result=await this.operations.mutate<T>(operation,target,reason,body,updatedAt,this.pendingCommand.id);this.pendingCommand=null;return result;
  30. }
  31. async loadBatchPage(page:number):Promise<void>{const request=++this.batchRequest;try{const result=await this.operations.payrolls(page,20,this.allBatchMonths()?'':this.month());if(request===this.batchRequest)this.page.set(result);}catch(e){if(request===this.batchRequest)this.error.set(e instanceof Error?e.message:'批次加载失败');}}
  32. async loadWritingMappings():Promise<void>{this.standardsSection.set('students');try{const result=await this.operations.request<{items:WritingMappingRow[]}>('ops/course-mappings/students');this.writingRows.set(result.items);}catch(e){this.error.set(e instanceof Error?e.message:'学段核对加载失败');}}
  33. async saveWritingMapping(row:WritingMappingRow):Promise<void>{if(this.saving())return;if(!row.categoryKey){this.error.set(`请先为“${row.studentName}”选择对应分类`);return;}if(row.evidence.trim().length<2){this.error.set(`请填写“${row.studentName}”的核对依据,例如“学生档案为小学五年级”`);return;}this.error.set('');this.saving.set(true);try{await this.operations.mutate('ops/course-mappings/student-commit',row.appointmentId,'按学员记录核对语法写作分类',{categoryKey:row.categoryKey,evidence:row.evidence.trim()},row.updatedAt);await this.loadWritingMappings();this.message.set('该预约的学段分类已确认');}catch(e){this.error.set(e instanceof Error?e.message:'确认失败');}finally{this.saving.set(false);}}
  34. readonly overview=signal<PayrollOverview|null>(null);readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly standards=signal<TeachingStandardsData|null>(null);readonly reservationReconciliation=signal<CreditReservationReconciliation|null>(null);readonly reconciliation=signal<{items:Array<{appointmentId:string;appointmentGeneralId:number;studentId:number;lessonAt:string;durationMinutes:number;expected:number;charged:number;adjustment:number;status:string}>;summary:{completedReviewCount:number;differenceCount:number;refundTotal:number};refreshedAt:string}|null>(null);readonly loading=signal(true);readonly refreshing=signal(false);readonly saving=signal(false);readonly savingMappingId=signal(0);readonly error=signal('');readonly message=signal('');readonly tab=signal<'overview'|'rewards'|'batches'|'rates'>('overview');readonly standardsSection=signal<'rules'|'mappings'|'students'|'categories'|'reservations'|'reconciliation'>('rules');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly monthPickerOpen=signal(false);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];readonly storeId=signal(0);readonly search=signal('');readonly selectedCoachId=signal(0);readonly selectedCoachName=signal('');readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly ruleCategoryKey=signal('word');readonly ruleDuration=signal(30);readonly ruleDelivery=signal<'online'|'offline'>('online');readonly rulePay=signal(15);readonly mappingSelections=signal<Record<number,string>>({});readonly mappingSearch=signal('');readonly stageOptions=TEACHING_STAGE_OPTIONS;readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly paymentMethod=signal('银行转账');readonly paymentReference=signal('');readonly icons={Banknote,Brain,CalendarDays,ChevronDown,ChevronLeft,ChevronRight,Download,Eye,FileCheck2,Plus,RefreshCw,Search,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),coachName=this.selectedCoachName(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId).map((line)=>({...line,coachName:coachName||line.coachName})):source;});readonly activeCategories=computed(()=>(this.standards()?.categories||[]).filter((row)=>row.active));readonly ruleDurations=computed(()=>TEACHING_CATEGORY_DURATIONS[this.ruleCategoryKey()]||[]);readonly standardMatrix=computed(()=>teachingRateMatrix(this.standards()?.rules||[],this.standards()?.categories||[]));readonly filteredMappings=computed(()=>{const keyword=this.mappingSearch().trim().toLowerCase();return(this.standards()?.mappings||[]).filter((row)=>!keyword||[row.courseName,row.courseId,row.stageName,row.categoryName,row.suggestedCategoryName].some((value)=>String(value||'').toLowerCase().includes(keyword)));});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
  35. readonly rewardCoachId=signal(0);readonly rewardFilters=signal<ReviewRewardFilters>({month:this.month(),storeId:0,search:'',coachId:0});private readonly route=inject(ActivatedRoute,{optional:true});
  36. ngOnInit():void{const query=this.route?.snapshot.queryParamMap;if(query?.get('tab')==='rewards'){this.rewardCoachId.set(Number(query.get('coachId'))||0);this.openRewards();}void this.load(true);}
  37. openRewards():void{this.tab.set('rewards');this.rewardFilters.set({month:this.month(),storeId:this.storeId(),search:this.search(),coachId:this.rewardCoachId(),resetRevision:this.resetRevision,revision:(this.rewardFilters().revision||0)+1});}
  38. @HostListener('document:click') closeMonthPicker():void{this.monthPickerOpen.set(false);}
  39. @HostListener('document:keydown.escape') closeMonthPickerWithKeyboard():void{this.closeMonthPicker();}
  40. toggleMonthPicker(event:Event):void{event.stopPropagation();if(this.monthPickerOpen()){this.closeMonthPicker();return;}this.monthPickerYear.set(Number(this.month().slice(0,4))||new Date().getFullYear());this.monthPickerOpen.set(true);}
  41. shiftMonthPickerYear(delta:number,event:Event):void{event.stopPropagation();this.monthPickerYear.update(year=>year+delta);}
  42. selectMonth(monthIndex:number):void{this.month.set(this.monthOptionValue(monthIndex));this.clearMonthContext();this.closeMonthPicker();}
  43. selectCurrentMonth():void{this.month.set(localDateKey(new Date()).slice(0,7));this.clearMonthContext();this.closeMonthPicker();}
  44. monthLabel():string{return this.monthLabelOf(this.month());}
  45. private monthLabelOf(value:string):string{const [year,month]=value.split('-');return year&&month?`${year}年${Number(month)}月`:'选择月份';}
  46. private clearMonthContext():void{this.message.set('');this.error.set('');this.closeDrawer();}
  47. monthOptionValue(monthIndex:number):string{return `${this.monthPickerYear()}-${String(monthIndex+1).padStart(2,'0')}`;}
  48. clearRewardCoach():void{this.rewardCoachId.set(0);this.openRewards();}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
  49. async load(refresh=false):Promise<void>{
  50. const request=++this.loadRequest,batchRequest=++this.batchRequest,query={month:this.month(),storeId:this.storeId(),search:this.search()};this.loading.set(true);this.error.set('');
  51. try{
  52. const results=await Promise.allSettled([this.operations.payrollOverview(query.month,query.storeId,query.search,refresh),this.operations.payrolls(1,20,this.allBatchMonths()?'':query.month),this.operations.payRates(),this.operations.teachingStandards(),this.operations.request<{items:Array<{coachId:number;coachName:string}>}>('ops/payroll/coaches')]);
  53. if(request!==this.loadRequest)return;
  54. const [overview,page,rates,standards,coaches]=results;
  55. if(overview.status==='fulfilled')this.overview.set(overview.value);
  56. if(page.status==='fulfilled'&&batchRequest===this.batchRequest)this.page.set(page.value);
  57. if(rates.status==='fulfilled')this.rates.set(rates.value.items);
  58. if(standards.status==='fulfilled'){this.standards.set(standards.value);this.mappingSelections.set(Object.fromEntries(standards.value.mappings.map(row=>[row.courseId,row.categoryKey||row.suggestedCategoryKey])));}
  59. if(coaches.status==='fulfilled')this.adjustmentCoaches.set(coaches.value.items);
  60. const failures=results.flatMap((result,index)=>result.status==='rejected'?[['收益统计','工资批次','历史单价','工资标准','老师列表'][index]+': '+(result.reason instanceof Error?result.reason.message:'加载失败')]:[]);
  61. if(failures.length)this.error.set(failures.join(';')+'。对应区域保留上次成功结果(如有),请重试。');
  62. }finally{if(request===this.loadRequest)this.loading.set(false);}
  63. }
  64. async refreshLive():Promise<void>{if(this.tab()==='rewards'){this.openRewards();return;}await this.load(true);}
  65. async resetFilters():Promise<void>{this.resetRevision++;this.closeDrawer();this.search.set('');this.storeId.set(0);this.rewardCoachId.set(0);await this.applyFilters();}
  66. async applyFilters():Promise<void>{this.message.set('');if(this.tab()==='batches')this.allBatchMonths.set(false);if(this.tab()==='rewards'){this.openRewards();return;}await this.load(true);}
  67. async open(row:PayrollSummary):Promise<void>{const request=++this.openRequest;this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);const detail=await this.operations.payrollDetail(row.objectId);if(request===this.openRequest)this.detail.set(detail);}catch(error){if(request===this.openRequest)this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{if(request===this.openRequest)this.loading.set(false);}}
  68. coachLabel(name:string,coachId:number):string{const value=String(name||'').trim();return !value||value===String(coachId)?`陪练 ${coachId}`:value;}
  69. openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.selectedCoachName.set(this.coachLabel(row.coachName,row.coachId));this.detail.set(null);}closeDrawer():void{this.openRequest++;this.detail.set(null);this.selectedCoachId.set(0);this.selectedCoachName.set('');}
  70. async create():Promise<void>{if(this.saving())return;if(!this.overview()?.sourceStatus.available){this.error.set('实时上课记录不可用,不能生成工资草稿');return;}const generatedMonth=this.month();this.saving.set(true);this.error.set('');try{const result=await this.payrollCommand<PayrollSummary>('ops/payroll/create','',`生成 ${generatedMonth} 月全公司工资草稿`,{month:generatedMonth});await this.load();await this.open(result);this.message.set(`${this.monthLabelOf(generatedMonth)}全公司工资草稿已生成,尚未审核或发放`);this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
  71. async saveRate():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
  72. creditCosts(categoryKey=this.ruleCategoryKey(),duration=this.ruleDuration()):Array<{account:TeachingCreditAccount;amount:number}>{if(categoryKey==='trial')return[{account:'UserExp',amount:1}];if(categoryKey==='review')return[{account:'UserPoint',amount:duration===30?.5:1}];return duration===30?[{account:'Purse',amount:1},{account:'UserPoint',amount:.5}]:[{account:'SilverCoin',amount:1},{account:'UserPoint',amount:1}];}
  73. creditSummary(rule:TeachingRule):string{return rule.creditCosts.map((item)=>`${item.label} -${item.amount}`).join('+');}
  74. editMatrixRule(row:TeachingRateMatrixRow,delivery:'online'|'offline'):void{const rule=row[delivery];this.ruleCategoryKey.set(row.categoryKey);this.ruleDuration.set(row.durationMinutes);this.ruleDelivery.set(delivery);this.rulePay.set(rule?.teacherPay||0);this.effectiveFrom.set(localDateKey(new Date()));}
  75. updateRuleCategory(categoryKey:string):void{this.ruleCategoryKey.set(categoryKey);const durations=TEACHING_CATEGORY_DURATIONS[categoryKey]||[];if(!durations.includes(this.ruleDuration()))this.ruleDuration.set(durations[0]||30);}
  76. mappingCategories(row:CourseTeachingMapping):TeachingCourseCategory[]{if(!row.stageConfirmed&&/语法|写作|作文|强化/.test(row.courseName))return [];return this.activeCategories().filter((category)=>!TEACHING_CATEGORY_STAGE[category.key]||TEACHING_CATEGORY_STAGE[category.key]===row.stageKey);}
  77. mappingCategoryUnavailable(row:CourseTeachingMapping):boolean{return Boolean(row.categoryKey&&!this.mappingCategories(row).some((category)=>category.key===row.categoryKey));}
  78. setMapping(courseId:number,categoryKey:string):void{this.mappingSelections.update((value)=>({...value,[courseId]:categoryKey}));}
  79. async saveTeachingRule():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/teaching-rules/save','',`调整 ${this.ruleCategoryKey()} ${this.ruleDuration()}分钟${this.ruleDelivery()==='online'?'线上':'线下'}标准`,{categoryKey:this.ruleCategoryKey(),durationMinutes:this.ruleDuration(),deliveryMode:this.ruleDelivery(),teacherPay:this.rulePay(),creditCosts:this.creditCosts(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('工资与课时规则已保存,历史版本已保留');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'规则保存失败');}finally{this.saving.set(false);}}
  80. async deactivateTeachingRule(rule:TeachingRule):Promise<void>{if(rule.isDefault||!rule.active)return;if(!globalThis.confirm(`确定停用“${rule.categoryName} ${rule.durationMinutes}分钟 ${rule.deliveryLabel}”从 ${rule.effectiveFrom} 生效的规则吗?停用后,找不到其他有效版本的课程将无法排课。`))return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/teaching-rules/deactivate',rule.objectId,`停用 ${rule.categoryName} ${rule.durationMinutes}分钟${rule.deliveryLabel}规则`,{},rule.updatedAt);this.message.set('规则已停用,历史版本和既有预约快照保持不变');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'规则停用失败');}finally{this.saving.set(false);}}
  81. async confirmMapping(row:CourseTeachingMapping):Promise<void>{const categoryKey=this.mappingSelections()[row.courseId];if(!this.mappingCategories(row).some((category)=>category.key===categoryKey)){this.error.set('请按课程学段选择有效分类;通用语法写作课程需按学员学段分别确认');return;}if(!categoryKey){this.error.set('请确认课程分类');return;}this.saving.set(true);this.savingMappingId.set(row.courseId);this.error.set('');this.message.set('');try{await this.operations.mutate('ops/course-mappings/commit','',`确认课程「${row.courseName}」的标准分类`,{items:[{courseId:row.courseId,categoryKey}]});const standards=await this.operations.teachingStandards();this.standards.set(standards);this.mappingSelections.set(Object.fromEntries(standards.mappings.map((item)=>[item.courseId,item.categoryKey||item.suggestedCategoryKey])));this.message.set('课程分类已确认');}catch(error){this.error.set(error instanceof Error?error.message:'课程映射保存失败');}finally{this.savingMappingId.set(0);this.saving.set(false);}}
  82. async previewReservations():Promise<void>{this.saving.set(true);this.error.set('');try{this.reservationReconciliation.set(await this.operations.mutate<CreditReservationReconciliation>('ops/credit-reservations/preview','','预览历史未来预约课时预留',{}));}catch(error){this.error.set(error instanceof Error?error.message:'历史预约预留核对失败');}finally{this.saving.set(false);}}
  83. async commitReservations():Promise<void>{const data=this.reservationReconciliation();if(!data?.summary.total)return;this.saving.set(true);this.error.set('');try{const result=await this.operations.mutate<{reserved:number;held:number}>('ops/credit-reservations/commit','','确认历史未来预约课时预留',{appointmentIds:data.items.map((item)=>item.appointmentId)});this.message.set(`已补建 ${result.reserved} 条课时预留,${result.held} 条预约已标记为待处理`);await this.previewReservations();}catch(error){this.error.set(error instanceof Error?error.message:'历史预约预留确认失败');}finally{this.saving.set(false);}}
  84. async previewReconciliation():Promise<void>{this.saving.set(true);this.error.set('');try{this.reconciliation.set(await this.operations.mutate('ops/credit-reconciliation/preview','', '查看2026年9月起复习课课时差异',{}));}catch(error){this.error.set(error instanceof Error?error.message:'课时差异预览失败');}finally{this.saving.set(false);}}
  85. async commitReconciliation():Promise<void>{const data=this.reconciliation();if(!data?.summary.differenceCount)return;this.saving.set(true);this.error.set('');try{const result=await this.operations.mutate<{committed:number;refundTotal:number}>('ops/credit-reconciliation/commit','', '确认2026年9月起复习课课时差异退回',{appointmentIds:data.items.filter((item)=>item.adjustment>0).map((item)=>item.appointmentId)});this.message.set(`已处理 ${result.committed} 条差异,退回陪练时长 ${result.refundTotal}`);await this.previewReconciliation();}catch(error){this.error.set(error instanceof Error?error.message:'课时差异确认失败');}finally{this.saving.set(false);}}
  86. async addAdjustment():Promise<void>{if(this.saving())return;const batch=this.detail();if(!batch||!this.adjustmentCoachId()||!this.adjustmentAmount()||!this.adjustmentReason().trim()){this.error.set('请选择陪练并填写非零调整金额和原因');return;}this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/add-adjustment',batch.objectId,this.adjustmentReason(),{coachId:this.adjustmentCoachId(),amount:this.adjustmentAmount(),description:this.adjustmentReason()},batch.updatedAt);await this.open(batch);this.adjustmentAmount.set(0);this.adjustmentReason.set('');this.message.set('调整项已保存');}catch(error){this.error.set(error instanceof Error?error.message:'调整项保存失败');}finally{this.saving.set(false);}}
  87. async refreshDraft():Promise<void>{if(this.saving())return;const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.payrollCommand<PayrollDetail>('ops/payroll/refresh',batch.objectId,'刷新实时上课记录并增量重算工资草稿',{},batch.updatedAt));this.message.set('工资草稿已吸收新增上课记录并重新计算');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'工资草稿刷新失败');}finally{this.saving.set(false);}}
  88. async excludeLine(objectId:string):Promise<void>{const batch=this.detail(),reason=this.adjustmentReason().trim();if(!batch||batch.status!=='draft')return;if(!reason){this.error.set('请先在调整原因中填写排除该异常记录的原因');return;}this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/exclude-line',objectId,reason,{},'');await this.open(batch);this.message.set('异常记录已排除并保留审计原因');}catch(error){this.error.set(error instanceof Error?error.message:'异常记录排除失败');}finally{this.saving.set(false);}}
  89. async review():Promise<void>{if(this.saving())return;const batch=this.detail();if(!batch)return;this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/review',batch.objectId,'实时刷新完成,复核工资明细并永久锁定',{},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资单已审核锁定');}catch(error){this.error.set(error instanceof Error?error.message:'工资审核失败');}finally{this.saving.set(false);}}
  90. async markPaid():Promise<void>{if(this.saving())return;const batch=this.detail();if(!batch||!this.paymentMethod().trim()||!this.paymentReference().trim()){this.error.set('请填写发放方式和凭证编号');return;}this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/mark-paid',batch.objectId,'确认工资已线下发放',{paymentMethod:this.paymentMethod(),paymentReference:this.paymentReference()},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资发放信息已登记');}catch(error){this.error.set(error instanceof Error?error.message:'发放登记失败');}finally{this.saving.set(false);}}
  91. async exportExcel():Promise<void>{this.saving.set(true);this.error.set('');try{const batch=this.detail(),data=await this.operations.payrollExport(this.month(),batch?.objectId||'',this.storeId(),this.search(),this.selectedCoachId());const summary=data.coachItems.map((row)=>({'统计口径':batch?'工资批次结算':'当月收入(不含批次调整)','陪练老师':this.coachLabel(row.coachName,row.coachId),'门店':row.storeName,'30分钟课':row.type30Count,'60分钟课':row.type60Count,'体验课':row.trialCount,'课型4':row.type4Count,'总课次':row.lessonCount,'工时':row.totalHours,'基础工资':row.baseAmount,'有效复习次数':row.reviewCount||0,'抗遗忘工资':row.reviewAmount||0,'调整':row.adjustmentAmount,'应发金额':row.payableAmount}));const details=data.lines.map((line)=>this.exportLine(line)),exceptions=data.exceptions.map((line)=>({...this.exportLine(line),'异常原因':line.exception}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(summary),'老师汇总');utils.book_append_sheet(book,utils.json_to_sheet(details),'计薪明细');utils.book_append_sheet(book,utils.json_to_sheet(exceptions),'异常记录');utils.book_append_sheet(book,utils.json_to_sheet((data.excluded||[]).map(line=>({...this.exportLine(line),'排除原因':line['exclusionReason']}))),'已排除记录');utils.book_append_sheet(book,utils.json_to_sheet([{'课酬':data.summary.baseAmount,'抗遗忘工资':data.summary.reviewAmount||0,'人工调整':data.summary.adjustmentAmount,'应发合计':data.summary.payableAmount}]),'金额核对');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
  92. private exportLine(line:PayrollLine):Record<string,unknown>{return{'明细类型':line.kind==='review_reward'?'抗遗忘工资':line.kind==='adjustment'?'人工调整':'课酬','计薪凭据':line.reviewRewardId||'','复习任务':line.reviewTaskId||'','计划复习日期':line.plannedDate||'','来源记录ID':line.legacyGeneralId,'上课日期':line.lessonAt?new Date(line.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'','结算月份':line.settlementMonth,'原上课月份':line.originalMonth,'陪练老师':this.coachLabel(line.coachName,line.coachId),'门店':line.storeName,'学员':line.studentName,'课程':line.courseName,'课程分类':line.kind==='review_reward'?'抗遗忘工资':line.courseCategoryName||line.classTypeLabel,'授课方式':line.deliveryLabel||'未记录','时长':line.kind==='review_reward'?'—':line.durationMinutes?`${line.durationMinutes}分钟`:line.classTypeLabel,'工时':line.durationHours,'单价':line.rateAmount,'金额':line.amount,'说明':line.description};}
  93. }